diff --git a/.core_files.yaml b/.core_files.yaml
index 6fd3a74df92..2624c4432be 100644
--- a/.core_files.yaml
+++ b/.core_files.yaml
@@ -6,6 +6,7 @@ core: &core
- homeassistant/helpers/**
- homeassistant/package_constraints.txt
- homeassistant/util/**
+ - mypy.ini
- pyproject.toml
- requirements.txt
- setup.cfg
@@ -131,6 +132,7 @@ tests: &tests
- tests/components/conftest.py
- tests/components/diagnostics/**
- tests/components/history/**
+ - tests/components/light/common.py
- tests/components/logbook/**
- tests/components/recorder/**
- tests/components/repairs/**
diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml
index 7c08df39000..20b1bd4c718 100644
--- a/.github/workflows/builder.yml
+++ b/.github/workflows/builder.yml
@@ -10,7 +10,7 @@ on:
env:
BUILD_TYPE: core
- DEFAULT_PYTHON: "3.12"
+ DEFAULT_PYTHON: "3.13"
PIP_TIMEOUT: 60
UV_HTTP_TIMEOUT: 60
UV_SYSTEM_PYTHON: "true"
@@ -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.4.3
+ uses: actions/upload-artifact@v4.5.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@v6
+ uses: dawidd6/action-download-artifact@v7
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@v6
+ uses: dawidd6/action-download-artifact@v7
with:
github_token: ${{secrets.GITHUB_TOKEN}}
repo: home-assistant/intents-package
@@ -509,7 +509,7 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build Docker image
- uses: docker/build-push-action@4f58ea79222b3b9dc2c8bbdd6debcef730109a75 # v6.9.0
+ uses: docker/build-push-action@48aba3b46d1b1fec4febb7c5d0c644b249a11355 # v6.10.0
with:
context: . # So action will not pull the repository again
file: ./script/hassfest/docker/Dockerfile
@@ -517,12 +517,12 @@ jobs:
tags: ${{ env.HASSFEST_IMAGE_TAG }}
- name: Run hassfest against core
- run: docker run --rm -v ${{ github.workspace }}/homeassistant:/github/workspace/homeassistant ${{ env.HASSFEST_IMAGE_TAG }} --core-integrations-path=/github/workspace/homeassistant/components
+ run: docker run --rm -v ${{ github.workspace }}:/github/workspace ${{ env.HASSFEST_IMAGE_TAG }} --core-path=/github/workspace
- name: Push Docker image
if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true'
id: push
- uses: docker/build-push-action@4f58ea79222b3b9dc2c8bbdd6debcef730109a75 # v6.9.0
+ uses: docker/build-push-action@48aba3b46d1b1fec4febb7c5d0c644b249a11355 # v6.10.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@ef244123eb79f2f7a7e75d99086184180e6d0018 # v1.4.4
+ uses: actions/attest-build-provenance@7668571508540a607bdfd90a87a560489fe372eb # v2.1.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 778ab8b0647..7bfebcd1f07 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: "2024.12"
+ HA_SHORT_VERSION: "2025.2"
DEFAULT_PYTHON: "3.12"
- ALL_PYTHON_VERSIONS: "['3.12']"
+ ALL_PYTHON_VERSIONS: "['3.12', '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
@@ -240,7 +240,7 @@ jobs:
check-latest: true
- name: Restore base Python virtual environment
id: cache-venv
- uses: actions/cache@v4.1.2
+ uses: actions/cache@v4.2.0
with:
path: venv
key: >-
@@ -256,7 +256,7 @@ jobs:
uv pip install "$(cat requirements_test.txt | grep pre-commit)"
- name: Restore pre-commit environment from cache
id: cache-precommit
- uses: actions/cache@v4.1.2
+ uses: actions/cache@v4.2.0
with:
path: ${{ env.PRE_COMMIT_CACHE }}
lookup-only: true
@@ -286,7 +286,7 @@ jobs:
check-latest: true
- name: Restore base Python virtual environment
id: cache-venv
- uses: actions/cache/restore@v4.1.2
+ uses: actions/cache/restore@v4.2.0
with:
path: venv
fail-on-cache-miss: true
@@ -295,7 +295,7 @@ jobs:
needs.info.outputs.pre-commit_cache_key }}
- name: Restore pre-commit environment from cache
id: cache-precommit
- uses: actions/cache/restore@v4.1.2
+ uses: actions/cache/restore@v4.2.0
with:
path: ${{ env.PRE_COMMIT_CACHE }}
fail-on-cache-miss: true
@@ -326,7 +326,7 @@ jobs:
check-latest: true
- name: Restore base Python virtual environment
id: cache-venv
- uses: actions/cache/restore@v4.1.2
+ uses: actions/cache/restore@v4.2.0
with:
path: venv
fail-on-cache-miss: true
@@ -335,7 +335,7 @@ jobs:
needs.info.outputs.pre-commit_cache_key }}
- name: Restore pre-commit environment from cache
id: cache-precommit
- uses: actions/cache/restore@v4.1.2
+ uses: actions/cache/restore@v4.2.0
with:
path: ${{ env.PRE_COMMIT_CACHE }}
fail-on-cache-miss: true
@@ -366,7 +366,7 @@ jobs:
check-latest: true
- name: Restore base Python virtual environment
id: cache-venv
- uses: actions/cache/restore@v4.1.2
+ uses: actions/cache/restore@v4.2.0
with:
path: venv
fail-on-cache-miss: true
@@ -375,7 +375,7 @@ jobs:
needs.info.outputs.pre-commit_cache_key }}
- name: Restore pre-commit environment from cache
id: cache-precommit
- uses: actions/cache/restore@v4.1.2
+ uses: actions/cache/restore@v4.2.0
with:
path: ${{ env.PRE_COMMIT_CACHE }}
fail-on-cache-miss: true
@@ -482,16 +482,15 @@ jobs:
env.HA_SHORT_VERSION }}-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT
- name: Restore base Python virtual environment
id: cache-venv
- uses: actions/cache@v4.1.2
+ uses: actions/cache@v4.2.0
with:
path: venv
- lookup-only: true
key: >-
${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{
needs.info.outputs.python_cache_key }}
- name: Restore uv wheel cache
if: steps.cache-venv.outputs.cache-hit != 'true'
- uses: actions/cache@v4.1.2
+ uses: actions/cache@v4.2.0
with:
path: ${{ env.UV_CACHE_DIR }}
key: >-
@@ -531,6 +530,26 @@ jobs:
python -m script.gen_requirements_all ci
uv pip install -r requirements_all_pytest.txt -r requirements_test.txt
uv pip install -e . --config-settings editable_mode=compat
+ - name: Dump pip freeze
+ run: |
+ python -m venv venv
+ . venv/bin/activate
+ python --version
+ uv pip freeze >> pip_freeze.txt
+ - name: Upload pip_freeze artifact
+ uses: actions/upload-artifact@v4.5.0
+ with:
+ name: pip-freeze-${{ matrix.python-version }}
+ path: pip_freeze.txt
+ overwrite: true
+ - name: Remove pip_freeze
+ run: rm pip_freeze.txt
+ - name: Remove generated requirements_all
+ if: steps.cache-venv.outputs.cache-hit != 'true'
+ run: rm requirements_all_pytest.txt requirements_all_wheels_*.txt
+ - name: Check dirty
+ run: |
+ ./script/check_dirty
hassfest:
name: Check hassfest
@@ -559,7 +578,7 @@ jobs:
check-latest: true
- name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment
id: cache-venv
- uses: actions/cache/restore@v4.1.2
+ uses: actions/cache/restore@v4.2.0
with:
path: venv
fail-on-cache-miss: true
@@ -592,7 +611,7 @@ jobs:
check-latest: true
- name: Restore base Python virtual environment
id: cache-venv
- uses: actions/cache/restore@v4.1.2
+ uses: actions/cache/restore@v4.2.0
with:
path: venv
fail-on-cache-miss: true
@@ -630,7 +649,7 @@ jobs:
check-latest: true
- name: Restore full Python ${{ matrix.python-version }} virtual environment
id: cache-venv
- uses: actions/cache/restore@v4.1.2
+ uses: actions/cache/restore@v4.2.0
with:
path: venv
fail-on-cache-miss: true
@@ -642,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.4.3
+ uses: actions/upload-artifact@v4.5.0
with:
name: licenses-${{ github.run_number }}-${{ matrix.python-version }}
path: licenses-${{ matrix.python-version }}.json
@@ -673,7 +692,7 @@ jobs:
check-latest: true
- name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment
id: cache-venv
- uses: actions/cache/restore@v4.1.2
+ uses: actions/cache/restore@v4.2.0
with:
path: venv
fail-on-cache-miss: true
@@ -720,7 +739,7 @@ jobs:
check-latest: true
- name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment
id: cache-venv
- uses: actions/cache/restore@v4.1.2
+ uses: actions/cache/restore@v4.2.0
with:
path: venv
fail-on-cache-miss: true
@@ -772,7 +791,7 @@ jobs:
env.HA_SHORT_VERSION }}-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT
- name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment
id: cache-venv
- uses: actions/cache/restore@v4.1.2
+ uses: actions/cache/restore@v4.2.0
with:
path: venv
fail-on-cache-miss: true
@@ -780,7 +799,7 @@ jobs:
${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{
needs.info.outputs.python_cache_key }}
- name: Restore mypy cache
- uses: actions/cache@v4.1.2
+ uses: actions/cache@v4.2.0
with:
path: .mypy_cache
key: >-
@@ -819,6 +838,12 @@ jobs:
needs:
- info
- base
+ - gen-requirements-all
+ - hassfest
+ - lint-other
+ - lint-ruff
+ - lint-ruff-format
+ - mypy
name: Split tests for full run
steps:
- name: Install additional OS dependencies
@@ -840,7 +865,7 @@ jobs:
check-latest: true
- name: Restore base Python virtual environment
id: cache-venv
- uses: actions/cache/restore@v4.1.2
+ uses: actions/cache/restore@v4.2.0
with:
path: venv
fail-on-cache-miss: true
@@ -852,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.4.3
+ uses: actions/upload-artifact@v4.5.0
with:
name: pytest_buckets
path: pytest_buckets.txt
@@ -904,7 +929,7 @@ jobs:
check-latest: true
- name: Restore full Python ${{ matrix.python-version }} virtual environment
id: cache-venv
- uses: actions/cache/restore@v4.1.2
+ uses: actions/cache/restore@v4.2.0
with:
path: venv
fail-on-cache-miss: true
@@ -954,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.4.3
+ uses: actions/upload-artifact@v4.5.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.4.3
+ uses: actions/upload-artifact@v4.5.0
with:
name: coverage-${{ matrix.python-version }}-${{ matrix.group }}
path: coverage.xml
@@ -1025,7 +1050,7 @@ jobs:
check-latest: true
- name: Restore full Python ${{ matrix.python-version }} virtual environment
id: cache-venv
- uses: actions/cache/restore@v4.1.2
+ uses: actions/cache/restore@v4.2.0
with:
path: venv
fail-on-cache-miss: true
@@ -1081,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.4.3
+ uses: actions/upload-artifact@v4.5.0
with:
name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{
steps.pytest-partial.outputs.mariadb }}
@@ -1089,7 +1114,7 @@ jobs:
overwrite: true
- name: Upload coverage artifact
if: needs.info.outputs.skip_coverage != 'true'
- uses: actions/upload-artifact@v4.4.3
+ uses: actions/upload-artifact@v4.5.0
with:
name: coverage-${{ matrix.python-version }}-${{
steps.pytest-partial.outputs.mariadb }}
@@ -1154,7 +1179,7 @@ jobs:
check-latest: true
- name: Restore full Python ${{ matrix.python-version }} virtual environment
id: cache-venv
- uses: actions/cache/restore@v4.1.2
+ uses: actions/cache/restore@v4.2.0
with:
path: venv
fail-on-cache-miss: true
@@ -1211,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.4.3
+ uses: actions/upload-artifact@v4.5.0
with:
name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{
steps.pytest-partial.outputs.postgresql }}
@@ -1219,7 +1244,7 @@ jobs:
overwrite: true
- name: Upload coverage artifact
if: needs.info.outputs.skip_coverage != 'true'
- uses: actions/upload-artifact@v4.4.3
+ uses: actions/upload-artifact@v4.5.0
with:
name: coverage-${{ matrix.python-version }}-${{
steps.pytest-partial.outputs.postgresql }}
@@ -1248,12 +1273,11 @@ jobs:
pattern: coverage-*
- name: Upload coverage to Codecov
if: needs.info.outputs.test_full_suite == 'true'
- uses: codecov/codecov-action@v4.6.0
+ uses: codecov/codecov-action@v5.1.2
with:
fail_ci_if_error: true
flags: full-suite
token: ${{ secrets.CODECOV_TOKEN }}
- version: v0.6.0
pytest-partial:
runs-on: ubuntu-24.04
@@ -1301,7 +1325,7 @@ jobs:
check-latest: true
- name: Restore full Python ${{ matrix.python-version }} virtual environment
id: cache-venv
- uses: actions/cache/restore@v4.1.2
+ uses: actions/cache/restore@v4.2.0
with:
path: venv
fail-on-cache-miss: true
@@ -1354,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.4.3
+ uses: actions/upload-artifact@v4.5.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.4.3
+ uses: actions/upload-artifact@v4.5.0
with:
name: coverage-${{ matrix.python-version }}-${{ matrix.group }}
path: coverage.xml
@@ -1387,8 +1411,7 @@ jobs:
pattern: coverage-*
- name: Upload coverage to Codecov
if: needs.info.outputs.test_full_suite == 'false'
- uses: codecov/codecov-action@v4.6.0
+ uses: codecov/codecov-action@v5.1.2
with:
fail_ci_if_error: true
token: ${{ secrets.CODECOV_TOKEN }}
- version: v0.6.0
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
index 176e010c5b9..511ec963db3 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.27.0
+ uses: github/codeql-action/init@v3.28.0
with:
languages: python
- name: Perform CodeQL Analysis
- uses: github/codeql-action/analyze@v3.27.0
+ uses: github/codeql-action/analyze@v3.28.0
with:
category: "/language:python"
diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml
index 0c8df57d5a2..cdf07f5c8d1 100644
--- a/.github/workflows/wheels.yml
+++ b/.github/workflows/wheels.yml
@@ -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.4.3
+ uses: actions/upload-artifact@v4.5.0
with:
name: env_file
path: ./.env_file
include-hidden-files: true
overwrite: true
+ - name: Upload build_constraints
+ uses: actions/upload-artifact@v4.5.0
+ with:
+ name: build_constraints
+ path: ./build_constraints.txt
+ overwrite: true
+
- name: Upload requirements_diff
- uses: actions/upload-artifact@v4.4.3
+ uses: actions/upload-artifact@v4.5.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.4.3
+ uses: actions/upload-artifact@v4.5.0
with:
name: requirements_all_wheels
path: ./requirements_all_wheels_*.txt
@@ -112,7 +131,7 @@ jobs:
strategy:
fail-fast: false
matrix:
- abi: ["cp312"]
+ abi: ["cp312", "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:
@@ -135,15 +159,15 @@ jobs:
sed -i "/uv/d" requirements_diff.txt
- name: Build wheels
- uses: home-assistant/wheels@2024.07.1
+ uses: home-assistant/wheels@2024.11.0
with:
abi: ${{ matrix.abi }}
tag: musllinux_1_2
arch: ${{ matrix.arch }}
wheels-key: ${{ secrets.WHEELS_KEY }}
env-file: true
- apk: "libffi-dev;openssl-dev;yaml-dev;nasm"
- skip-binary: aiohttp;multidict;yarl
+ 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"
requirements: "requirements.txt"
@@ -156,7 +180,7 @@ jobs:
strategy:
fail-fast: false
matrix:
- abi: ["cp312"]
+ abi: ["cp312", "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:
@@ -197,69 +226,44 @@ jobs:
split -l $(expr $(expr $(cat requirements_all.txt | wc -l) + 1) / 3) requirements_all_wheels_${{ matrix.arch }}.txt requirements_all.txt
- - name: Create requirements for cython<3
- run: |
- # Some dependencies still require 'cython<3'
- # and don't yet use isolated build environments.
- # Build these first.
- # pydantic: https://github.com/pydantic/pydantic/issues/7689
-
- touch requirements_old-cython.txt
- cat homeassistant/package_constraints.txt | grep 'pydantic==' >> requirements_old-cython.txt
-
- - name: Build wheels (old cython)
- uses: home-assistant/wheels@2024.07.1
- with:
- abi: ${{ matrix.abi }}
- tag: musllinux_1_2
- 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"
- skip-binary: aiohttp;charset-normalizer;grpcio;multidict;SQLAlchemy;propcache;protobuf;pydantic;pymicro-vad;yarl
- constraints: "homeassistant/package_constraints.txt"
- requirements-diff: "requirements_diff.txt"
- requirements: "requirements_old-cython.txt"
- pip: "'cython<3'"
-
- name: Build wheels (part 1)
- uses: home-assistant/wheels@2024.07.1
+ uses: home-assistant/wheels@2024.11.0
with:
abi: ${{ matrix.abi }}
tag: musllinux_1_2
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"
- skip-binary: aiohttp;charset-normalizer;grpcio;multidict;SQLAlchemy;propcache;protobuf;pydantic;pymicro-vad;yarl
+ 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"
requirements: "requirements_all.txtaa"
- name: Build wheels (part 2)
- uses: home-assistant/wheels@2024.07.1
+ uses: home-assistant/wheels@2024.11.0
with:
abi: ${{ matrix.abi }}
tag: musllinux_1_2
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"
- skip-binary: aiohttp;charset-normalizer;grpcio;multidict;SQLAlchemy;propcache;protobuf;pydantic;pymicro-vad;yarl
+ 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"
requirements: "requirements_all.txtab"
- name: Build wheels (part 3)
- uses: home-assistant/wheels@2024.07.1
+ uses: home-assistant/wheels@2024.11.0
with:
abi: ${{ matrix.abi }}
tag: musllinux_1_2
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"
- skip-binary: aiohttp;charset-normalizer;grpcio;multidict;SQLAlchemy;propcache;protobuf;pydantic;pymicro-vad;yarl
+ 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"
requirements: "requirements_all.txtac"
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index f89dadda43d..b99aa41f0cc 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.7.2
+ rev: v0.8.6
hooks:
- id: ruff
args:
@@ -12,13 +12,13 @@ repos:
hooks:
- id: codespell
args:
- - --ignore-words-list=astroid,checkin,currenty,hass,iif,incomfort,lookin,nam,NotIn
+ - --ignore-words-list=aiport,astroid,checkin,currenty,hass,iif,incomfort,lookin,nam,NotIn
- --skip="./.*,*.csv,*.json,*.ambr"
- --quiet-level=2
exclude_types: [csv, json, html]
exclude: ^tests/fixtures/|homeassistant/generated/|tests/components/.*/snapshots/
- repo: https://github.com/pre-commit/pre-commit-hooks
- rev: v4.4.0
+ rev: v5.0.0
hooks:
- id: check-executables-have-shebangs
stages: [manual]
@@ -83,14 +83,14 @@ repos:
pass_filenames: false
language: script
types: [text]
- files: ^(homeassistant/.+/(icons|manifest|strings)\.json|homeassistant/brands/.*\.json|homeassistant/.+/services\.yaml|script/hassfest/(?!metadata|mypy_config).+\.py|requirements.+\.txt)$
+ files: ^(homeassistant/.+/(icons|manifest|strings)\.json|homeassistant/.+/(quality_scale)\.yaml|homeassistant/brands/.*\.json|homeassistant/.+/services\.yaml|script/hassfest/(?!metadata|mypy_config).+\.py|requirements.+\.txt)$
- id: hassfest-metadata
name: hassfest-metadata
entry: script/run-in-env.sh python3 -m script.hassfest -p metadata,docker
pass_filenames: false
language: script
types: [text]
- files: ^(script/hassfest/metadata\.py|homeassistant/const\.py$|pyproject\.toml)$
+ files: ^(script/hassfest/metadata\.py|homeassistant/const\.py$|pyproject\.toml|homeassistant/components/go2rtc/const\.py)$
- id: hassfest-mypy-config
name: hassfest-mypy-config
entry: script/run-in-env.sh python3 -m script.hassfest -p mypy_config
diff --git a/.strict-typing b/.strict-typing
index b0fd74bce54..98fbb16ff45 100644
--- a/.strict-typing
+++ b/.strict-typing
@@ -41,6 +41,7 @@ homeassistant.util.unit_system
# --- Add components below this line ---
homeassistant.components
homeassistant.components.abode.*
+homeassistant.components.acaia.*
homeassistant.components.accuweather.*
homeassistant.components.acer_projector.*
homeassistant.components.acmeda.*
@@ -136,6 +137,7 @@ homeassistant.components.co2signal.*
homeassistant.components.command_line.*
homeassistant.components.config.*
homeassistant.components.configurator.*
+homeassistant.components.cookidoo.*
homeassistant.components.counter.*
homeassistant.components.cover.*
homeassistant.components.cpuspeed.*
@@ -168,6 +170,7 @@ homeassistant.components.easyenergy.*
homeassistant.components.ecovacs.*
homeassistant.components.ecowitt.*
homeassistant.components.efergy.*
+homeassistant.components.eheimdigital.*
homeassistant.components.electrasmart.*
homeassistant.components.electric_kiwi.*
homeassistant.components.elevenlabs.*
@@ -268,6 +271,7 @@ homeassistant.components.ios.*
homeassistant.components.iotty.*
homeassistant.components.ipp.*
homeassistant.components.iqvia.*
+homeassistant.components.iron_os.*
homeassistant.components.islamic_prayer_times.*
homeassistant.components.isy994.*
homeassistant.components.jellyfin.*
@@ -307,6 +311,8 @@ homeassistant.components.manual.*
homeassistant.components.mastodon.*
homeassistant.components.matrix.*
homeassistant.components.matter.*
+homeassistant.components.mcp_server.*
+homeassistant.components.mealie.*
homeassistant.components.media_extractor.*
homeassistant.components.media_player.*
homeassistant.components.media_source.*
@@ -357,13 +363,17 @@ 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.pi_hole.*
homeassistant.components.ping.*
homeassistant.components.plugwise.*
+homeassistant.components.powerfox.*
homeassistant.components.powerwall.*
homeassistant.components.private_ble_device.*
homeassistant.components.prometheus.*
@@ -373,6 +383,7 @@ homeassistant.components.pure_energie.*
homeassistant.components.purpleair.*
homeassistant.components.pushbullet.*
homeassistant.components.pvoutput.*
+homeassistant.components.python_script.*
homeassistant.components.qnap_qsw.*
homeassistant.components.rabbitair.*
homeassistant.components.radarr.*
@@ -385,6 +396,7 @@ homeassistant.components.recollect_waste.*
homeassistant.components.recorder.*
homeassistant.components.remote.*
homeassistant.components.renault.*
+homeassistant.components.reolink.*
homeassistant.components.repairs.*
homeassistant.components.rest.*
homeassistant.components.rest_command.*
@@ -399,11 +411,13 @@ homeassistant.components.romy.*
homeassistant.components.rpi_power.*
homeassistant.components.rss_feed_template.*
homeassistant.components.rtsp_to_webrtc.*
+homeassistant.components.russound_rio.*
homeassistant.components.ruuvi_gateway.*
homeassistant.components.ruuvitag_ble.*
homeassistant.components.samsungtv.*
homeassistant.components.scene.*
homeassistant.components.schedule.*
+homeassistant.components.schlage.*
homeassistant.components.scrape.*
homeassistant.components.script.*
homeassistant.components.search.*
@@ -436,7 +450,7 @@ homeassistant.components.ssdp.*
homeassistant.components.starlink.*
homeassistant.components.statistics.*
homeassistant.components.steamist.*
-homeassistant.components.stookalert.*
+homeassistant.components.stookwijzer.*
homeassistant.components.stream.*
homeassistant.components.streamlabswater.*
homeassistant.components.stt.*
diff --git a/.vscode/tasks.json b/.vscode/tasks.json
index 2495249af66..7425e7a2533 100644
--- a/.vscode/tasks.json
+++ b/.vscode/tasks.json
@@ -16,7 +16,7 @@
{
"label": "Pytest",
"type": "shell",
- "command": "python3 -m pytest --timeout=10 tests",
+ "command": "${command:python.interpreterPath} -m pytest --timeout=10 tests",
"dependsOn": ["Install all Test Requirements"],
"group": {
"kind": "test",
@@ -31,7 +31,7 @@
{
"label": "Pytest (changed tests only)",
"type": "shell",
- "command": "python3 -m pytest --timeout=10 --picked",
+ "command": "${command:python.interpreterPath} -m pytest --timeout=10 --picked",
"group": {
"kind": "test",
"isDefault": true
@@ -56,6 +56,20 @@
},
"problemMatcher": []
},
+ {
+ "label": "Pre-commit",
+ "type": "shell",
+ "command": "pre-commit run --show-diff-on-failure",
+ "group": {
+ "kind": "test",
+ "isDefault": true
+ },
+ "presentation": {
+ "reveal": "always",
+ "panel": "new"
+ },
+ "problemMatcher": []
+ },
{
"label": "Pylint",
"type": "shell",
@@ -75,7 +89,23 @@
"label": "Code Coverage",
"detail": "Generate code coverage report for a given integration.",
"type": "shell",
- "command": "python3 -m pytest ./tests/components/${input:integrationName}/ --cov=homeassistant.components.${input:integrationName} --cov-report term-missing --durations-min=1 --durations=0 --numprocesses=auto",
+ "command": "${command:python.interpreterPath} -m pytest ./tests/components/${input:integrationName}/ --cov=homeassistant.components.${input:integrationName} --cov-report term-missing --durations-min=1 --durations=0 --numprocesses=auto",
+ "dependsOn": ["Compile English translations"],
+ "group": {
+ "kind": "test",
+ "isDefault": true
+ },
+ "presentation": {
+ "reveal": "always",
+ "panel": "new"
+ },
+ "problemMatcher": []
+ },
+ {
+ "label": "Update syrupy snapshots",
+ "detail": "Update syrupy snapshots for a given integration.",
+ "type": "shell",
+ "command": "${command:python.interpreterPath} -m pytest ./tests/components/${input:integrationName} --snapshot-update",
"dependsOn": ["Compile English translations"],
"group": {
"kind": "test",
@@ -133,7 +163,7 @@
"label": "Compile English translations",
"detail": "In order to test changes to translation files, the translation strings must be compiled into Home Assistant's translation directories.",
"type": "shell",
- "command": "python3 -m script.translations develop --all",
+ "command": "${command:python.interpreterPath} -m script.translations develop --all",
"group": {
"kind": "build",
"isDefault": true
@@ -143,7 +173,7 @@
"label": "Run scaffold",
"detail": "Add new functionality to a integration using a scaffold.",
"type": "shell",
- "command": "python3 -m script.scaffold ${input:scaffoldName} --integration ${input:integrationName}",
+ "command": "${command:python.interpreterPath} -m script.scaffold ${input:scaffoldName} --integration ${input:integrationName}",
"group": {
"kind": "build",
"isDefault": true
@@ -153,7 +183,7 @@
"label": "Create new integration",
"detail": "Use the scaffold to create a new integration.",
"type": "shell",
- "command": "python3 -m script.scaffold integration",
+ "command": "${command:python.interpreterPath} -m script.scaffold integration",
"group": {
"kind": "build",
"isDefault": true
diff --git a/CODEOWNERS b/CODEOWNERS
index 022eda00123..4ef40a79bd1 100644
--- a/CODEOWNERS
+++ b/CODEOWNERS
@@ -40,6 +40,8 @@ build.json @home-assistant/supervisor
# Integrations
/homeassistant/components/abode/ @shred86
/tests/components/abode/ @shred86
+/homeassistant/components/acaia/ @zweckj
+/tests/components/acaia/ @zweckj
/homeassistant/components/accuweather/ @bieniu
/tests/components/accuweather/ @bieniu
/homeassistant/components/acmeda/ @atmurray
@@ -282,6 +284,8 @@ build.json @home-assistant/supervisor
/tests/components/control4/ @lawtancool
/homeassistant/components/conversation/ @home-assistant/core @synesthesiam
/tests/components/conversation/ @home-assistant/core @synesthesiam
+/homeassistant/components/cookidoo/ @miaucl
+/tests/components/cookidoo/ @miaucl
/homeassistant/components/coolmaster/ @OnFreund
/tests/components/coolmaster/ @OnFreund
/homeassistant/components/counter/ @fabaff
@@ -383,6 +387,8 @@ build.json @home-assistant/supervisor
/homeassistant/components/efergy/ @tkdrob
/tests/components/efergy/ @tkdrob
/homeassistant/components/egardia/ @jeroenterheerdt
+/homeassistant/components/eheimdigital/ @autinerd
+/tests/components/eheimdigital/ @autinerd
/homeassistant/components/electrasmart/ @jafar-atili
/tests/components/electrasmart/ @jafar-atili
/homeassistant/components/electric_kiwi/ @mikey0000
@@ -572,8 +578,8 @@ build.json @home-assistant/supervisor
/tests/components/google_tasks/ @allenporter
/homeassistant/components/google_travel_time/ @eifinger
/tests/components/google_travel_time/ @eifinger
-/homeassistant/components/govee_ble/ @bdraco @PierreAronnax
-/tests/components/govee_ble/ @bdraco @PierreAronnax
+/homeassistant/components/govee_ble/ @bdraco
+/tests/components/govee_ble/ @bdraco
/homeassistant/components/govee_light_local/ @Galorhallen
/tests/components/govee_light_local/ @Galorhallen
/homeassistant/components/gpsd/ @fabaff @jrieger
@@ -586,8 +592,8 @@ build.json @home-assistant/supervisor
/tests/components/group/ @home-assistant/core
/homeassistant/components/guardian/ @bachya
/tests/components/guardian/ @bachya
-/homeassistant/components/habitica/ @ASMfreaK @leikoilja @tr4nt0r
-/tests/components/habitica/ @ASMfreaK @leikoilja @tr4nt0r
+/homeassistant/components/habitica/ @tr4nt0r
+/tests/components/habitica/ @tr4nt0r
/homeassistant/components/hardkernel/ @home-assistant/core
/tests/components/hardkernel/ @home-assistant/core
/homeassistant/components/hardware/ @home-assistant/core
@@ -631,6 +637,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,6 +688,8 @@ build.json @home-assistant/supervisor
/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
@@ -725,8 +735,8 @@ build.json @home-assistant/supervisor
/tests/components/ios/ @robbiet480
/homeassistant/components/iotawatt/ @gtdiehl @jyavenard
/tests/components/iotawatt/ @gtdiehl @jyavenard
-/homeassistant/components/iotty/ @pburgio @shapournemati-iotty
-/tests/components/iotty/ @pburgio @shapournemati-iotty
+/homeassistant/components/iotty/ @shapournemati-iotty
+/tests/components/iotty/ @shapournemati-iotty
/homeassistant/components/iperf3/ @rohankapoorcom
/homeassistant/components/ipma/ @dgomes
/tests/components/ipma/ @dgomes
@@ -751,6 +761,8 @@ build.json @home-assistant/supervisor
/tests/components/ista_ecotrend/ @tr4nt0r
/homeassistant/components/isy994/ @bdraco @shbatm
/tests/components/isy994/ @bdraco @shbatm
+/homeassistant/components/ituran/ @shmuelzon
+/tests/components/ituran/ @shmuelzon
/homeassistant/components/izone/ @Swamp-Ig
/tests/components/izone/ @Swamp-Ig
/homeassistant/components/jellyfin/ @j-stienstra @ctalkington
@@ -879,6 +891,8 @@ build.json @home-assistant/supervisor
/tests/components/matrix/ @PaarthShah
/homeassistant/components/matter/ @home-assistant/matter
/tests/components/matter/ @home-assistant/matter
+/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
@@ -972,8 +986,6 @@ build.json @home-assistant/supervisor
/tests/components/nanoleaf/ @milanmeu @joostlek
/homeassistant/components/nasweb/ @nasWebio
/tests/components/nasweb/ @nasWebio
-/homeassistant/components/neato/ @Santobert
-/tests/components/neato/ @Santobert
/homeassistant/components/nederlandse_spoorwegen/ @YarmoM
/homeassistant/components/ness_alarm/ @nickw444
/tests/components/ness_alarm/ @nickw444
@@ -1004,6 +1016,8 @@ build.json @home-assistant/supervisor
/tests/components/nice_go/ @IceBotYT
/homeassistant/components/nightscout/ @marciogranzotto
/tests/components/nightscout/ @marciogranzotto
+/homeassistant/components/niko_home_control/ @VandeurenGlenn
+/tests/components/niko_home_control/ @VandeurenGlenn
/homeassistant/components/nilu/ @hfurubotten
/homeassistant/components/nina/ @DeerMaximum
/tests/components/nina/ @DeerMaximum
@@ -1045,6 +1059,8 @@ build.json @home-assistant/supervisor
/homeassistant/components/octoprint/ @rfleming71
/tests/components/octoprint/ @rfleming71
/homeassistant/components/ohmconnect/ @robbiet480
+/homeassistant/components/ohme/ @dan-r
+/tests/components/ohme/ @dan-r
/homeassistant/components/ollama/ @synesthesiam
/tests/components/ollama/ @synesthesiam
/homeassistant/components/ombi/ @larssont
@@ -1056,8 +1072,8 @@ build.json @home-assistant/supervisor
/tests/components/ondilo_ico/ @JeromeHXP
/homeassistant/components/onewire/ @garbled1 @epenet
/tests/components/onewire/ @garbled1 @epenet
-/homeassistant/components/onkyo/ @arturpragacz
-/tests/components/onkyo/ @arturpragacz
+/homeassistant/components/onkyo/ @arturpragacz @eclair4151
+/tests/components/onkyo/ @arturpragacz @eclair4151
/homeassistant/components/onvif/ @hunterjm
/tests/components/onvif/ @hunterjm
/homeassistant/components/open_meteo/ @frenck
@@ -1093,8 +1109,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
@@ -1103,6 +1121,8 @@ build.json @home-assistant/supervisor
/tests/components/palazzetti/ @dotvav
/homeassistant/components/panel_custom/ @home-assistant/frontend
/tests/components/panel_custom/ @home-assistant/frontend
+/homeassistant/components/peblar/ @frenck
+/tests/components/peblar/ @frenck
/homeassistant/components/peco/ @IceBotYT
/tests/components/peco/ @IceBotYT
/homeassistant/components/pegel_online/ @mib1185
@@ -1123,14 +1143,16 @@ 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
/tests/components/point/ @fredrike
/homeassistant/components/poolsense/ @haemishkyd
/tests/components/poolsense/ @haemishkyd
+/homeassistant/components/powerfox/ @klaasnicolaas
+/tests/components/powerfox/ @klaasnicolaas
/homeassistant/components/powerwall/ @bdraco @jrester @daniel-simpson
/tests/components/powerwall/ @bdraco @jrester @daniel-simpson
/homeassistant/components/private_ble_device/ @Jc2k
@@ -1344,6 +1366,8 @@ build.json @home-assistant/supervisor
/tests/components/siren/ @home-assistant/core @raman325
/homeassistant/components/sisyphus/ @jkeljo
/homeassistant/components/sky_hub/ @rogerselwyn
+/homeassistant/components/sky_remote/ @dunnmj @saty9
+/tests/components/sky_remote/ @dunnmj @saty9
/homeassistant/components/skybell/ @tkdrob
/tests/components/skybell/ @tkdrob
/homeassistant/components/slack/ @tkdrob @fletcherau
@@ -1351,6 +1375,8 @@ build.json @home-assistant/supervisor
/homeassistant/components/sleepiq/ @mfugate1 @kbickar
/tests/components/sleepiq/ @mfugate1 @kbickar
/homeassistant/components/slide/ @ualex73
+/homeassistant/components/slide_local/ @dontinelli
+/tests/components/slide_local/ @dontinelli
/homeassistant/components/slimproto/ @marcelveldt
/tests/components/slimproto/ @marcelveldt
/homeassistant/components/sma/ @kellerza @rklomp
@@ -1409,15 +1435,13 @@ build.json @home-assistant/supervisor
/tests/components/starline/ @anonym-tsk
/homeassistant/components/starlink/ @boswelja
/tests/components/starlink/ @boswelja
-/homeassistant/components/statistics/ @ThomDietrich
-/tests/components/statistics/ @ThomDietrich
+/homeassistant/components/statistics/ @ThomDietrich @gjohansson-ST
+/tests/components/statistics/ @ThomDietrich @gjohansson-ST
/homeassistant/components/steam_online/ @tkdrob
/tests/components/steam_online/ @tkdrob
/homeassistant/components/steamist/ @bdraco
/tests/components/steamist/ @bdraco
/homeassistant/components/stiebel_eltron/ @fucm
-/homeassistant/components/stookalert/ @fwestenberg @frenck
-/tests/components/stookalert/ @fwestenberg @frenck
/homeassistant/components/stookwijzer/ @fwestenberg
/tests/components/stookwijzer/ @fwestenberg
/homeassistant/components/stream/ @hunterjm @uvjustin @allenporter
@@ -1462,8 +1486,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
@@ -1485,8 +1509,8 @@ build.json @home-assistant/supervisor
/tests/components/tedee/ @patrickhilker @zweckj
/homeassistant/components/tellduslive/ @fredrike
/tests/components/tellduslive/ @fredrike
-/homeassistant/components/template/ @PhracturedBlue @tetienne @home-assistant/core
-/tests/components/template/ @PhracturedBlue @tetienne @home-assistant/core
+/homeassistant/components/template/ @PhracturedBlue @home-assistant/core
+/tests/components/template/ @PhracturedBlue @home-assistant/core
/homeassistant/components/tesla_fleet/ @Bre77
/tests/components/tesla_fleet/ @Bre77
/homeassistant/components/tesla_wall_connector/ @einarhauks
@@ -1557,8 +1581,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
@@ -1571,6 +1595,8 @@ build.json @home-assistant/supervisor
/tests/components/unifi/ @Kane610
/homeassistant/components/unifi_direct/ @tofuSCHNITZEL
/homeassistant/components/unifiled/ @florisvdk
+/homeassistant/components/unifiprotect/ @RaHehl
+/tests/components/unifiprotect/ @RaHehl
/homeassistant/components/upb/ @gwww
/tests/components/upb/ @gwww
/homeassistant/components/upc_connect/ @pvizeli @fabaff
@@ -1638,6 +1664,8 @@ build.json @home-assistant/supervisor
/tests/components/waqi/ @joostlek
/homeassistant/components/water_heater/ @home-assistant/core
/tests/components/water_heater/ @home-assistant/core
+/homeassistant/components/watergate/ @adam-the-hero
+/tests/components/watergate/ @adam-the-hero
/homeassistant/components/watson_tts/ @rutkai
/homeassistant/components/watttime/ @bachya
/tests/components/watttime/ @bachya
@@ -1722,6 +1750,7 @@ build.json @home-assistant/supervisor
/tests/components/youless/ @gjong
/homeassistant/components/youtube/ @joostlek
/tests/components/youtube/ @joostlek
+/homeassistant/components/zabbix/ @kruton
/homeassistant/components/zamg/ @killer0071234
/tests/components/zamg/ @killer0071234
/homeassistant/components/zengge/ @emontnemery
diff --git a/Dockerfile b/Dockerfile
index 903a121c032..630fc19496c 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -13,7 +13,7 @@ ENV \
ARG QEMU_CPU
# Install uv
-RUN pip3 install uv==0.5.0
+RUN pip3 install uv==0.5.8
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.6/go2rtc_linux_${go2rtc_suffix} --output /bin/go2rtc \
+ && curl -L https://github.com/AlexxIT/go2rtc/releases/download/v1.9.7/go2rtc_linux_${go2rtc_suffix} --output /bin/go2rtc \
&& chmod +x /bin/go2rtc \
# Verify go2rtc can be executed
&& go2rtc --version
diff --git a/Dockerfile.dev b/Dockerfile.dev
index d05c6df425c..5a3f1a2ae64 100644
--- a/Dockerfile.dev
+++ b/Dockerfile.dev
@@ -1,4 +1,4 @@
-FROM mcr.microsoft.com/devcontainers/python:1-3.12
+FROM mcr.microsoft.com/devcontainers/python:1-3.13
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
@@ -35,6 +35,9 @@ RUN \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
+# Add go2rtc binary
+COPY --from=ghcr.io/alexxit/go2rtc:latest /usr/local/bin/go2rtc /bin/go2rtc
+
# Install uv
RUN pip3 install uv
diff --git a/build.yaml b/build.yaml
index 13618740ab8..e6e149cf700 100644
--- a/build.yaml
+++ b/build.yaml
@@ -1,10 +1,10 @@
image: ghcr.io/home-assistant/{arch}-homeassistant
build_from:
- aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2024.06.1
- armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2024.06.1
- armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2024.06.1
- amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2024.06.1
- i386: ghcr.io/home-assistant/i386-homeassistant-base:2024.06.1
+ aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2024.12.0
+ armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2024.12.0
+ armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2024.12.0
+ amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2024.12.0
+ i386: ghcr.io/home-assistant/i386-homeassistant-base:2024.12.0
codenotary:
signer: notary@home-assistant.io
base_image: notary@home-assistant.io
diff --git a/homeassistant/auth/__init__.py b/homeassistant/auth/__init__.py
index 21a4b6113d0..afe3b2d7aa3 100644
--- a/homeassistant/auth/__init__.py
+++ b/homeassistant/auth/__init__.py
@@ -115,7 +115,7 @@ class AuthManagerFlowManager(
*,
context: AuthFlowContext | None = None,
data: dict[str, Any] | None = None,
- ) -> LoginFlow:
+ ) -> LoginFlow[Any]:
"""Create a login flow."""
auth_provider = self.auth_manager.get_auth_provider(*handler_key)
if not auth_provider:
diff --git a/homeassistant/auth/jwt_wrapper.py b/homeassistant/auth/jwt_wrapper.py
index 3aa3ac63764..464df006f5f 100644
--- a/homeassistant/auth/jwt_wrapper.py
+++ b/homeassistant/auth/jwt_wrapper.py
@@ -18,7 +18,7 @@ from homeassistant.util.json import json_loads
JWT_TOKEN_CACHE_SIZE = 16
MAX_TOKEN_SIZE = 8192
-_VERIFY_KEYS = ("signature", "exp", "nbf", "iat", "aud", "iss")
+_VERIFY_KEYS = ("signature", "exp", "nbf", "iat", "aud", "iss", "sub", "jti")
_VERIFY_OPTIONS: dict[str, Any] = {f"verify_{key}": True for key in _VERIFY_KEYS} | {
"require": []
diff --git a/homeassistant/auth/mfa_modules/__init__.py b/homeassistant/auth/mfa_modules/__init__.py
index d57a274c7ff..8a6430d770a 100644
--- a/homeassistant/auth/mfa_modules/__init__.py
+++ b/homeassistant/auth/mfa_modules/__init__.py
@@ -4,8 +4,9 @@ from __future__ import annotations
import logging
import types
-from typing import Any
+from typing import Any, Generic
+from typing_extensions import TypeVar
import voluptuous as vol
from voluptuous.humanize import humanize_error
@@ -34,6 +35,12 @@ 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."""
@@ -71,7 +78,7 @@ class MultiFactorAuthModule:
"""Return a voluptuous schema to define mfa auth module's input."""
raise NotImplementedError
- async def async_setup_flow(self, user_id: str) -> SetupFlow:
+ async def async_setup_flow(self, user_id: str) -> SetupFlow[Any]:
"""Return a data entry flow handler for setup module.
Mfa module should extend SetupFlow
@@ -95,11 +102,14 @@ class MultiFactorAuthModule:
raise NotImplementedError
-class SetupFlow(data_entry_flow.FlowHandler):
+class SetupFlow(data_entry_flow.FlowHandler, Generic[_MultiFactorAuthModuleT]):
"""Handler for the setup flow."""
def __init__(
- self, auth_module: MultiFactorAuthModule, setup_schema: vol.Schema, user_id: str
+ self,
+ auth_module: _MultiFactorAuthModuleT,
+ setup_schema: vol.Schema,
+ user_id: str,
) -> None:
"""Initialize the setup flow."""
self._auth_module = auth_module
diff --git a/homeassistant/auth/mfa_modules/notify.py b/homeassistant/auth/mfa_modules/notify.py
index d2010dc2c9d..b60a3012aac 100644
--- a/homeassistant/auth/mfa_modules/notify.py
+++ b/homeassistant/auth/mfa_modules/notify.py
@@ -162,7 +162,7 @@ class NotifyAuthModule(MultiFactorAuthModule):
return sorted(unordered_services)
- async def async_setup_flow(self, user_id: str) -> SetupFlow:
+ async def async_setup_flow(self, user_id: str) -> NotifySetupFlow:
"""Return a data entry flow handler for setup module.
Mfa module should extend SetupFlow
@@ -268,7 +268,7 @@ class NotifyAuthModule(MultiFactorAuthModule):
await self.hass.services.async_call("notify", notify_service, data)
-class NotifySetupFlow(SetupFlow):
+class NotifySetupFlow(SetupFlow[NotifyAuthModule]):
"""Handler for the setup flow."""
def __init__(
@@ -280,8 +280,6 @@ class NotifySetupFlow(SetupFlow):
) -> None:
"""Initialize the setup flow."""
super().__init__(auth_module, setup_schema, user_id)
- # to fix typing complaint
- self._auth_module: NotifyAuthModule = auth_module
self._available_notify_services = available_notify_services
self._secret: str | None = None
self._count: int | None = None
diff --git a/homeassistant/auth/mfa_modules/totp.py b/homeassistant/auth/mfa_modules/totp.py
index e9055b45f05..625b273f39a 100644
--- a/homeassistant/auth/mfa_modules/totp.py
+++ b/homeassistant/auth/mfa_modules/totp.py
@@ -114,7 +114,7 @@ class TotpAuthModule(MultiFactorAuthModule):
self._users[user_id] = ota_secret # type: ignore[index]
return ota_secret
- async def async_setup_flow(self, user_id: str) -> SetupFlow:
+ async def async_setup_flow(self, user_id: str) -> TotpSetupFlow:
"""Return a data entry flow handler for setup module.
Mfa module should extend SetupFlow
@@ -174,20 +174,19 @@ class TotpAuthModule(MultiFactorAuthModule):
return bool(pyotp.TOTP(ota_secret).verify(code, valid_window=1))
-class TotpSetupFlow(SetupFlow):
+class TotpSetupFlow(SetupFlow[TotpAuthModule]):
"""Handler for the setup flow."""
+ _ota_secret: str
+ _url: str
+ _image: str
+
def __init__(
self, auth_module: TotpAuthModule, setup_schema: vol.Schema, user: User
) -> None:
"""Initialize the setup flow."""
super().__init__(auth_module, setup_schema, user.id)
- # to fix typing complaint
- self._auth_module: TotpAuthModule = auth_module
self._user = user
- self._ota_secret: str = ""
- self._url: str | None = None
- self._image: str | None = None
async def async_step_init(
self, user_input: dict[str, str] | None = None
@@ -214,12 +213,11 @@ class TotpSetupFlow(SetupFlow):
errors["base"] = "invalid_code"
else:
- hass = self._auth_module.hass
(
self._ota_secret,
self._url,
self._image,
- ) = await hass.async_add_executor_job(
+ ) = await self._auth_module.hass.async_add_executor_job(
_generate_secret_and_qr_code,
str(self._user.name),
)
diff --git a/homeassistant/auth/providers/__init__.py b/homeassistant/auth/providers/__init__.py
index 34278c47df7..02f99e7bd71 100644
--- a/homeassistant/auth/providers/__init__.py
+++ b/homeassistant/auth/providers/__init__.py
@@ -5,8 +5,9 @@ from __future__ import annotations
from collections.abc import Mapping
import logging
import types
-from typing import Any
+from typing import Any, Generic
+from typing_extensions import TypeVar
import voluptuous as vol
from voluptuous.humanize import humanize_error
@@ -46,6 +47,8 @@ AUTH_PROVIDER_SCHEMA = vol.Schema(
extra=vol.ALLOW_EXTRA,
)
+_AuthProviderT = TypeVar("_AuthProviderT", bound="AuthProvider", default="AuthProvider")
+
class AuthProvider:
"""Provider of user authentication."""
@@ -105,7 +108,7 @@ class AuthProvider:
# Implement by extending class
- async def async_login_flow(self, context: AuthFlowContext | None) -> LoginFlow:
+ async def async_login_flow(self, context: AuthFlowContext | None) -> LoginFlow[Any]:
"""Return the data flow for logging in with auth provider.
Auth provider should extend LoginFlow and return an instance.
@@ -192,12 +195,15 @@ async def load_auth_provider_module(
return module
-class LoginFlow(FlowHandler[AuthFlowContext, AuthFlowResult, tuple[str, str]]):
+class LoginFlow(
+ FlowHandler[AuthFlowContext, AuthFlowResult, tuple[str, str]],
+ Generic[_AuthProviderT],
+):
"""Handler for the login flow."""
_flow_result = AuthFlowResult
- def __init__(self, auth_provider: AuthProvider) -> None:
+ def __init__(self, auth_provider: _AuthProviderT) -> None:
"""Initialize the login flow."""
self._auth_provider = auth_provider
self._auth_module_id: str | None = None
diff --git a/homeassistant/auth/providers/command_line.py b/homeassistant/auth/providers/command_line.py
index 12447bc8c18..74630d925e1 100644
--- a/homeassistant/auth/providers/command_line.py
+++ b/homeassistant/auth/providers/command_line.py
@@ -6,7 +6,7 @@ import asyncio
from collections.abc import Mapping
import logging
import os
-from typing import Any, cast
+from typing import Any
import voluptuous as vol
@@ -59,7 +59,9 @@ class CommandLineAuthProvider(AuthProvider):
super().__init__(*args, **kwargs)
self._user_meta: dict[str, dict[str, Any]] = {}
- async def async_login_flow(self, context: AuthFlowContext | None) -> LoginFlow:
+ async def async_login_flow(
+ self, context: AuthFlowContext | None
+ ) -> CommandLineLoginFlow:
"""Return a flow to login."""
return CommandLineLoginFlow(self)
@@ -133,7 +135,7 @@ class CommandLineAuthProvider(AuthProvider):
)
-class CommandLineLoginFlow(LoginFlow):
+class CommandLineLoginFlow(LoginFlow[CommandLineAuthProvider]):
"""Handler for the login flow."""
async def async_step_init(
@@ -145,9 +147,9 @@ class CommandLineLoginFlow(LoginFlow):
if user_input is not None:
user_input["username"] = user_input["username"].strip()
try:
- await cast(
- CommandLineAuthProvider, self._auth_provider
- ).async_validate_login(user_input["username"], user_input["password"])
+ await self._auth_provider.async_validate_login(
+ user_input["username"], user_input["password"]
+ )
except InvalidAuthError:
errors["base"] = "invalid_auth"
diff --git a/homeassistant/auth/providers/homeassistant.py b/homeassistant/auth/providers/homeassistant.py
index e5dded74762..522e5d77a29 100644
--- a/homeassistant/auth/providers/homeassistant.py
+++ b/homeassistant/auth/providers/homeassistant.py
@@ -305,7 +305,7 @@ class HassAuthProvider(AuthProvider):
await data.async_load()
self.data = data
- async def async_login_flow(self, context: AuthFlowContext | None) -> LoginFlow:
+ async def async_login_flow(self, context: AuthFlowContext | None) -> HassLoginFlow:
"""Return a flow to login."""
return HassLoginFlow(self)
@@ -400,7 +400,7 @@ class HassAuthProvider(AuthProvider):
pass
-class HassLoginFlow(LoginFlow):
+class HassLoginFlow(LoginFlow[HassAuthProvider]):
"""Handler for the login flow."""
async def async_step_init(
@@ -411,7 +411,7 @@ class HassLoginFlow(LoginFlow):
if user_input is not None:
try:
- await cast(HassAuthProvider, self._auth_provider).async_validate_login(
+ await self._auth_provider.async_validate_login(
user_input["username"], user_input["password"]
)
except InvalidAuth:
diff --git a/homeassistant/auth/providers/insecure_example.py b/homeassistant/auth/providers/insecure_example.py
index a7dced851a3..a92f5b55848 100644
--- a/homeassistant/auth/providers/insecure_example.py
+++ b/homeassistant/auth/providers/insecure_example.py
@@ -4,7 +4,6 @@ from __future__ import annotations
from collections.abc import Mapping
import hmac
-from typing import cast
import voluptuous as vol
@@ -36,7 +35,9 @@ class InvalidAuthError(HomeAssistantError):
class ExampleAuthProvider(AuthProvider):
"""Example auth provider based on hardcoded usernames and passwords."""
- async def async_login_flow(self, context: AuthFlowContext | None) -> LoginFlow:
+ async def async_login_flow(
+ self, context: AuthFlowContext | None
+ ) -> ExampleLoginFlow:
"""Return a flow to login."""
return ExampleLoginFlow(self)
@@ -93,7 +94,7 @@ class ExampleAuthProvider(AuthProvider):
return UserMeta(name=name, is_active=True)
-class ExampleLoginFlow(LoginFlow):
+class ExampleLoginFlow(LoginFlow[ExampleAuthProvider]):
"""Handler for the login flow."""
async def async_step_init(
@@ -104,7 +105,7 @@ class ExampleLoginFlow(LoginFlow):
if user_input is not None:
try:
- cast(ExampleAuthProvider, self._auth_provider).async_validate_login(
+ self._auth_provider.async_validate_login(
user_input["username"], user_input["password"]
)
except InvalidAuthError:
diff --git a/homeassistant/auth/providers/trusted_networks.py b/homeassistant/auth/providers/trusted_networks.py
index f32c35d4bd5..799fd4d2e16 100644
--- a/homeassistant/auth/providers/trusted_networks.py
+++ b/homeassistant/auth/providers/trusted_networks.py
@@ -104,7 +104,9 @@ class TrustedNetworksAuthProvider(AuthProvider):
"""Trusted Networks auth provider does not support MFA."""
return False
- async def async_login_flow(self, context: AuthFlowContext | None) -> LoginFlow:
+ async def async_login_flow(
+ self, context: AuthFlowContext | None
+ ) -> TrustedNetworksLoginFlow:
"""Return a flow to login."""
assert context is not None
ip_addr = cast(IPAddress, context.get("ip_address"))
@@ -214,7 +216,7 @@ class TrustedNetworksAuthProvider(AuthProvider):
self.async_validate_access(ip_address(remote_ip))
-class TrustedNetworksLoginFlow(LoginFlow):
+class TrustedNetworksLoginFlow(LoginFlow[TrustedNetworksAuthProvider]):
"""Handler for the login flow."""
def __init__(
@@ -235,9 +237,7 @@ class TrustedNetworksLoginFlow(LoginFlow):
) -> AuthFlowResult:
"""Handle the step of the form."""
try:
- cast(
- TrustedNetworksAuthProvider, self._auth_provider
- ).async_validate_access(self._ip_address)
+ self._auth_provider.async_validate_access(self._ip_address)
except InvalidAuthError:
return self.async_abort(reason="not_allowed")
diff --git a/homeassistant/backup_restore.py b/homeassistant/backup_restore.py
index 32991dfb2d3..57e1c734dfc 100644
--- a/homeassistant/backup_restore.py
+++ b/homeassistant/backup_restore.py
@@ -1,6 +1,10 @@
"""Home Assistant module to handle restoring backups."""
+from __future__ import annotations
+
+from collections.abc import Iterable
from dataclasses import dataclass
+import hashlib
import json
import logging
from pathlib import Path
@@ -14,7 +18,12 @@ import securetar
from .const import __version__ as HA_VERSION
RESTORE_BACKUP_FILE = ".HA_RESTORE"
-KEEP_PATHS = ("backups",)
+KEEP_BACKUPS = ("backups",)
+KEEP_DATABASE = (
+ "home-assistant_v2.db",
+ "home-assistant_v2.db-wal",
+)
+
_LOGGER = logging.getLogger(__name__)
@@ -24,6 +33,21 @@ class RestoreBackupFileContent:
"""Definition for restore backup file content."""
backup_file_path: Path
+ password: str | None
+ remove_after_restore: bool
+ restore_database: bool
+ restore_homeassistant: bool
+
+
+def password_to_key(password: str) -> bytes:
+ """Generate a AES Key from password.
+
+ Matches the implementation in supervisor.backups.utils.password_to_key.
+ """
+ key: bytes = password.encode()
+ for _ in range(100):
+ key = hashlib.sha256(key).digest()
+ return key[:16]
def restore_backup_file_content(config_dir: Path) -> RestoreBackupFileContent | None:
@@ -32,20 +56,27 @@ def restore_backup_file_content(config_dir: Path) -> RestoreBackupFileContent |
try:
instruction_content = json.loads(instruction_path.read_text(encoding="utf-8"))
return RestoreBackupFileContent(
- backup_file_path=Path(instruction_content["path"])
+ backup_file_path=Path(instruction_content["path"]),
+ password=instruction_content["password"],
+ remove_after_restore=instruction_content["remove_after_restore"],
+ restore_database=instruction_content["restore_database"],
+ restore_homeassistant=instruction_content["restore_homeassistant"],
)
- except (FileNotFoundError, json.JSONDecodeError):
+ except (FileNotFoundError, KeyError, json.JSONDecodeError):
return None
+ finally:
+ # Always remove the backup instruction file to prevent a boot loop
+ instruction_path.unlink(missing_ok=True)
-def _clear_configuration_directory(config_dir: Path) -> None:
- """Delete all files and directories in the config directory except for the backups directory."""
- keep_paths = [config_dir.joinpath(path) for path in KEEP_PATHS]
- config_contents = sorted(
- [entry for entry in config_dir.iterdir() if entry not in keep_paths]
+def _clear_configuration_directory(config_dir: Path, keep: Iterable[str]) -> None:
+ """Delete all files and directories in the config directory except entries in the keep list."""
+ keep_paths = [config_dir.joinpath(path) for path in keep]
+ entries_to_remove = sorted(
+ entry for entry in config_dir.iterdir() if entry not in keep_paths
)
- for entry in config_contents:
+ for entry in entries_to_remove:
entrypath = config_dir.joinpath(entry)
if entrypath.is_file():
@@ -54,12 +85,15 @@ def _clear_configuration_directory(config_dir: Path) -> None:
shutil.rmtree(entrypath)
-def _extract_backup(config_dir: Path, backup_file_path: Path) -> None:
+def _extract_backup(
+ config_dir: Path,
+ restore_content: RestoreBackupFileContent,
+) -> None:
"""Extract the backup file to the config directory."""
with (
TemporaryDirectory() as tempdir,
securetar.SecureTarFile(
- backup_file_path,
+ restore_content.backup_file_path,
gzip=False,
mode="r",
) as ostf,
@@ -88,22 +122,41 @@ def _extract_backup(config_dir: Path, backup_file_path: Path) -> None:
f"homeassistant.tar{'.gz' if backup_meta["compressed"] else ''}",
),
gzip=backup_meta["compressed"],
+ key=password_to_key(restore_content.password)
+ if restore_content.password is not None
+ else None,
mode="r",
) as istf:
- for member in istf.getmembers():
- if member.name == "data":
- continue
- member.name = member.name.replace("data/", "")
- _clear_configuration_directory(config_dir)
istf.extractall(
- path=config_dir,
- members=[
- member
- for member in securetar.secure_path(istf)
- if member.name != "data"
- ],
+ path=Path(tempdir, "homeassistant"),
+ members=securetar.secure_path(istf),
filter="fully_trusted",
)
+ if restore_content.restore_homeassistant:
+ keep = list(KEEP_BACKUPS)
+ if not restore_content.restore_database:
+ keep.extend(KEEP_DATABASE)
+ _clear_configuration_directory(config_dir, keep)
+ shutil.copytree(
+ Path(tempdir, "homeassistant", "data"),
+ config_dir,
+ dirs_exist_ok=True,
+ ignore=shutil.ignore_patterns(*(keep)),
+ )
+ elif restore_content.restore_database:
+ for entry in KEEP_DATABASE:
+ entrypath = config_dir / entry
+
+ if entrypath.is_file():
+ entrypath.unlink()
+ elif entrypath.is_dir():
+ shutil.rmtree(entrypath)
+
+ for entry in KEEP_DATABASE:
+ shutil.copy(
+ Path(tempdir, "homeassistant", "data", entry),
+ config_dir,
+ )
def restore_backup(config_dir_path: str) -> bool:
@@ -119,8 +172,13 @@ def restore_backup(config_dir_path: str) -> bool:
backup_file_path = restore_content.backup_file_path
_LOGGER.info("Restoring %s", backup_file_path)
try:
- _extract_backup(config_dir, backup_file_path)
+ _extract_backup(
+ config_dir=config_dir,
+ restore_content=restore_content,
+ )
except FileNotFoundError as err:
raise ValueError(f"Backup file {backup_file_path} does not exist") from err
+ if restore_content.remove_after_restore:
+ backup_file_path.unlink(missing_ok=True)
_LOGGER.info("Restore complete, restarting")
return True
diff --git a/homeassistant/block_async_io.py b/homeassistant/block_async_io.py
index 7a68b2515e9..767716dbe27 100644
--- a/homeassistant/block_async_io.py
+++ b/homeassistant/block_async_io.py
@@ -50,6 +50,12 @@ def _check_sleep_call_allowed(mapped_args: dict[str, Any]) -> bool:
return False
+def _check_load_verify_locations_call_allowed(mapped_args: dict[str, Any]) -> bool:
+ # If only cadata is passed, we can ignore it
+ kwargs = mapped_args.get("kwargs")
+ return bool(kwargs and len(kwargs) == 1 and "cadata" in kwargs)
+
+
@dataclass(slots=True, frozen=True)
class BlockingCall:
"""Class to hold information about a blocking call."""
@@ -158,7 +164,7 @@ _BLOCKING_CALLS: tuple[BlockingCall, ...] = (
original_func=SSLContext.load_verify_locations,
object=SSLContext,
function="load_verify_locations",
- check_allowed=None,
+ check_allowed=_check_load_verify_locations_call_allowed,
strict=False,
strict_core=False,
skip_for_tests=True,
diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py
index dcfb6685627..f1f1835863b 100644
--- a/homeassistant/bootstrap.py
+++ b/homeassistant/bootstrap.py
@@ -89,7 +89,7 @@ from .helpers import (
)
from .helpers.dispatcher import async_dispatcher_send_internal
from .helpers.storage import get_internal_store_manager
-from .helpers.system_info import async_get_system_info, is_official_image
+from .helpers.system_info import async_get_system_info
from .helpers.typing import ConfigType
from .setup import (
# _setup_started is marked as protected to make it clear
@@ -106,6 +106,7 @@ from .util.async_ import create_eager_task
from .util.hass_dict import HassKey
from .util.logging import async_activate_log_queue_handler
from .util.package import async_get_user_site, is_docker_env, is_virtual_env
+from .util.system_info import is_official_image
with contextlib.suppress(ImportError):
# Ensure anyio backend is imported to avoid it being imported in the event loop
@@ -252,6 +253,7 @@ PRELOAD_STORAGE = [
"assist_pipeline.pipelines",
"core.analytics",
"auth_module.totp",
+ "backup",
]
@@ -515,7 +517,7 @@ async def async_from_config_dict(
issue_registry.async_create_issue(
hass,
core.DOMAIN,
- "python_version",
+ f"python_version_{required_python_version}",
is_fixable=False,
severity=issue_registry.IssueSeverity.WARNING,
breaks_in_ha_version=REQUIRED_NEXT_PYTHON_HA_RELEASE,
diff --git a/homeassistant/brands/microsoft.json b/homeassistant/brands/microsoft.json
index 9da24e76f19..4d9eb5f95f3 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",
diff --git a/homeassistant/brands/sky.json b/homeassistant/brands/sky.json
new file mode 100644
index 00000000000..3ab0cbbe5bd
--- /dev/null
+++ b/homeassistant/brands/sky.json
@@ -0,0 +1,5 @@
+{
+ "domain": "sky",
+ "name": "Sky",
+ "integrations": ["sky_hub", "sky_remote"]
+}
diff --git a/homeassistant/brands/slide.json b/homeassistant/brands/slide.json
new file mode 100644
index 00000000000..808a54affc3
--- /dev/null
+++ b/homeassistant/brands/slide.json
@@ -0,0 +1,5 @@
+{
+ "domain": "slide",
+ "name": "Slide",
+ "integrations": ["slide", "slide_local"]
+}
diff --git a/homeassistant/components/abode/config_flow.py b/homeassistant/components/abode/config_flow.py
index 1c0186e1003..01b6c7f568f 100644
--- a/homeassistant/components/abode/config_flow.py
+++ b/homeassistant/components/abode/config_flow.py
@@ -112,9 +112,6 @@ class AbodeFlowHandler(ConfigFlow, domain=DOMAIN):
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle a flow initialized by the user."""
- if self._async_current_entries():
- return self.async_abort(reason="single_instance_allowed")
-
if user_input is None:
return self.async_show_form(
step_id="user", data_schema=vol.Schema(self.data_schema)
diff --git a/homeassistant/components/abode/light.py b/homeassistant/components/abode/light.py
index d69aad80875..e2d0a331f0a 100644
--- a/homeassistant/components/abode/light.py
+++ b/homeassistant/components/abode/light.py
@@ -9,18 +9,16 @@ from jaraco.abode.devices.light import Light
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
- ATTR_COLOR_TEMP,
+ ATTR_COLOR_TEMP_KELVIN,
ATTR_HS_COLOR,
+ DEFAULT_MAX_KELVIN,
+ DEFAULT_MIN_KELVIN,
ColorMode,
LightEntity,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from homeassistant.util.color import (
- color_temperature_kelvin_to_mired,
- color_temperature_mired_to_kelvin,
-)
from . import AbodeSystem
from .const import DOMAIN
@@ -44,13 +42,13 @@ class AbodeLight(AbodeDevice, LightEntity):
_device: Light
_attr_name = None
+ _attr_max_color_temp_kelvin = DEFAULT_MAX_KELVIN
+ _attr_min_color_temp_kelvin = DEFAULT_MIN_KELVIN
def turn_on(self, **kwargs: Any) -> None:
"""Turn on the light."""
- if ATTR_COLOR_TEMP in kwargs and self._device.is_color_capable:
- self._device.set_color_temp(
- int(color_temperature_mired_to_kelvin(kwargs[ATTR_COLOR_TEMP]))
- )
+ if ATTR_COLOR_TEMP_KELVIN in kwargs and self._device.is_color_capable:
+ self._device.set_color_temp(kwargs[ATTR_COLOR_TEMP_KELVIN])
return
if ATTR_HS_COLOR in kwargs and self._device.is_color_capable:
@@ -85,10 +83,10 @@ class AbodeLight(AbodeDevice, LightEntity):
return None
@property
- def color_temp(self) -> int | None:
+ def color_temp_kelvin(self) -> int | None:
"""Return the color temp of the light."""
if self._device.has_color:
- return color_temperature_kelvin_to_mired(self._device.color_temp)
+ return int(self._device.color_temp)
return None
@property
diff --git a/homeassistant/components/abode/manifest.json b/homeassistant/components/abode/manifest.json
index 9f5806d544a..c1ffb9f699b 100644
--- a/homeassistant/components/abode/manifest.json
+++ b/homeassistant/components/abode/manifest.json
@@ -9,5 +9,6 @@
},
"iot_class": "cloud_push",
"loggers": ["jaraco.abode", "lomond"],
- "requirements": ["jaraco.abode==6.2.1"]
+ "requirements": ["jaraco.abode==6.2.1"],
+ "single_config_entry": true
}
diff --git a/homeassistant/components/abode/strings.json b/homeassistant/components/abode/strings.json
index 4b98b69eb19..c6887d78042 100644
--- a/homeassistant/components/abode/strings.json
+++ b/homeassistant/components/abode/strings.json
@@ -28,24 +28,23 @@
"invalid_mfa_code": "Invalid MFA code"
},
"abort": {
- "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
}
},
"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",
@@ -59,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/__init__.py b/homeassistant/components/acaia/__init__.py
new file mode 100644
index 00000000000..44f21533e98
--- /dev/null
+++ b/homeassistant/components/acaia/__init__.py
@@ -0,0 +1,31 @@
+"""Initialize the Acaia component."""
+
+from homeassistant.const import Platform
+from homeassistant.core import HomeAssistant
+
+from .coordinator import AcaiaConfigEntry, AcaiaCoordinator
+
+PLATFORMS = [
+ Platform.BINARY_SENSOR,
+ Platform.BUTTON,
+ Platform.SENSOR,
+]
+
+
+async def async_setup_entry(hass: HomeAssistant, entry: AcaiaConfigEntry) -> bool:
+ """Set up acaia as config entry."""
+
+ coordinator = AcaiaCoordinator(hass, entry)
+ await coordinator.async_config_entry_first_refresh()
+
+ entry.runtime_data = coordinator
+
+ await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
+
+ return True
+
+
+async def async_unload_entry(hass: HomeAssistant, entry: AcaiaConfigEntry) -> bool:
+ """Unload a config entry."""
+
+ return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
diff --git a/homeassistant/components/acaia/binary_sensor.py b/homeassistant/components/acaia/binary_sensor.py
new file mode 100644
index 00000000000..ecb7ac06eb5
--- /dev/null
+++ b/homeassistant/components/acaia/binary_sensor.py
@@ -0,0 +1,61 @@
+"""Binary sensor platform for Acaia scales."""
+
+from collections.abc import Callable
+from dataclasses import dataclass
+
+from aioacaia.acaiascale import AcaiaScale
+
+from homeassistant.components.binary_sensor import (
+ BinarySensorDeviceClass,
+ BinarySensorEntity,
+ BinarySensorEntityDescription,
+)
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+
+from .coordinator import AcaiaConfigEntry
+from .entity import AcaiaEntity
+
+# Coordinator is used to centralize the data updates
+PARALLEL_UPDATES = 0
+
+
+@dataclass(kw_only=True, frozen=True)
+class AcaiaBinarySensorEntityDescription(BinarySensorEntityDescription):
+ """Description for Acaia binary sensor entities."""
+
+ is_on_fn: Callable[[AcaiaScale], bool]
+
+
+BINARY_SENSORS: tuple[AcaiaBinarySensorEntityDescription, ...] = (
+ AcaiaBinarySensorEntityDescription(
+ key="timer_running",
+ translation_key="timer_running",
+ device_class=BinarySensorDeviceClass.RUNNING,
+ is_on_fn=lambda scale: scale.timer_running,
+ ),
+)
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: AcaiaConfigEntry,
+ async_add_entities: AddEntitiesCallback,
+) -> None:
+ """Set up binary sensors."""
+
+ coordinator = entry.runtime_data
+ async_add_entities(
+ AcaiaBinarySensor(coordinator, description) for description in BINARY_SENSORS
+ )
+
+
+class AcaiaBinarySensor(AcaiaEntity, BinarySensorEntity):
+ """Representation of an Acaia binary sensor."""
+
+ entity_description: AcaiaBinarySensorEntityDescription
+
+ @property
+ def is_on(self) -> bool:
+ """Return true if the binary sensor is on."""
+ return self.entity_description.is_on_fn(self._scale)
diff --git a/homeassistant/components/acaia/button.py b/homeassistant/components/acaia/button.py
new file mode 100644
index 00000000000..a41233bfc17
--- /dev/null
+++ b/homeassistant/components/acaia/button.py
@@ -0,0 +1,63 @@
+"""Button entities for Acaia scales."""
+
+from collections.abc import Callable, Coroutine
+from dataclasses import dataclass
+from typing import Any
+
+from aioacaia.acaiascale import AcaiaScale
+
+from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+
+from .coordinator import AcaiaConfigEntry
+from .entity import AcaiaEntity
+
+PARALLEL_UPDATES = 0
+
+
+@dataclass(kw_only=True, frozen=True)
+class AcaiaButtonEntityDescription(ButtonEntityDescription):
+ """Description for acaia button entities."""
+
+ press_fn: Callable[[AcaiaScale], Coroutine[Any, Any, None]]
+
+
+BUTTONS: tuple[AcaiaButtonEntityDescription, ...] = (
+ AcaiaButtonEntityDescription(
+ key="tare",
+ translation_key="tare",
+ press_fn=lambda scale: scale.tare(),
+ ),
+ AcaiaButtonEntityDescription(
+ key="reset_timer",
+ translation_key="reset_timer",
+ press_fn=lambda scale: scale.reset_timer(),
+ ),
+ AcaiaButtonEntityDescription(
+ key="start_stop",
+ translation_key="start_stop",
+ press_fn=lambda scale: scale.start_stop_timer(),
+ ),
+)
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: AcaiaConfigEntry,
+ async_add_entities: AddEntitiesCallback,
+) -> None:
+ """Set up button entities and services."""
+
+ coordinator = entry.runtime_data
+ async_add_entities(AcaiaButton(coordinator, description) for description in BUTTONS)
+
+
+class AcaiaButton(AcaiaEntity, ButtonEntity):
+ """Representation of an Acaia button."""
+
+ entity_description: AcaiaButtonEntityDescription
+
+ async def async_press(self) -> None:
+ """Handle the button press."""
+ await self.entity_description.press_fn(self._scale)
diff --git a/homeassistant/components/acaia/config_flow.py b/homeassistant/components/acaia/config_flow.py
new file mode 100644
index 00000000000..fb2639fc886
--- /dev/null
+++ b/homeassistant/components/acaia/config_flow.py
@@ -0,0 +1,149 @@
+"""Config flow for Acaia integration."""
+
+import logging
+from typing import Any
+
+from aioacaia.exceptions import AcaiaDeviceNotFound, AcaiaError, AcaiaUnknownDevice
+from aioacaia.helpers import is_new_scale
+import voluptuous as vol
+
+from homeassistant.components.bluetooth import (
+ BluetoothServiceInfoBleak,
+ async_discovered_service_info,
+)
+from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
+from homeassistant.const import CONF_ADDRESS, CONF_NAME
+from homeassistant.helpers.device_registry import format_mac
+from homeassistant.helpers.selector import (
+ SelectOptionDict,
+ SelectSelector,
+ SelectSelectorConfig,
+ SelectSelectorMode,
+)
+
+from .const import CONF_IS_NEW_STYLE_SCALE, DOMAIN
+
+_LOGGER = logging.getLogger(__name__)
+
+
+class AcaiaConfigFlow(ConfigFlow, domain=DOMAIN):
+ """Handle a config flow for acaia."""
+
+ def __init__(self) -> None:
+ """Initialize the config flow."""
+ self._discovered: dict[str, Any] = {}
+ self._discovered_devices: dict[str, str] = {}
+
+ 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:
+ mac = user_input[CONF_ADDRESS]
+ try:
+ is_new_style_scale = await is_new_scale(mac)
+ except AcaiaDeviceNotFound:
+ errors["base"] = "device_not_found"
+ except AcaiaError:
+ _LOGGER.exception("Error occurred while connecting to the scale")
+ errors["base"] = "unknown"
+ except AcaiaUnknownDevice:
+ return self.async_abort(reason="unsupported_device")
+ else:
+ await self.async_set_unique_id(format_mac(mac))
+ self._abort_if_unique_id_configured()
+
+ if not errors:
+ return self.async_create_entry(
+ title=self._discovered_devices[mac],
+ data={
+ CONF_ADDRESS: mac,
+ CONF_IS_NEW_STYLE_SCALE: is_new_style_scale,
+ },
+ )
+
+ for device in async_discovered_service_info(self.hass):
+ self._discovered_devices[device.address] = device.name
+
+ if not self._discovered_devices:
+ return self.async_abort(reason="no_devices_found")
+
+ options = [
+ SelectOptionDict(
+ value=device_mac,
+ label=f"{device_name} ({device_mac})",
+ )
+ for device_mac, device_name in self._discovered_devices.items()
+ ]
+
+ return self.async_show_form(
+ step_id="user",
+ data_schema=vol.Schema(
+ {
+ vol.Required(CONF_ADDRESS): SelectSelector(
+ SelectSelectorConfig(
+ options=options,
+ mode=SelectSelectorMode.DROPDOWN,
+ )
+ )
+ }
+ ),
+ errors=errors,
+ )
+
+ async def async_step_bluetooth(
+ self, discovery_info: BluetoothServiceInfoBleak
+ ) -> ConfigFlowResult:
+ """Handle a discovered Bluetooth device."""
+
+ self._discovered[CONF_ADDRESS] = discovery_info.address
+ self._discovered[CONF_NAME] = discovery_info.name
+
+ await self.async_set_unique_id(format_mac(discovery_info.address))
+ self._abort_if_unique_id_configured()
+
+ try:
+ self._discovered[CONF_IS_NEW_STYLE_SCALE] = await is_new_scale(
+ discovery_info.address
+ )
+ except AcaiaDeviceNotFound:
+ _LOGGER.debug("Device not found during discovery")
+ return self.async_abort(reason="device_not_found")
+ except AcaiaError:
+ _LOGGER.debug(
+ "Error occurred while connecting to the scale during discovery",
+ exc_info=True,
+ )
+ return self.async_abort(reason="unknown")
+ except AcaiaUnknownDevice:
+ _LOGGER.debug("Unsupported device during discovery")
+ return self.async_abort(reason="unsupported_device")
+
+ return await self.async_step_bluetooth_confirm()
+
+ async def async_step_bluetooth_confirm(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Handle confirmation of Bluetooth discovery."""
+
+ if user_input is not None:
+ return self.async_create_entry(
+ title=self._discovered[CONF_NAME],
+ data={
+ CONF_ADDRESS: self._discovered[CONF_ADDRESS],
+ CONF_IS_NEW_STYLE_SCALE: self._discovered[CONF_IS_NEW_STYLE_SCALE],
+ },
+ )
+
+ self.context["title_placeholders"] = placeholders = {
+ CONF_NAME: self._discovered[CONF_NAME]
+ }
+
+ self._set_confirm_only()
+ return self.async_show_form(
+ step_id="bluetooth_confirm",
+ description_placeholders=placeholders,
+ )
diff --git a/homeassistant/components/acaia/const.py b/homeassistant/components/acaia/const.py
new file mode 100644
index 00000000000..c603578763d
--- /dev/null
+++ b/homeassistant/components/acaia/const.py
@@ -0,0 +1,4 @@
+"""Constants for component."""
+
+DOMAIN = "acaia"
+CONF_IS_NEW_STYLE_SCALE = "is_new_style_scale"
diff --git a/homeassistant/components/acaia/coordinator.py b/homeassistant/components/acaia/coordinator.py
new file mode 100644
index 00000000000..bd915b42408
--- /dev/null
+++ b/homeassistant/components/acaia/coordinator.py
@@ -0,0 +1,86 @@
+"""Coordinator for Acaia integration."""
+
+from __future__ import annotations
+
+from datetime import timedelta
+import logging
+
+from aioacaia.acaiascale import AcaiaScale
+from aioacaia.exceptions import AcaiaDeviceNotFound, AcaiaError
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import CONF_ADDRESS
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
+
+from .const import CONF_IS_NEW_STYLE_SCALE
+
+SCAN_INTERVAL = timedelta(seconds=15)
+
+_LOGGER = logging.getLogger(__name__)
+
+type AcaiaConfigEntry = ConfigEntry[AcaiaCoordinator]
+
+
+class AcaiaCoordinator(DataUpdateCoordinator[None]):
+ """Class to handle fetching data from the scale."""
+
+ config_entry: AcaiaConfigEntry
+
+ def __init__(self, hass: HomeAssistant, entry: AcaiaConfigEntry) -> None:
+ """Initialize coordinator."""
+ super().__init__(
+ hass,
+ _LOGGER,
+ name="acaia coordinator",
+ update_interval=SCAN_INTERVAL,
+ config_entry=entry,
+ )
+
+ self._scale = AcaiaScale(
+ address_or_ble_device=entry.data[CONF_ADDRESS],
+ name=entry.title,
+ is_new_style_scale=entry.data[CONF_IS_NEW_STYLE_SCALE],
+ notify_callback=self.async_update_listeners,
+ )
+
+ @property
+ def scale(self) -> AcaiaScale:
+ """Return the scale object."""
+ return self._scale
+
+ async def _async_update_data(self) -> None:
+ """Fetch data."""
+
+ # scale is already connected, return
+ if self._scale.connected:
+ return
+
+ # scale is not connected, try to connect
+ try:
+ await self._scale.connect(setup_tasks=False)
+ except (AcaiaDeviceNotFound, AcaiaError, TimeoutError) as ex:
+ _LOGGER.debug(
+ "Could not connect to scale: %s, Error: %s",
+ self.config_entry.data[CONF_ADDRESS],
+ ex,
+ )
+ self._scale.device_disconnected_handler(notify=False)
+ return
+
+ # connected, set up background tasks
+ if not self._scale.heartbeat_task or self._scale.heartbeat_task.done():
+ self._scale.heartbeat_task = self.config_entry.async_create_background_task(
+ hass=self.hass,
+ target=self._scale.send_heartbeats(),
+ name="acaia_heartbeat_task",
+ )
+
+ if not self._scale.process_queue_task or self._scale.process_queue_task.done():
+ self._scale.process_queue_task = (
+ self.config_entry.async_create_background_task(
+ hass=self.hass,
+ target=self._scale.process_queue(),
+ name="acaia_process_queue_task",
+ )
+ )
diff --git a/homeassistant/components/acaia/diagnostics.py b/homeassistant/components/acaia/diagnostics.py
new file mode 100644
index 00000000000..2d9f4511804
--- /dev/null
+++ b/homeassistant/components/acaia/diagnostics.py
@@ -0,0 +1,31 @@
+"""Diagnostics support for Acaia."""
+
+from __future__ import annotations
+
+from dataclasses import asdict
+from typing import Any
+
+from homeassistant.core import HomeAssistant
+
+from . import AcaiaConfigEntry
+
+
+async def async_get_config_entry_diagnostics(
+ hass: HomeAssistant,
+ entry: AcaiaConfigEntry,
+) -> dict[str, Any]:
+ """Return diagnostics for a config entry."""
+ coordinator = entry.runtime_data
+ scale = coordinator.scale
+
+ # collect all data sources
+ return {
+ "model": scale.model,
+ "device_state": (
+ asdict(scale.device_state) if scale.device_state is not None else ""
+ ),
+ "mac": scale.mac,
+ "last_disconnect_time": scale.last_disconnect_time,
+ "timer": scale.timer,
+ "weight": scale.weight,
+ }
diff --git a/homeassistant/components/acaia/entity.py b/homeassistant/components/acaia/entity.py
new file mode 100644
index 00000000000..bef1ac313ca
--- /dev/null
+++ b/homeassistant/components/acaia/entity.py
@@ -0,0 +1,46 @@
+"""Base class for Acaia entities."""
+
+from dataclasses import dataclass
+
+from homeassistant.helpers.device_registry import (
+ CONNECTION_BLUETOOTH,
+ DeviceInfo,
+ format_mac,
+)
+from homeassistant.helpers.entity import EntityDescription
+from homeassistant.helpers.update_coordinator import CoordinatorEntity
+
+from .const import DOMAIN
+from .coordinator import AcaiaCoordinator
+
+
+@dataclass
+class AcaiaEntity(CoordinatorEntity[AcaiaCoordinator]):
+ """Common elements for all entities."""
+
+ _attr_has_entity_name = True
+
+ def __init__(
+ self,
+ coordinator: AcaiaCoordinator,
+ entity_description: EntityDescription,
+ ) -> None:
+ """Initialize the entity."""
+ super().__init__(coordinator)
+ self.entity_description = entity_description
+ self._scale = coordinator.scale
+ formatted_mac = format_mac(self._scale.mac)
+ self._attr_unique_id = f"{formatted_mac}_{entity_description.key}"
+
+ self._attr_device_info = DeviceInfo(
+ identifiers={(DOMAIN, formatted_mac)},
+ manufacturer="Acaia",
+ model=self._scale.model,
+ suggested_area="Kitchen",
+ connections={(CONNECTION_BLUETOOTH, self._scale.mac)},
+ )
+
+ @property
+ def available(self) -> bool:
+ """Returns whether entity is available."""
+ return super().available and self._scale.connected
diff --git a/homeassistant/components/acaia/icons.json b/homeassistant/components/acaia/icons.json
new file mode 100644
index 00000000000..59b316a36ce
--- /dev/null
+++ b/homeassistant/components/acaia/icons.json
@@ -0,0 +1,24 @@
+{
+ "entity": {
+ "binary_sensor": {
+ "timer_running": {
+ "default": "mdi:timer",
+ "state": {
+ "on": "mdi:timer-play",
+ "off": "mdi:timer-off"
+ }
+ }
+ },
+ "button": {
+ "tare": {
+ "default": "mdi:scale-balance"
+ },
+ "reset_timer": {
+ "default": "mdi:timer-refresh"
+ },
+ "start_stop": {
+ "default": "mdi:timer-play"
+ }
+ }
+ }
+}
diff --git a/homeassistant/components/acaia/manifest.json b/homeassistant/components/acaia/manifest.json
new file mode 100644
index 00000000000..681f3f08555
--- /dev/null
+++ b/homeassistant/components/acaia/manifest.json
@@ -0,0 +1,30 @@
+{
+ "domain": "acaia",
+ "name": "Acaia",
+ "bluetooth": [
+ {
+ "manufacturer_id": 16962
+ },
+ {
+ "local_name": "ACAIA*"
+ },
+ {
+ "local_name": "PYXIS-*"
+ },
+ {
+ "local_name": "LUNAR-*"
+ },
+ {
+ "local_name": "PROCHBT001"
+ }
+ ],
+ "codeowners": ["@zweckj"],
+ "config_flow": true,
+ "dependencies": ["bluetooth_adapters"],
+ "documentation": "https://www.home-assistant.io/integrations/acaia",
+ "integration_type": "device",
+ "iot_class": "local_push",
+ "loggers": ["aioacaia"],
+ "quality_scale": "platinum",
+ "requirements": ["aioacaia==0.1.13"]
+}
diff --git a/homeassistant/components/acaia/quality_scale.yaml b/homeassistant/components/acaia/quality_scale.yaml
new file mode 100644
index 00000000000..62573e38799
--- /dev/null
+++ b/homeassistant/components/acaia/quality_scale.yaml
@@ -0,0 +1,106 @@
+rules:
+ # Bronze
+ action-setup:
+ status: exempt
+ comment: |
+ No custom actions are defined.
+ appropriate-polling: done
+ brands: done
+ common-modules: done
+ config-flow-test-coverage: done
+ config-flow: done
+ dependency-transparency: done
+ docs-actions:
+ status: exempt
+ comment: |
+ No custom actions are defined.
+ 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: done
+ test-before-setup:
+ status: exempt
+ comment: |
+ Device is expected to be offline most of the time, but needs to connect quickly once available.
+ unique-config-entry: done
+ # Silver
+ action-exceptions:
+ status: exempt
+ comment: |
+ No custom actions are defined.
+ config-entry-unloading: done
+ docs-configuration-parameters: done
+ docs-installation-parameters: done
+ entity-unavailable: done
+ integration-owner: done
+ log-when-unavailable:
+ status: done
+ comment: |
+ Handled by coordinator.
+ parallel-updates: done
+ reauthentication-flow:
+ status: exempt
+ comment: |
+ No authentication required.
+ test-coverage: done
+ # Gold
+ devices: done
+ diagnostics: done
+ discovery-update-info:
+ status: exempt
+ comment: |
+ No IP discovery.
+ discovery:
+ status: done
+ comment: |
+ Bluetooth 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: |
+ Device type integration.
+ entity-category: done
+ entity-device-class: done
+ entity-disabled-by-default:
+ status: exempt
+ comment: |
+ No noisy/non-essential entities.
+ entity-translations: done
+ exception-translations:
+ status: exempt
+ comment: |
+ No custom exceptions.
+ icon-translations: done
+ reconfiguration-flow:
+ status: exempt
+ comment: |
+ Only parameter that could be changed (MAC = unique_id) would force a new config entry.
+ repair-issues:
+ status: exempt
+ comment: |
+ No repairs/issues.
+ stale-devices:
+ status: exempt
+ comment: |
+ Device type integration.
+
+ # Platinum
+ async-dependency: done
+ inject-websession:
+ status: exempt
+ comment: |
+ Bluetooth connection.
+ strict-typing: done
diff --git a/homeassistant/components/acaia/sensor.py b/homeassistant/components/acaia/sensor.py
new file mode 100644
index 00000000000..7ba44958eca
--- /dev/null
+++ b/homeassistant/components/acaia/sensor.py
@@ -0,0 +1,146 @@
+"""Sensor platform for Acaia."""
+
+from collections.abc import Callable
+from dataclasses import dataclass
+
+from aioacaia.acaiascale import AcaiaDeviceState, AcaiaScale
+from aioacaia.const import UnitMass as AcaiaUnitOfMass
+
+from homeassistant.components.sensor import (
+ RestoreSensor,
+ SensorDeviceClass,
+ SensorEntity,
+ SensorEntityDescription,
+ SensorExtraStoredData,
+ SensorStateClass,
+)
+from homeassistant.const import PERCENTAGE, UnitOfMass, UnitOfVolumeFlowRate
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+
+from .coordinator import AcaiaConfigEntry
+from .entity import AcaiaEntity
+
+# Coordinator is used to centralize the data updates
+PARALLEL_UPDATES = 0
+
+
+@dataclass(kw_only=True, frozen=True)
+class AcaiaSensorEntityDescription(SensorEntityDescription):
+ """Description for Acaia sensor entities."""
+
+ value_fn: Callable[[AcaiaScale], int | float | None]
+
+
+@dataclass(kw_only=True, frozen=True)
+class AcaiaDynamicUnitSensorEntityDescription(AcaiaSensorEntityDescription):
+ """Description for Acaia sensor entities with dynamic units."""
+
+ unit_fn: Callable[[AcaiaDeviceState], str] | None = None
+
+
+SENSORS: tuple[AcaiaSensorEntityDescription, ...] = (
+ AcaiaDynamicUnitSensorEntityDescription(
+ key="weight",
+ device_class=SensorDeviceClass.WEIGHT,
+ native_unit_of_measurement=UnitOfMass.GRAMS,
+ state_class=SensorStateClass.MEASUREMENT,
+ unit_fn=lambda data: (
+ UnitOfMass.OUNCES
+ if data.units == AcaiaUnitOfMass.OUNCES
+ else UnitOfMass.GRAMS
+ ),
+ value_fn=lambda scale: scale.weight,
+ ),
+ AcaiaDynamicUnitSensorEntityDescription(
+ key="flow_rate",
+ device_class=SensorDeviceClass.VOLUME_FLOW_RATE,
+ native_unit_of_measurement=UnitOfVolumeFlowRate.MILLILITERS_PER_SECOND,
+ suggested_display_precision=1,
+ state_class=SensorStateClass.MEASUREMENT,
+ value_fn=lambda scale: scale.flow_rate,
+ ),
+)
+RESTORE_SENSORS: tuple[AcaiaSensorEntityDescription, ...] = (
+ AcaiaSensorEntityDescription(
+ key="battery",
+ device_class=SensorDeviceClass.BATTERY,
+ native_unit_of_measurement=PERCENTAGE,
+ state_class=SensorStateClass.MEASUREMENT,
+ value_fn=lambda scale: (
+ scale.device_state.battery_level if scale.device_state else None
+ ),
+ ),
+)
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: AcaiaConfigEntry,
+ async_add_entities: AddEntitiesCallback,
+) -> None:
+ """Set up sensors."""
+
+ coordinator = entry.runtime_data
+ entities: list[SensorEntity] = [
+ AcaiaSensor(coordinator, entity_description) for entity_description in SENSORS
+ ]
+ entities.extend(
+ AcaiaRestoreSensor(coordinator, entity_description)
+ for entity_description in RESTORE_SENSORS
+ )
+ async_add_entities(entities)
+
+
+class AcaiaSensor(AcaiaEntity, SensorEntity):
+ """Representation of an Acaia sensor."""
+
+ entity_description: AcaiaDynamicUnitSensorEntityDescription
+
+ @property
+ def native_unit_of_measurement(self) -> str | None:
+ """Return the unit of measurement of this entity."""
+ if (
+ self._scale.device_state is not None
+ and self.entity_description.unit_fn is not None
+ ):
+ return self.entity_description.unit_fn(self._scale.device_state)
+ return self.entity_description.native_unit_of_measurement
+
+ @property
+ def native_value(self) -> int | float | None:
+ """Return the state of the entity."""
+ return self.entity_description.value_fn(self._scale)
+
+
+class AcaiaRestoreSensor(AcaiaEntity, RestoreSensor):
+ """Representation of an Acaia sensor with restore capabilities."""
+
+ entity_description: AcaiaSensorEntityDescription
+ _restored_data: SensorExtraStoredData | None = None
+
+ async def async_added_to_hass(self) -> None:
+ """Handle entity which will be added."""
+ await super().async_added_to_hass()
+
+ self._restored_data = await self.async_get_last_sensor_data()
+ if self._restored_data is not None:
+ self._attr_native_value = self._restored_data.native_value
+ self._attr_native_unit_of_measurement = (
+ self._restored_data.native_unit_of_measurement
+ )
+
+ if self._scale.device_state is not None:
+ self._attr_native_value = self.entity_description.value_fn(self._scale)
+
+ @callback
+ def _handle_coordinator_update(self) -> None:
+ """Handle updated data from the coordinator."""
+ if self._scale.device_state is not None:
+ self._attr_native_value = self.entity_description.value_fn(self._scale)
+ self._async_write_ha_state()
+
+ @property
+ def available(self) -> bool:
+ """Return True if entity is available."""
+ return super().available or self._restored_data is not None
diff --git a/homeassistant/components/acaia/strings.json b/homeassistant/components/acaia/strings.json
new file mode 100644
index 00000000000..e0e97b7c2ff
--- /dev/null
+++ b/homeassistant/components/acaia/strings.json
@@ -0,0 +1,46 @@
+{
+ "config": {
+ "flow_title": "{name}",
+ "abort": {
+ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
+ "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]",
+ "unsupported_device": "This device is not supported."
+ },
+ "error": {
+ "device_not_found": "Device could not be found.",
+ "unknown": "[%key:common::config_flow::error::unknown%]"
+ },
+ "step": {
+ "bluetooth_confirm": {
+ "description": "[%key:component::bluetooth::config::step::bluetooth_confirm::description%]"
+ },
+ "user": {
+ "description": "[%key:component::bluetooth::config::step::user::description%]",
+ "data": {
+ "address": "[%key:common::config_flow::data::device%]"
+ },
+ "data_description": {
+ "address": "Select Acaia scale you want to set up"
+ }
+ }
+ }
+ },
+ "entity": {
+ "binary_sensor": {
+ "timer_running": {
+ "name": "Timer running"
+ }
+ },
+ "button": {
+ "tare": {
+ "name": "Tare"
+ },
+ "reset_timer": {
+ "name": "Reset timer"
+ },
+ "start_stop": {
+ "name": "Start/stop timer"
+ }
+ }
+ }
+}
diff --git a/homeassistant/components/accuweather/manifest.json b/homeassistant/components/accuweather/manifest.json
index 24a8180eef8..75f4a265b5f 100644
--- a/homeassistant/components/accuweather/manifest.json
+++ b/homeassistant/components/accuweather/manifest.json
@@ -7,7 +7,6 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["accuweather"],
- "quality_scale": "platinum",
- "requirements": ["accuweather==3.0.0"],
+ "requirements": ["accuweather==4.0.0"],
"single_config_entry": true
}
diff --git a/homeassistant/components/acer_projector/manifest.json b/homeassistant/components/acer_projector/manifest.json
index 58a2372e42a..026374bf53d 100644
--- a/homeassistant/components/acer_projector/manifest.json
+++ b/homeassistant/components/acer_projector/manifest.json
@@ -4,5 +4,6 @@
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/acer_projector",
"iot_class": "local_polling",
+ "quality_scale": "legacy",
"requirements": ["pyserial==3.5"]
}
diff --git a/homeassistant/components/actiontec/manifest.json b/homeassistant/components/actiontec/manifest.json
index ff9cf85614f..e7aa33f1baf 100644
--- a/homeassistant/components/actiontec/manifest.json
+++ b/homeassistant/components/actiontec/manifest.json
@@ -3,5 +3,6 @@
"name": "Actiontec",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/actiontec",
- "iot_class": "local_polling"
+ "iot_class": "local_polling",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/adax/climate.py b/homeassistant/components/adax/climate.py
index ac381ff46d5..15022ba3c9f 100644
--- a/homeassistant/components/adax/climate.py
+++ b/homeassistant/components/adax/climate.py
@@ -75,7 +75,6 @@ class AdaxDevice(ClimateEntity):
)
_attr_target_temperature_step = PRECISION_WHOLE
_attr_temperature_unit = UnitOfTemperature.CELSIUS
- _enable_turn_on_off_backwards_compatibility = False
def __init__(self, heater_data: dict[str, Any], adax_data_handler: Adax) -> None:
"""Initialize the heater."""
diff --git a/homeassistant/components/ads/cover.py b/homeassistant/components/ads/cover.py
index 541f8bfc82c..c7b0f4f2f8a 100644
--- a/homeassistant/components/ads/cover.py
+++ b/homeassistant/components/ads/cover.py
@@ -37,7 +37,7 @@ STATE_KEY_POSITION = "position"
PLATFORM_SCHEMA = COVER_PLATFORM_SCHEMA.extend(
{
- vol.Optional(CONF_ADS_VAR): cv.string,
+ vol.Required(CONF_ADS_VAR): cv.string,
vol.Optional(CONF_ADS_VAR_POSITION): cv.string,
vol.Optional(CONF_ADS_VAR_SET_POS): cv.string,
vol.Optional(CONF_ADS_VAR_CLOSE): cv.string,
diff --git a/homeassistant/components/ads/manifest.json b/homeassistant/components/ads/manifest.json
index 86fc54ea784..683c3cb619f 100644
--- a/homeassistant/components/ads/manifest.json
+++ b/homeassistant/components/ads/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/ads",
"iot_class": "local_push",
"loggers": ["pyads"],
+ "quality_scale": "legacy",
"requirements": ["pyads==3.4.0"]
}
diff --git a/homeassistant/components/advantage_air/climate.py b/homeassistant/components/advantage_air/climate.py
index 8da46cc7463..d07a3182ed7 100644
--- a/homeassistant/components/advantage_air/climate.py
+++ b/homeassistant/components/advantage_air/climate.py
@@ -102,7 +102,6 @@ class AdvantageAirAC(AdvantageAirAcEntity, ClimateEntity):
_attr_max_temp = 32
_attr_min_temp = 16
_attr_name = None
- _enable_turn_on_off_backwards_compatibility = False
_support_preset = ClimateEntityFeature(0)
def __init__(self, instance: AdvantageAirData, ac_key: str) -> None:
@@ -261,7 +260,6 @@ class AdvantageAirZone(AdvantageAirZoneEntity, ClimateEntity):
_attr_target_temperature_step = PRECISION_WHOLE
_attr_max_temp = 32
_attr_min_temp = 16
- _enable_turn_on_off_backwards_compatibility = False
def __init__(self, instance: AdvantageAirData, ac_key: str, zone_key: str) -> None:
"""Initialize an AdvantageAir Zone control."""
diff --git a/homeassistant/components/advantage_air/manifest.json b/homeassistant/components/advantage_air/manifest.json
index a07d14896eb..553a641b603 100644
--- a/homeassistant/components/advantage_air/manifest.json
+++ b/homeassistant/components/advantage_air/manifest.json
@@ -6,6 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/advantage_air",
"iot_class": "local_polling",
"loggers": ["advantage_air"],
- "quality_scale": "platinum",
"requirements": ["advantage-air==0.4.4"]
}
diff --git a/homeassistant/components/aemet/__init__.py b/homeassistant/components/aemet/__init__.py
index 29bc044c67d..4bd9dd03eea 100644
--- a/homeassistant/components/aemet/__init__.py
+++ b/homeassistant/components/aemet/__init__.py
@@ -1,17 +1,19 @@
"""The AEMET OpenData component."""
import logging
+import shutil
from aemet_opendata.exceptions import AemetError, TownNotFound
-from aemet_opendata.interface import AEMET, ConnectionOptions
+from aemet_opendata.interface import AEMET, ConnectionOptions, UpdateFeature
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import aiohttp_client
+from homeassistant.helpers.storage import STORAGE_DIR
-from .const import CONF_STATION_UPDATES, PLATFORMS
+from .const import CONF_RADAR_UPDATES, CONF_STATION_UPDATES, DOMAIN, PLATFORMS
from .coordinator import AemetConfigEntry, AemetData, WeatherUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
@@ -23,10 +25,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: AemetConfigEntry) -> boo
api_key = entry.data[CONF_API_KEY]
latitude = entry.data[CONF_LATITUDE]
longitude = entry.data[CONF_LONGITUDE]
- station_updates = entry.options.get(CONF_STATION_UPDATES, True)
+ update_features: int = UpdateFeature.FORECAST
+ if entry.options.get(CONF_RADAR_UPDATES, False):
+ update_features |= UpdateFeature.RADAR
+ if entry.options.get(CONF_STATION_UPDATES, True):
+ update_features |= UpdateFeature.STATION
- options = ConnectionOptions(api_key, station_updates)
+ options = ConnectionOptions(api_key, update_features)
aemet = AEMET(aiohttp_client.async_get_clientsession(hass), options)
+ aemet.set_api_data_dir(hass.config.path(STORAGE_DIR, f"{DOMAIN}-{entry.unique_id}"))
+
try:
await aemet.select_coordinates(latitude, longitude)
except TownNotFound as err:
@@ -55,3 +63,11 @@ async def async_update_options(hass: HomeAssistant, entry: ConfigEntry) -> None:
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
+
+
+async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
+ """Remove a config entry."""
+ await hass.async_add_executor_job(
+ shutil.rmtree,
+ hass.config.path(STORAGE_DIR, f"{DOMAIN}-{entry.unique_id}"),
+ )
diff --git a/homeassistant/components/aemet/config_flow.py b/homeassistant/components/aemet/config_flow.py
index 6b2eca3f5c9..80b5c07e6bd 100644
--- a/homeassistant/components/aemet/config_flow.py
+++ b/homeassistant/components/aemet/config_flow.py
@@ -17,10 +17,11 @@ from homeassistant.helpers.schema_config_entry_flow import (
SchemaOptionsFlowHandler,
)
-from .const import CONF_STATION_UPDATES, DEFAULT_NAME, DOMAIN
+from .const import CONF_RADAR_UPDATES, CONF_STATION_UPDATES, DEFAULT_NAME, DOMAIN
OPTIONS_SCHEMA = vol.Schema(
{
+ vol.Required(CONF_RADAR_UPDATES, default=False): bool,
vol.Required(CONF_STATION_UPDATES, default=True): bool,
}
)
@@ -45,7 +46,7 @@ class AemetConfigFlow(ConfigFlow, domain=DOMAIN):
await self.async_set_unique_id(f"{latitude}-{longitude}")
self._abort_if_unique_id_configured()
- options = ConnectionOptions(user_input[CONF_API_KEY], False)
+ options = ConnectionOptions(user_input[CONF_API_KEY])
aemet = AEMET(aiohttp_client.async_get_clientsession(self.hass), options)
try:
await aemet.select_coordinates(latitude, longitude)
diff --git a/homeassistant/components/aemet/const.py b/homeassistant/components/aemet/const.py
index 665075c4093..b79a94d209d 100644
--- a/homeassistant/components/aemet/const.py
+++ b/homeassistant/components/aemet/const.py
@@ -51,8 +51,9 @@ from homeassistant.components.weather import (
from homeassistant.const import Platform
ATTRIBUTION = "Powered by AEMET OpenData"
+CONF_RADAR_UPDATES = "radar_updates"
CONF_STATION_UPDATES = "station_updates"
-PLATFORMS = [Platform.SENSOR, Platform.WEATHER]
+PLATFORMS = [Platform.IMAGE, Platform.SENSOR, Platform.WEATHER]
DEFAULT_NAME = "AEMET"
DOMAIN = "aemet"
diff --git a/homeassistant/components/aemet/diagnostics.py b/homeassistant/components/aemet/diagnostics.py
index bc366fc6d44..b072309d4b8 100644
--- a/homeassistant/components/aemet/diagnostics.py
+++ b/homeassistant/components/aemet/diagnostics.py
@@ -4,7 +4,7 @@ from __future__ import annotations
from typing import Any
-from aemet_opendata.const import AOD_COORDS
+from aemet_opendata.const import AOD_COORDS, AOD_IMG_BYTES
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.const import (
@@ -26,6 +26,7 @@ TO_REDACT_CONFIG = [
TO_REDACT_COORD = [
AOD_COORDS,
+ AOD_IMG_BYTES,
]
diff --git a/homeassistant/components/aemet/image.py b/homeassistant/components/aemet/image.py
new file mode 100644
index 00000000000..ffc53022e4c
--- /dev/null
+++ b/homeassistant/components/aemet/image.py
@@ -0,0 +1,86 @@
+"""Support for the AEMET OpenData images."""
+
+from __future__ import annotations
+
+from typing import Final
+
+from aemet_opendata.const import AOD_DATETIME, AOD_IMG_BYTES, AOD_IMG_TYPE, AOD_RADAR
+from aemet_opendata.helpers import dict_nested_value
+
+from homeassistant.components.image import Image, ImageEntity, ImageEntityDescription
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+
+from .coordinator import AemetConfigEntry, WeatherUpdateCoordinator
+from .entity import AemetEntity
+
+AEMET_IMAGES: Final[tuple[ImageEntityDescription, ...]] = (
+ ImageEntityDescription(
+ key=AOD_RADAR,
+ translation_key="weather_radar",
+ ),
+)
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ config_entry: AemetConfigEntry,
+ async_add_entities: AddEntitiesCallback,
+) -> None:
+ """Set up AEMET OpenData image entities based on a config entry."""
+ domain_data = config_entry.runtime_data
+ name = domain_data.name
+ coordinator = domain_data.coordinator
+
+ unique_id = config_entry.unique_id
+ assert unique_id is not None
+
+ async_add_entities(
+ AemetImage(
+ hass,
+ name,
+ coordinator,
+ description,
+ unique_id,
+ )
+ for description in AEMET_IMAGES
+ if dict_nested_value(coordinator.data["lib"], [description.key]) is not None
+ )
+
+
+class AemetImage(AemetEntity, ImageEntity):
+ """Implementation of an AEMET OpenData image."""
+
+ entity_description: ImageEntityDescription
+
+ def __init__(
+ self,
+ hass: HomeAssistant,
+ name: str,
+ coordinator: WeatherUpdateCoordinator,
+ description: ImageEntityDescription,
+ unique_id: str,
+ ) -> None:
+ """Initialize the image."""
+ super().__init__(coordinator, name, unique_id)
+ ImageEntity.__init__(self, hass)
+ self.entity_description = description
+ self._attr_unique_id = f"{unique_id}-{description.key}"
+
+ self._async_update_attrs()
+
+ @callback
+ def _handle_coordinator_update(self) -> None:
+ """Update attributes when the coordinator updates."""
+ self._async_update_attrs()
+ super()._handle_coordinator_update()
+
+ @callback
+ def _async_update_attrs(self) -> None:
+ """Update image attributes."""
+ image_data = self.get_aemet_value([self.entity_description.key])
+ self._cached_image = Image(
+ content_type=image_data.get(AOD_IMG_TYPE),
+ content=image_data.get(AOD_IMG_BYTES),
+ )
+ self._attr_image_last_updated = image_data.get(AOD_DATETIME)
diff --git a/homeassistant/components/aemet/manifest.json b/homeassistant/components/aemet/manifest.json
index 3696e16b437..24ca0099091 100644
--- a/homeassistant/components/aemet/manifest.json
+++ b/homeassistant/components/aemet/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/aemet",
"iot_class": "cloud_polling",
"loggers": ["aemet_opendata"],
- "requirements": ["AEMET-OpenData==0.5.4"]
+ "requirements": ["AEMET-OpenData==0.6.4"]
}
diff --git a/homeassistant/components/aemet/strings.json b/homeassistant/components/aemet/strings.json
index 75c810978ad..d65c546b050 100644
--- a/homeassistant/components/aemet/strings.json
+++ b/homeassistant/components/aemet/strings.json
@@ -18,10 +18,18 @@
}
}
},
+ "entity": {
+ "image": {
+ "weather_radar": {
+ "name": "Weather radar"
+ }
+ }
+ },
"options": {
"step": {
"init": {
"data": {
+ "radar_updates": "Gather data from AEMET weather radar",
"station_updates": "Gather data from AEMET weather stations"
}
}
diff --git a/homeassistant/components/airgradient/quality_scale.yaml b/homeassistant/components/airgradient/quality_scale.yaml
new file mode 100644
index 00000000000..43816401cdb
--- /dev/null
+++ b/homeassistant/components/airgradient/quality_scale.yaml
@@ -0,0 +1,86 @@
+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: todo
+ docs-installation-instructions: todo
+ docs-removal-instructions: todo
+ 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: todo
+ config-entry-unloading: done
+ docs-configuration-parameters:
+ status: exempt
+ comment: No options to configure
+ docs-installation-parameters: todo
+ entity-unavailable: done
+ integration-owner: done
+ log-when-unavailable: done
+ parallel-updates: todo
+ reauthentication-flow:
+ status: exempt
+ comment: |
+ This integration does not require authentication.
+ test-coverage: todo
+ # Gold
+ devices: done
+ diagnostics: done
+ discovery-update-info:
+ status: todo
+ comment: DHCP is still possible
+ discovery:
+ status: todo
+ comment: DHCP is still possible
+ 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 has a fixed single 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: |
+ 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/airly/manifest.json b/homeassistant/components/airly/manifest.json
index 233625ab04a..ccd37589e8c 100644
--- a/homeassistant/components/airly/manifest.json
+++ b/homeassistant/components/airly/manifest.json
@@ -7,6 +7,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["airly"],
- "quality_scale": "platinum",
"requirements": ["airly==1.1.0"]
}
diff --git a/homeassistant/components/airq/manifest.json b/homeassistant/components/airq/manifest.json
index 2b23928aba8..1ae7da14875 100644
--- a/homeassistant/components/airq/manifest.json
+++ b/homeassistant/components/airq/manifest.json
@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["aioairq"],
- "requirements": ["aioairq==0.3.2"]
+ "requirements": ["aioairq==0.4.3"]
}
diff --git a/homeassistant/components/airthings/sensor.py b/homeassistant/components/airthings/sensor.py
index 74d712ccfc6..f35d5c9667c 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,
),
}
diff --git a/homeassistant/components/airtouch4/climate.py b/homeassistant/components/airtouch4/climate.py
index dbb6f02859b..0af920bd7a9 100644
--- a/homeassistant/components/airtouch4/climate.py
+++ b/homeassistant/components/airtouch4/climate.py
@@ -95,7 +95,6 @@ class AirtouchAC(CoordinatorEntity, ClimateEntity):
| ClimateEntityFeature.TURN_ON
)
_attr_temperature_unit = UnitOfTemperature.CELSIUS
- _enable_turn_on_off_backwards_compatibility = False
def __init__(self, coordinator, ac_number, info):
"""Initialize the climate device."""
@@ -205,7 +204,6 @@ class AirtouchGroup(CoordinatorEntity, ClimateEntity):
)
_attr_temperature_unit = UnitOfTemperature.CELSIUS
_attr_hvac_modes = AT_GROUP_MODES
- _enable_turn_on_off_backwards_compatibility = False
def __init__(self, coordinator, group_number, info):
"""Initialize the climate device."""
diff --git a/homeassistant/components/airtouch5/climate.py b/homeassistant/components/airtouch5/climate.py
index dfc34c1beaf..16566f5d664 100644
--- a/homeassistant/components/airtouch5/climate.py
+++ b/homeassistant/components/airtouch5/climate.py
@@ -124,7 +124,6 @@ class Airtouch5ClimateEntity(ClimateEntity, Airtouch5Entity):
_attr_translation_key = DOMAIN
_attr_target_temperature_step = 1
_attr_name = None
- _enable_turn_on_off_backwards_compatibility = False
class Airtouch5AC(Airtouch5ClimateEntity):
diff --git a/homeassistant/components/airtouch5/manifest.json b/homeassistant/components/airtouch5/manifest.json
index 312a627d0e8..58ef8668ebe 100644
--- a/homeassistant/components/airtouch5/manifest.json
+++ b/homeassistant/components/airtouch5/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/airtouch5",
"iot_class": "local_push",
"loggers": ["airtouch5py"],
- "requirements": ["airtouch5py==0.2.10"]
+ "requirements": ["airtouch5py==0.2.11"]
}
diff --git a/homeassistant/components/airzone/climate.py b/homeassistant/components/airzone/climate.py
index 6be7416bbb0..4ed54286cff 100644
--- a/homeassistant/components/airzone/climate.py
+++ b/homeassistant/components/airzone/climate.py
@@ -136,7 +136,6 @@ class AirzoneClimate(AirzoneZoneEntity, ClimateEntity):
_attr_name = None
_speeds: dict[int, str] = {}
_speeds_reverse: dict[str, int] = {}
- _enable_turn_on_off_backwards_compatibility = False
def __init__(
self,
diff --git a/homeassistant/components/airzone/manifest.json b/homeassistant/components/airzone/manifest.json
index 10fb20bb2ce..01fde7eb2fb 100644
--- a/homeassistant/components/airzone/manifest.json
+++ b/homeassistant/components/airzone/manifest.json
@@ -11,5 +11,5 @@
"documentation": "https://www.home-assistant.io/integrations/airzone",
"iot_class": "local_polling",
"loggers": ["aioairzone"],
- "requirements": ["aioairzone==0.9.5"]
+ "requirements": ["aioairzone==0.9.7"]
}
diff --git a/homeassistant/components/airzone_cloud/climate.py b/homeassistant/components/airzone_cloud/climate.py
index d32b070ad8c..b98473072e4 100644
--- a/homeassistant/components/airzone_cloud/climate.py
+++ b/homeassistant/components/airzone_cloud/climate.py
@@ -177,7 +177,6 @@ class AirzoneClimate(AirzoneEntity, ClimateEntity):
_attr_name = None
_attr_temperature_unit = UnitOfTemperature.CELSIUS
- _enable_turn_on_off_backwards_compatibility = False
def _init_attributes(self) -> None:
"""Init common climate device attributes."""
@@ -194,12 +193,6 @@ class AirzoneClimate(AirzoneEntity, ClimateEntity):
ClimateEntityFeature.TARGET_TEMPERATURE_RANGE
)
- if (
- self.get_airzone_value(AZD_SPEED) is not None
- and self.get_airzone_value(AZD_SPEEDS) is not None
- ):
- self._initialize_fan_speeds()
-
@callback
def _handle_coordinator_update(self) -> None:
"""Update attributes when the coordinator updates."""
@@ -214,8 +207,6 @@ class AirzoneClimate(AirzoneEntity, ClimateEntity):
self._attr_hvac_action = HVAC_ACTION_LIB_TO_HASS[
self.get_airzone_value(AZD_ACTION)
]
- if self.supported_features & ClimateEntityFeature.FAN_MODE:
- self._attr_fan_mode = self._speeds.get(self.get_airzone_value(AZD_SPEED))
if self.get_airzone_value(AZD_POWER):
self._attr_hvac_mode = HVAC_MODE_LIB_TO_HASS[
self.get_airzone_value(AZD_MODE)
@@ -252,6 +243,22 @@ class AirzoneDeviceClimate(AirzoneClimate):
_speeds: dict[int, str]
_speeds_reverse: dict[str, int]
+ def _init_attributes(self) -> None:
+ """Init common climate device attributes."""
+ super()._init_attributes()
+ if (
+ self.get_airzone_value(AZD_SPEED) is not None
+ and self.get_airzone_value(AZD_SPEEDS) is not None
+ ):
+ self._initialize_fan_speeds()
+
+ @callback
+ def _async_update_attrs(self) -> None:
+ """Update climate attributes."""
+ super()._async_update_attrs()
+ if self.supported_features & ClimateEntityFeature.FAN_MODE:
+ self._attr_fan_mode = self._speeds.get(self.get_airzone_value(AZD_SPEED))
+
def _initialize_fan_speeds(self) -> None:
"""Initialize fan speeds."""
azd_speeds: dict[int, int] = self.get_airzone_value(AZD_SPEEDS)
diff --git a/homeassistant/components/alarm_control_panel/__init__.py b/homeassistant/components/alarm_control_panel/__init__.py
index 2946fc64941..4c5e201df8f 100644
--- a/homeassistant/components/alarm_control_panel/__init__.py
+++ b/homeassistant/components/alarm_control_panel/__init__.py
@@ -4,9 +4,8 @@ from __future__ import annotations
import asyncio
from datetime import timedelta
-from functools import partial
import logging
-from typing import Any, Final, final
+from typing import TYPE_CHECKING, Any, Final, final
from propcache import cached_property
import voluptuous as vol
@@ -27,26 +26,14 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ServiceValidationError
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.config_validation import make_entity_service_schema
-from homeassistant.helpers.deprecation import (
- 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.entity_platform import EntityPlatform
+from homeassistant.helpers.frame import ReportBehavior, report_usage
from homeassistant.helpers.typing import ConfigType
from homeassistant.util.hass_dict import HassKey
-from .const import ( # noqa: F401
- _DEPRECATED_FORMAT_NUMBER,
- _DEPRECATED_FORMAT_TEXT,
- _DEPRECATED_SUPPORT_ALARM_ARM_AWAY,
- _DEPRECATED_SUPPORT_ALARM_ARM_CUSTOM_BYPASS,
- _DEPRECATED_SUPPORT_ALARM_ARM_HOME,
- _DEPRECATED_SUPPORT_ALARM_ARM_NIGHT,
- _DEPRECATED_SUPPORT_ALARM_ARM_VACATION,
- _DEPRECATED_SUPPORT_ALARM_TRIGGER,
+from .const import (
ATTR_CHANGED_BY,
ATTR_CODE_ARM_REQUIRED,
DOMAIN,
@@ -163,7 +150,6 @@ class AlarmControlPanelEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_A
_alarm_control_panel_option_default_code: str | None = None
__alarm_legacy_state: bool = False
- __alarm_legacy_state_reported: bool = False
def __init_subclass__(cls, **kwargs: Any) -> None:
"""Post initialisation processing."""
@@ -173,17 +159,15 @@ class AlarmControlPanelEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_A
# setting the state directly.
cls.__alarm_legacy_state = True
- def __setattr__(self, __name: str, __value: Any) -> None:
+ def __setattr__(self, name: str, value: Any, /) -> None:
"""Set attribute.
Deprecation warning if setting '_attr_state' directly
unless already reported.
"""
- if __name == "_attr_state":
- if self.__alarm_legacy_state_reported is not True:
- self._report_deprecated_alarm_state_handling()
- self.__alarm_legacy_state_reported = True
- return super().__setattr__(__name, __value)
+ if name == "_attr_state":
+ self._report_deprecated_alarm_state_handling()
+ return super().__setattr__(name, value)
@callback
def add_to_platform_start(
@@ -194,7 +178,7 @@ class AlarmControlPanelEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_A
) -> None:
"""Start adding an entity to a platform."""
super().add_to_platform_start(hass, platform, parallel_updates)
- if self.__alarm_legacy_state and not self.__alarm_legacy_state_reported:
+ if self.__alarm_legacy_state:
self._report_deprecated_alarm_state_handling()
@callback
@@ -203,27 +187,30 @@ class AlarmControlPanelEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_A
Integrations should implement alarm_state instead of using state directly.
"""
- self.__alarm_legacy_state_reported = True
- if "custom_components" in type(self).__module__:
- # Do not report on core integrations as they have been fixed.
- report_issue = "report it to the custom integration author."
- _LOGGER.warning(
- "Entity %s (%s) is setting state directly"
- " which will stop working in HA Core 2025.11."
- " Entities should implement the 'alarm_state' property and"
- " return its state using the AlarmControlPanelState enum, please %s",
- self.entity_id,
- type(self),
- report_issue,
- )
+ report_usage(
+ "is setting state directly."
+ f" Entity {self.entity_id} ({type(self)}) should implement the 'alarm_state'"
+ " property and return its state using the AlarmControlPanelState enum",
+ core_integration_behavior=ReportBehavior.ERROR,
+ custom_integration_behavior=ReportBehavior.LOG,
+ breaks_in_ha_version="2025.11",
+ integration_domain=self.platform.platform_name if self.platform else None,
+ exclude_integrations={DOMAIN},
+ )
@final
@property
def state(self) -> str | None:
"""Return the current state."""
- if (alarm_state := self.alarm_state) is None:
- return None
- return alarm_state
+ if (alarm_state := self.alarm_state) is not None:
+ return alarm_state
+ if self._attr_state is not None:
+ # Backwards compatibility for integrations that set state directly
+ # Should be removed in 2025.11
+ if TYPE_CHECKING:
+ assert isinstance(self._attr_state, str)
+ return self._attr_state
+ return None
@cached_property
def alarm_state(self) -> AlarmControlPanelState | None:
@@ -269,7 +256,6 @@ class AlarmControlPanelEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_A
"""Check if arm code is required, raise if no code is given."""
if not (_code := self.code_or_default_code(code)) and self.code_arm_required:
raise ServiceValidationError(
- f"Arming requires a code but none was given for {self.entity_id}",
translation_domain=DOMAIN,
translation_key="code_arm_required",
translation_placeholders={
@@ -369,12 +355,7 @@ class AlarmControlPanelEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_A
@cached_property
def supported_features(self) -> AlarmControlPanelEntityFeature:
"""Return the list of supported features."""
- features = self._attr_supported_features
- if type(features) is int: # noqa: E721
- new_features = AlarmControlPanelEntityFeature(features)
- self._report_deprecated_supported_features_values(new_features)
- return new_features
- return features
+ return self._attr_supported_features
@final
@property
@@ -412,13 +393,3 @@ class AlarmControlPanelEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_A
self._alarm_control_panel_option_default_code = default_code
return
self._alarm_control_panel_option_default_code = None
-
-
-# As we import constants of the const module here, we need to add the following
-# functions to check for deprecated constants again
-# 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/alarm_control_panel/const.py b/homeassistant/components/alarm_control_panel/const.py
index f3218626ead..f9a5887513c 100644
--- a/homeassistant/components/alarm_control_panel/const.py
+++ b/homeassistant/components/alarm_control_panel/const.py
@@ -1,16 +1,8 @@
"""Provides the constants needed for component."""
from enum import IntFlag, StrEnum
-from functools import partial
from typing import Final
-from homeassistant.helpers.deprecation import (
- DeprecatedConstantEnum,
- all_with_deprecated_constants,
- check_if_deprecated_constant,
- dir_with_deprecated_constants,
-)
-
DOMAIN: Final = "alarm_control_panel"
ATTR_CHANGED_BY: Final = "changed_by"
@@ -39,12 +31,6 @@ class CodeFormat(StrEnum):
NUMBER = "number"
-# These constants are deprecated as of Home Assistant 2022.5, can be removed in 2025.1
-# Please use the CodeFormat enum instead.
-_DEPRECATED_FORMAT_TEXT: Final = DeprecatedConstantEnum(CodeFormat.TEXT, "2025.1")
-_DEPRECATED_FORMAT_NUMBER: Final = DeprecatedConstantEnum(CodeFormat.NUMBER, "2025.1")
-
-
class AlarmControlPanelEntityFeature(IntFlag):
"""Supported features of the alarm control panel entity."""
@@ -56,27 +42,6 @@ class AlarmControlPanelEntityFeature(IntFlag):
ARM_VACATION = 32
-# These constants are deprecated as of Home Assistant 2022.5
-# Please use the AlarmControlPanelEntityFeature enum instead.
-_DEPRECATED_SUPPORT_ALARM_ARM_HOME: Final = DeprecatedConstantEnum(
- AlarmControlPanelEntityFeature.ARM_HOME, "2025.1"
-)
-_DEPRECATED_SUPPORT_ALARM_ARM_AWAY: Final = DeprecatedConstantEnum(
- AlarmControlPanelEntityFeature.ARM_AWAY, "2025.1"
-)
-_DEPRECATED_SUPPORT_ALARM_ARM_NIGHT: Final = DeprecatedConstantEnum(
- AlarmControlPanelEntityFeature.ARM_NIGHT, "2025.1"
-)
-_DEPRECATED_SUPPORT_ALARM_TRIGGER: Final = DeprecatedConstantEnum(
- AlarmControlPanelEntityFeature.TRIGGER, "2025.1"
-)
-_DEPRECATED_SUPPORT_ALARM_ARM_CUSTOM_BYPASS: Final = DeprecatedConstantEnum(
- AlarmControlPanelEntityFeature.ARM_CUSTOM_BYPASS, "2025.1"
-)
-_DEPRECATED_SUPPORT_ALARM_ARM_VACATION: Final = DeprecatedConstantEnum(
- AlarmControlPanelEntityFeature.ARM_VACATION, "2025.1"
-)
-
CONDITION_TRIGGERED: Final = "is_triggered"
CONDITION_DISARMED: Final = "is_disarmed"
CONDITION_ARMED_HOME: Final = "is_armed_home"
@@ -84,10 +49,3 @@ CONDITION_ARMED_AWAY: Final = "is_armed_away"
CONDITION_ARMED_NIGHT: Final = "is_armed_night"
CONDITION_ARMED_VACATION: Final = "is_armed_vacation"
CONDITION_ARMED_CUSTOM_BYPASS: Final = "is_armed_custom_bypass"
-
-# 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/alarm_control_panel/strings.json b/homeassistant/components/alarm_control_panel/strings.json
index 6dac4d069a1..5f718280566 100644
--- a/homeassistant/components/alarm_control_panel/strings.json
+++ b/homeassistant/components/alarm_control_panel/strings.json
@@ -130,7 +130,7 @@
},
"alarm_trigger": {
"name": "Trigger",
- "description": "Enables an external alarm trigger.",
+ "description": "Trigger the alarm manually.",
"fields": {
"code": {
"name": "[%key:component::alarm_control_panel::services::alarm_disarm::fields::code::name%]",
@@ -138,5 +138,10 @@
}
}
}
+ },
+ "exceptions": {
+ "code_arm_required": {
+ "message": "Arming requires a code but none was given for {entity_id}."
+ }
}
}
diff --git a/homeassistant/components/alexa/capabilities.py b/homeassistant/components/alexa/capabilities.py
index 09b461428ac..c5b4ad15904 100644
--- a/homeassistant/components/alexa/capabilities.py
+++ b/homeassistant/components/alexa/capabilities.py
@@ -317,6 +317,7 @@ class Alexa(AlexaCapability):
"hi-IN",
"it-IT",
"ja-JP",
+ "nl-NL",
"pt-BR",
}
@@ -403,6 +404,7 @@ class AlexaPowerController(AlexaCapability):
"hi-IN",
"it-IT",
"ja-JP",
+ "nl-NL",
"pt-BR",
}
@@ -436,7 +438,7 @@ class AlexaPowerController(AlexaCapability):
elif self.entity.domain == remote.DOMAIN:
is_on = self.entity.state not in (STATE_OFF, STATE_UNKNOWN)
elif self.entity.domain == vacuum.DOMAIN:
- is_on = self.entity.state == vacuum.STATE_CLEANING
+ is_on = self.entity.state == vacuum.VacuumActivity.CLEANING
elif self.entity.domain == timer.DOMAIN:
is_on = self.entity.state != STATE_IDLE
elif self.entity.domain == water_heater.DOMAIN:
@@ -469,6 +471,7 @@ class AlexaLockController(AlexaCapability):
"hi-IN",
"it-IT",
"ja-JP",
+ "nl-NL",
"pt-BR",
}
@@ -523,6 +526,7 @@ class AlexaSceneController(AlexaCapability):
"hi-IN",
"it-IT",
"ja-JP",
+ "nl-NL",
"pt-BR",
}
@@ -562,6 +566,7 @@ class AlexaBrightnessController(AlexaCapability):
"hi-IN",
"it-IT",
"ja-JP",
+ "nl-NL",
"pt-BR",
}
@@ -611,6 +616,7 @@ class AlexaColorController(AlexaCapability):
"hi-IN",
"it-IT",
"ja-JP",
+ "nl-NL",
"pt-BR",
}
@@ -669,6 +675,7 @@ class AlexaColorTemperatureController(AlexaCapability):
"hi-IN",
"it-IT",
"ja-JP",
+ "nl-NL",
"pt-BR",
}
@@ -715,6 +722,7 @@ class AlexaSpeaker(AlexaCapability):
"fr-FR", # Not documented as of 2021-12-04, see PR #60489
"it-IT",
"ja-JP",
+ "nl-NL",
}
def name(self) -> str:
@@ -772,6 +780,7 @@ class AlexaStepSpeaker(AlexaCapability):
"es-ES",
"fr-FR", # Not documented as of 2021-12-04, see PR #60489
"it-IT",
+ "nl-NL",
}
def name(self) -> str:
@@ -801,6 +810,7 @@ class AlexaPlaybackController(AlexaCapability):
"hi-IN",
"it-IT",
"ja-JP",
+ "nl-NL",
"pt-BR",
}
@@ -816,13 +826,19 @@ class AlexaPlaybackController(AlexaCapability):
"""
supported_features = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
- operations = {
- media_player.MediaPlayerEntityFeature.NEXT_TRACK: "Next",
- media_player.MediaPlayerEntityFeature.PAUSE: "Pause",
- media_player.MediaPlayerEntityFeature.PLAY: "Play",
- media_player.MediaPlayerEntityFeature.PREVIOUS_TRACK: "Previous",
- media_player.MediaPlayerEntityFeature.STOP: "Stop",
- }
+ operations: dict[
+ cover.CoverEntityFeature | media_player.MediaPlayerEntityFeature, str
+ ]
+ if self.entity.domain == cover.DOMAIN:
+ operations = {cover.CoverEntityFeature.STOP: "Stop"}
+ else:
+ operations = {
+ media_player.MediaPlayerEntityFeature.NEXT_TRACK: "Next",
+ media_player.MediaPlayerEntityFeature.PAUSE: "Pause",
+ media_player.MediaPlayerEntityFeature.PLAY: "Play",
+ media_player.MediaPlayerEntityFeature.PREVIOUS_TRACK: "Previous",
+ media_player.MediaPlayerEntityFeature.STOP: "Stop",
+ }
return [
value
@@ -853,6 +869,7 @@ class AlexaInputController(AlexaCapability):
"hi-IN",
"it-IT",
"ja-JP",
+ "nl-NL",
"pt-BR",
}
@@ -1098,6 +1115,7 @@ class AlexaThermostatController(AlexaCapability):
"hi-IN",
"it-IT",
"ja-JP",
+ "nl-NL",
"pt-BR",
}
@@ -1239,6 +1257,7 @@ class AlexaPowerLevelController(AlexaCapability):
"fr-CA",
"fr-FR",
"it-IT",
+ "nl-NL",
"ja-JP",
}
@@ -1717,6 +1736,7 @@ class AlexaRangeController(AlexaCapability):
"hi-IN",
"it-IT",
"ja-JP",
+ "nl-NL",
"pt-BR",
}
@@ -2060,6 +2080,7 @@ class AlexaToggleController(AlexaCapability):
"hi-IN",
"it-IT",
"ja-JP",
+ "nl-NL",
"pt-BR",
}
@@ -2206,6 +2227,7 @@ class AlexaPlaybackStateReporter(AlexaCapability):
"hi-IN",
"it-IT",
"ja-JP",
+ "nl-NL",
"pt-BR",
}
@@ -2261,6 +2283,7 @@ class AlexaSeekController(AlexaCapability):
"hi-IN",
"it-IT",
"ja-JP",
+ "nl-NL",
"pt-BR",
}
@@ -2354,6 +2377,7 @@ class AlexaEqualizerController(AlexaCapability):
"hi-IN",
"it-IT",
"ja-JP",
+ "nl-NL",
"pt-BR",
}
@@ -2464,6 +2488,7 @@ class AlexaCameraStreamController(AlexaCapability):
"hi-IN",
"it-IT",
"ja-JP",
+ "nl-NL",
"pt-BR",
}
diff --git a/homeassistant/components/alexa/const.py b/homeassistant/components/alexa/const.py
index 4862e4d8a8c..27e9bbd5b67 100644
--- a/homeassistant/components/alexa/const.py
+++ b/homeassistant/components/alexa/const.py
@@ -59,6 +59,7 @@ CONF_SUPPORTED_LOCALES = (
"hi-IN",
"it-IT",
"ja-JP",
+ "nl-NL",
"pt-BR",
)
diff --git a/homeassistant/components/alexa/entities.py b/homeassistant/components/alexa/entities.py
index ca7b389a0f1..8c139d66369 100644
--- a/homeassistant/components/alexa/entities.py
+++ b/homeassistant/components/alexa/entities.py
@@ -559,6 +559,10 @@ class CoverCapabilities(AlexaEntity):
)
if supported & cover.CoverEntityFeature.SET_TILT_POSITION:
yield AlexaRangeController(self.entity, instance=f"{cover.DOMAIN}.tilt")
+ if supported & (
+ cover.CoverEntityFeature.STOP | cover.CoverEntityFeature.STOP_TILT
+ ):
+ yield AlexaPlaybackController(self.entity, instance=f"{cover.DOMAIN}.stop")
yield AlexaEndpointHealth(self.hass, self.entity)
yield Alexa(self.entity)
diff --git a/homeassistant/components/alexa/handlers.py b/homeassistant/components/alexa/handlers.py
index 8ea61ddbceb..04bef105546 100644
--- a/homeassistant/components/alexa/handlers.py
+++ b/homeassistant/components/alexa/handlers.py
@@ -2,6 +2,7 @@
from __future__ import annotations
+import asyncio
from collections.abc import Callable, Coroutine
import logging
import math
@@ -358,7 +359,7 @@ async def async_api_set_color_temperature(
await hass.services.async_call(
entity.domain,
SERVICE_TURN_ON,
- {ATTR_ENTITY_ID: entity.entity_id, light.ATTR_KELVIN: kelvin},
+ {ATTR_ENTITY_ID: entity.entity_id, light.ATTR_COLOR_TEMP_KELVIN: kelvin},
blocking=False,
context=context,
)
@@ -375,14 +376,14 @@ async def async_api_decrease_color_temp(
) -> AlexaResponse:
"""Process a decrease color temperature request."""
entity = directive.entity
- current = int(entity.attributes[light.ATTR_COLOR_TEMP])
- max_mireds = int(entity.attributes[light.ATTR_MAX_MIREDS])
+ current = int(entity.attributes[light.ATTR_COLOR_TEMP_KELVIN])
+ min_kelvin = int(entity.attributes[light.ATTR_MIN_COLOR_TEMP_KELVIN])
- value = min(max_mireds, current + 50)
+ value = max(min_kelvin, current - 500)
await hass.services.async_call(
entity.domain,
SERVICE_TURN_ON,
- {ATTR_ENTITY_ID: entity.entity_id, light.ATTR_COLOR_TEMP: value},
+ {ATTR_ENTITY_ID: entity.entity_id, light.ATTR_COLOR_TEMP_KELVIN: value},
blocking=False,
context=context,
)
@@ -399,14 +400,14 @@ async def async_api_increase_color_temp(
) -> AlexaResponse:
"""Process an increase color temperature request."""
entity = directive.entity
- current = int(entity.attributes[light.ATTR_COLOR_TEMP])
- min_mireds = int(entity.attributes[light.ATTR_MIN_MIREDS])
+ current = int(entity.attributes[light.ATTR_COLOR_TEMP_KELVIN])
+ max_kelvin = int(entity.attributes[light.ATTR_MAX_COLOR_TEMP_KELVIN])
- value = max(min_mireds, current - 50)
+ value = min(max_kelvin, current + 500)
await hass.services.async_call(
entity.domain,
SERVICE_TURN_ON,
- {ATTR_ENTITY_ID: entity.entity_id, light.ATTR_COLOR_TEMP: value},
+ {ATTR_ENTITY_ID: entity.entity_id, light.ATTR_COLOR_TEMP_KELVIN: value},
blocking=False,
context=context,
)
@@ -526,6 +527,7 @@ async def async_api_unlock(
"hi-IN",
"it-IT",
"ja-JP",
+ "nl-NL",
"pt-BR",
}:
msg = (
@@ -764,9 +766,25 @@ async def async_api_stop(
entity = directive.entity
data: dict[str, Any] = {ATTR_ENTITY_ID: entity.entity_id}
- await hass.services.async_call(
- entity.domain, SERVICE_MEDIA_STOP, data, blocking=False, context=context
- )
+ if entity.domain == cover.DOMAIN:
+ supported: int = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
+ feature_services: dict[int, str] = {
+ cover.CoverEntityFeature.STOP.value: cover.SERVICE_STOP_COVER,
+ cover.CoverEntityFeature.STOP_TILT.value: cover.SERVICE_STOP_COVER_TILT,
+ }
+ await asyncio.gather(
+ *(
+ hass.services.async_call(
+ entity.domain, service, data, blocking=False, context=context
+ )
+ for feature, service in feature_services.items()
+ if feature & supported
+ )
+ )
+ else:
+ await hass.services.async_call(
+ entity.domain, SERVICE_MEDIA_STOP, data, blocking=False, context=context
+ )
return directive.response()
diff --git a/homeassistant/components/alpha_vantage/manifest.json b/homeassistant/components/alpha_vantage/manifest.json
index c94da6bf487..cdfa847d115 100644
--- a/homeassistant/components/alpha_vantage/manifest.json
+++ b/homeassistant/components/alpha_vantage/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/alpha_vantage",
"iot_class": "cloud_polling",
"loggers": ["alpha_vantage"],
+ "quality_scale": "legacy",
"requirements": ["alpha-vantage==2.3.1"]
}
diff --git a/homeassistant/components/amazon_polly/manifest.json b/homeassistant/components/amazon_polly/manifest.json
index b057967d1e2..e7fbf8edc74 100644
--- a/homeassistant/components/amazon_polly/manifest.json
+++ b/homeassistant/components/amazon_polly/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/amazon_polly",
"iot_class": "cloud_push",
"loggers": ["boto3", "botocore", "s3transfer"],
+ "quality_scale": "legacy",
"requirements": ["boto3==1.34.131"]
}
diff --git a/homeassistant/components/amberelectric/__init__.py b/homeassistant/components/amberelectric/__init__.py
index cd44886c9ef..29d8f166f2a 100644
--- a/homeassistant/components/amberelectric/__init__.py
+++ b/homeassistant/components/amberelectric/__init__.py
@@ -1,7 +1,6 @@
"""Support for Amber Electric."""
-from amberelectric import Configuration
-from amberelectric.api import amber_api
+import amberelectric
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_TOKEN
@@ -15,8 +14,9 @@ type AmberConfigEntry = ConfigEntry[AmberUpdateCoordinator]
async def async_setup_entry(hass: HomeAssistant, entry: AmberConfigEntry) -> bool:
"""Set up Amber Electric from a config entry."""
- configuration = Configuration(access_token=entry.data[CONF_API_TOKEN])
- api_instance = amber_api.AmberApi.create(configuration)
+ configuration = amberelectric.Configuration(access_token=entry.data[CONF_API_TOKEN])
+ api_client = amberelectric.ApiClient(configuration)
+ api_instance = amberelectric.AmberApi(api_client)
site_id = entry.data[CONF_SITE_ID]
coordinator = AmberUpdateCoordinator(hass, api_instance, site_id)
diff --git a/homeassistant/components/amberelectric/config_flow.py b/homeassistant/components/amberelectric/config_flow.py
index a94700c27d1..c25258e2e33 100644
--- a/homeassistant/components/amberelectric/config_flow.py
+++ b/homeassistant/components/amberelectric/config_flow.py
@@ -3,8 +3,8 @@
from __future__ import annotations
import amberelectric
-from amberelectric.api import amber_api
-from amberelectric.model.site import Site, SiteStatus
+from amberelectric.models.site import Site
+from amberelectric.models.site_status import SiteStatus
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
@@ -23,11 +23,15 @@ API_URL = "https://app.amber.com.au/developers"
def generate_site_selector_name(site: Site) -> str:
"""Generate the name to show in the site drop down in the configuration flow."""
+ # For some reason the generated API key returns this as any, not a string. Thanks pydantic
+ nmi = str(site.nmi)
if site.status == SiteStatus.CLOSED:
- return site.nmi + " (Closed: " + site.closed_on.isoformat() + ")" # type: ignore[no-any-return]
+ if site.closed_on is None:
+ return f"{nmi} (Closed)"
+ return f"{nmi} (Closed: {site.closed_on.isoformat()})"
if site.status == SiteStatus.PENDING:
- return site.nmi + " (Pending)" # type: ignore[no-any-return]
- return site.nmi # type: ignore[no-any-return]
+ return f"{nmi} (Pending)"
+ return nmi
def filter_sites(sites: list[Site]) -> list[Site]:
@@ -35,7 +39,7 @@ def filter_sites(sites: list[Site]) -> list[Site]:
filtered: list[Site] = []
filtered_nmi: set[str] = set()
- for site in sorted(sites, key=lambda site: site.status.value):
+ for site in sorted(sites, key=lambda site: site.status):
if site.status == SiteStatus.ACTIVE or site.nmi not in filtered_nmi:
filtered.append(site)
filtered_nmi.add(site.nmi)
@@ -56,7 +60,8 @@ class AmberElectricConfigFlow(ConfigFlow, domain=DOMAIN):
def _fetch_sites(self, token: str) -> list[Site] | None:
configuration = amberelectric.Configuration(access_token=token)
- api: amber_api.AmberApi = amber_api.AmberApi.create(configuration)
+ api_client = amberelectric.ApiClient(configuration)
+ api = amberelectric.AmberApi(api_client)
try:
sites: list[Site] = filter_sites(api.get_sites())
diff --git a/homeassistant/components/amberelectric/coordinator.py b/homeassistant/components/amberelectric/coordinator.py
index a95aa3fa529..57028e07d21 100644
--- a/homeassistant/components/amberelectric/coordinator.py
+++ b/homeassistant/components/amberelectric/coordinator.py
@@ -5,13 +5,13 @@ from __future__ import annotations
from datetime import timedelta
from typing import Any
-from amberelectric import ApiException
-from amberelectric.api import amber_api
-from amberelectric.model.actual_interval import ActualInterval
-from amberelectric.model.channel import ChannelType
-from amberelectric.model.current_interval import CurrentInterval
-from amberelectric.model.forecast_interval import ForecastInterval
-from amberelectric.model.interval import Descriptor
+import amberelectric
+from amberelectric.models.actual_interval import ActualInterval
+from amberelectric.models.channel import ChannelType
+from amberelectric.models.current_interval import CurrentInterval
+from amberelectric.models.forecast_interval import ForecastInterval
+from amberelectric.models.price_descriptor import PriceDescriptor
+from amberelectric.rest import ApiException
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
@@ -31,22 +31,22 @@ def is_forecast(interval: ActualInterval | CurrentInterval | ForecastInterval) -
def is_general(interval: ActualInterval | CurrentInterval | ForecastInterval) -> bool:
"""Return true if the supplied interval is on the general channel."""
- return interval.channel_type == ChannelType.GENERAL # type: ignore[no-any-return]
+ return interval.channel_type == ChannelType.GENERAL
def is_controlled_load(
interval: ActualInterval | CurrentInterval | ForecastInterval,
) -> bool:
"""Return true if the supplied interval is on the controlled load channel."""
- return interval.channel_type == ChannelType.CONTROLLED_LOAD # type: ignore[no-any-return]
+ return interval.channel_type == ChannelType.CONTROLLEDLOAD
def is_feed_in(interval: ActualInterval | CurrentInterval | ForecastInterval) -> bool:
"""Return true if the supplied interval is on the feed in channel."""
- return interval.channel_type == ChannelType.FEED_IN # type: ignore[no-any-return]
+ return interval.channel_type == ChannelType.FEEDIN
-def normalize_descriptor(descriptor: Descriptor) -> str | None:
+def normalize_descriptor(descriptor: PriceDescriptor | None) -> str | None:
"""Return the snake case versions of descriptor names. Returns None if the name is not recognized."""
if descriptor is None:
return None
@@ -71,7 +71,7 @@ class AmberUpdateCoordinator(DataUpdateCoordinator):
"""AmberUpdateCoordinator - In charge of downloading the data for a site, which all the sensors read."""
def __init__(
- self, hass: HomeAssistant, api: amber_api.AmberApi, site_id: str
+ self, hass: HomeAssistant, api: amberelectric.AmberApi, site_id: str
) -> None:
"""Initialise the data service."""
super().__init__(
@@ -93,12 +93,13 @@ class AmberUpdateCoordinator(DataUpdateCoordinator):
"grid": {},
}
try:
- data = self._api.get_current_price(self.site_id, next=48)
+ data = self._api.get_current_prices(self.site_id, next=48)
+ intervals = [interval.actual_instance for interval in data]
except ApiException as api_exception:
raise UpdateFailed("Missing price data, skipping update") from api_exception
- current = [interval for interval in data if is_current(interval)]
- forecasts = [interval for interval in data if is_forecast(interval)]
+ current = [interval for interval in intervals if is_current(interval)]
+ forecasts = [interval for interval in intervals if is_forecast(interval)]
general = [interval for interval in current if is_general(interval)]
if len(general) == 0:
@@ -137,7 +138,7 @@ class AmberUpdateCoordinator(DataUpdateCoordinator):
interval for interval in forecasts if is_feed_in(interval)
]
- LOGGER.debug("Fetched new Amber data: %s", data)
+ LOGGER.debug("Fetched new Amber data: %s", intervals)
return result
async def _async_update_data(self) -> dict[str, Any]:
diff --git a/homeassistant/components/amberelectric/manifest.json b/homeassistant/components/amberelectric/manifest.json
index 51be42cfa68..401eb1629a1 100644
--- a/homeassistant/components/amberelectric/manifest.json
+++ b/homeassistant/components/amberelectric/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/amberelectric",
"iot_class": "cloud_polling",
"loggers": ["amberelectric"],
- "requirements": ["amberelectric==1.1.1"]
+ "requirements": ["amberelectric==2.0.12"]
}
diff --git a/homeassistant/components/amberelectric/sensor.py b/homeassistant/components/amberelectric/sensor.py
index 52c0c42e7bc..cdf40e5804d 100644
--- a/homeassistant/components/amberelectric/sensor.py
+++ b/homeassistant/components/amberelectric/sensor.py
@@ -8,9 +8,9 @@ from __future__ import annotations
from typing import Any
-from amberelectric.model.channel import ChannelType
-from amberelectric.model.current_interval import CurrentInterval
-from amberelectric.model.forecast_interval import ForecastInterval
+from amberelectric.models.channel import ChannelType
+from amberelectric.models.current_interval import CurrentInterval
+from amberelectric.models.forecast_interval import ForecastInterval
from homeassistant.components.sensor import (
SensorEntity,
@@ -52,7 +52,7 @@ class AmberSensor(CoordinatorEntity[AmberUpdateCoordinator], SensorEntity):
self,
coordinator: AmberUpdateCoordinator,
description: SensorEntityDescription,
- channel_type: ChannelType,
+ channel_type: str,
) -> None:
"""Initialize the Sensor."""
super().__init__(coordinator)
@@ -73,7 +73,7 @@ class AmberPriceSensor(AmberSensor):
"""Return the current price in $/kWh."""
interval = self.coordinator.data[self.entity_description.key][self.channel_type]
- if interval.channel_type == ChannelType.FEED_IN:
+ if interval.channel_type == ChannelType.FEEDIN:
return format_cents_to_dollars(interval.per_kwh) * -1
return format_cents_to_dollars(interval.per_kwh)
@@ -87,9 +87,9 @@ class AmberPriceSensor(AmberSensor):
return data
data["duration"] = interval.duration
- data["date"] = interval.date.isoformat()
+ data["date"] = interval.var_date.isoformat()
data["per_kwh"] = format_cents_to_dollars(interval.per_kwh)
- if interval.channel_type == ChannelType.FEED_IN:
+ if interval.channel_type == ChannelType.FEEDIN:
data["per_kwh"] = data["per_kwh"] * -1
data["nem_date"] = interval.nem_time.isoformat()
data["spot_per_kwh"] = format_cents_to_dollars(interval.spot_per_kwh)
@@ -120,7 +120,7 @@ class AmberForecastSensor(AmberSensor):
return None
interval = intervals[0]
- if interval.channel_type == ChannelType.FEED_IN:
+ if interval.channel_type == ChannelType.FEEDIN:
return format_cents_to_dollars(interval.per_kwh) * -1
return format_cents_to_dollars(interval.per_kwh)
@@ -142,10 +142,10 @@ class AmberForecastSensor(AmberSensor):
for interval in intervals:
datum = {}
datum["duration"] = interval.duration
- datum["date"] = interval.date.isoformat()
+ datum["date"] = interval.var_date.isoformat()
datum["nem_date"] = interval.nem_time.isoformat()
datum["per_kwh"] = format_cents_to_dollars(interval.per_kwh)
- if interval.channel_type == ChannelType.FEED_IN:
+ if interval.channel_type == ChannelType.FEEDIN:
datum["per_kwh"] = datum["per_kwh"] * -1
datum["spot_per_kwh"] = format_cents_to_dollars(interval.spot_per_kwh)
datum["start_time"] = interval.start_time.isoformat()
diff --git a/homeassistant/components/amcrest/manifest.json b/homeassistant/components/amcrest/manifest.json
index 8b8d87092c4..7d8f8f9e6c8 100644
--- a/homeassistant/components/amcrest/manifest.json
+++ b/homeassistant/components/amcrest/manifest.json
@@ -6,5 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/amcrest",
"iot_class": "local_polling",
"loggers": ["amcrest"],
+ "quality_scale": "legacy",
"requirements": ["amcrest==1.9.8"]
}
diff --git a/homeassistant/components/amcrest/strings.json b/homeassistant/components/amcrest/strings.json
index 816511bf05e..807c75e1ac8 100644
--- a/homeassistant/components/amcrest/strings.json
+++ b/homeassistant/components/amcrest/strings.json
@@ -41,7 +41,7 @@
}
},
"enable_motion_recording": {
- "name": "Enables motion recording",
+ "name": "Enable motion recording",
"description": "Enables recording a clip to camera storage when motion is detected.",
"fields": {
"entity_id": {
@@ -51,8 +51,8 @@
}
},
"disable_motion_recording": {
- "name": "Disables motion recording",
- "description": "Disable recording a clip to camera storage when motion is detected.",
+ "name": "Disable motion recording",
+ "description": "Disables recording a clip to camera storage when motion is detected.",
"fields": {
"entity_id": {
"name": "[%key:component::amcrest::services::enable_recording::fields::entity_id::name%]",
diff --git a/homeassistant/components/ampio/manifest.json b/homeassistant/components/ampio/manifest.json
index bc9c09d817a..17fc3eb3d96 100644
--- a/homeassistant/components/ampio/manifest.json
+++ b/homeassistant/components/ampio/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/ampio",
"iot_class": "cloud_polling",
"loggers": ["asmog"],
+ "quality_scale": "legacy",
"requirements": ["asmog==0.0.6"]
}
diff --git a/homeassistant/components/analytics_insights/config_flow.py b/homeassistant/components/analytics_insights/config_flow.py
index c36755f5403..da77a35f789 100644
--- a/homeassistant/components/analytics_insights/config_flow.py
+++ b/homeassistant/components/analytics_insights/config_flow.py
@@ -11,12 +11,7 @@ from python_homeassistant_analytics import (
from python_homeassistant_analytics.models import IntegrationType
import voluptuous as vol
-from homeassistant.config_entries import (
- ConfigEntry,
- ConfigFlow,
- ConfigFlowResult,
- OptionsFlow,
-)
+from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow
from homeassistant.core import callback
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.selector import (
@@ -25,6 +20,7 @@ from homeassistant.helpers.selector import (
SelectSelectorConfig,
)
+from . import AnalyticsInsightsConfigEntry
from .const import (
CONF_TRACKED_ADDONS,
CONF_TRACKED_CUSTOM_INTEGRATIONS,
@@ -46,7 +42,7 @@ class HomeassistantAnalyticsConfigFlow(ConfigFlow, domain=DOMAIN):
@staticmethod
@callback
def async_get_options_flow(
- config_entry: ConfigEntry,
+ config_entry: AnalyticsInsightsConfigEntry,
) -> HomeassistantAnalyticsOptionsFlowHandler:
"""Get the options flow for this handler."""
return HomeassistantAnalyticsOptionsFlowHandler()
diff --git a/homeassistant/components/analytics_insights/manifest.json b/homeassistant/components/analytics_insights/manifest.json
index 841cf1caf42..bf99d89e073 100644
--- a/homeassistant/components/analytics_insights/manifest.json
+++ b/homeassistant/components/analytics_insights/manifest.json
@@ -7,6 +7,6 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["python_homeassistant_analytics"],
- "requirements": ["python-homeassistant-analytics==0.8.0"],
+ "requirements": ["python-homeassistant-analytics==0.8.1"],
"single_config_entry": true
}
diff --git a/homeassistant/components/analytics_insights/quality_scale.yaml b/homeassistant/components/analytics_insights/quality_scale.yaml
new file mode 100644
index 00000000000..ff999d97d03
--- /dev/null
+++ b/homeassistant/components/analytics_insights/quality_scale.yaml
@@ -0,0 +1,100 @@
+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: todo
+ docs-installation-instructions: todo
+ docs-removal-instructions: todo
+ 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:
+ status: exempt
+ comment: |
+ This integration does not provide actions.
+ config-entry-unloading: done
+ docs-configuration-parameters: todo
+ docs-installation-parameters: todo
+ entity-unavailable:
+ status: done
+ comment: |
+ The coordinator handles this.
+ integration-owner: done
+ log-when-unavailable:
+ status: done
+ comment: |
+ The coordinator handles this.
+ parallel-updates: todo
+ reauthentication-flow:
+ status: exempt
+ comment: |
+ This integration does not require authentication.
+ test-coverage: todo
+ # 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: 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 has a fixed single service.
+ entity-category: done
+ entity-device-class:
+ status: exempt
+ comment: |
+ This integration does not have entities with device classes.
+ entity-disabled-by-default: done
+ entity-translations: done
+ exception-translations: todo
+ icon-translations: done
+ reconfiguration-flow:
+ status: exempt
+ comment: All the options of this integration are managed via the options flow
+ 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 service.
+
+ # Platinum
+ async-dependency: done
+ inject-websession: done
+ strict-typing: done
diff --git a/homeassistant/components/androidtv/__init__.py b/homeassistant/components/androidtv/__init__.py
index 34c4212c913..4ffa0e24777 100644
--- a/homeassistant/components/androidtv/__init__.py
+++ b/homeassistant/components/androidtv/__init__.py
@@ -110,7 +110,7 @@ def _setup_androidtv(
adb_log = f"using Python ADB implementation with adbkey='{adbkey}'"
else:
- # Use "pure-python-adb" (communicate with ADB server)
+ # Communicate via ADB server
signer = None
adb_log = (
"using ADB server at"
@@ -135,15 +135,16 @@ async def async_connect_androidtv(
)
aftv = await async_androidtv_setup(
- config[CONF_HOST],
- config[CONF_PORT],
- adbkey,
- config.get(CONF_ADB_SERVER_IP),
- config.get(CONF_ADB_SERVER_PORT, DEFAULT_ADB_SERVER_PORT),
- state_detection_rules,
- config[CONF_DEVICE_CLASS],
- timeout,
- signer,
+ host=config[CONF_HOST],
+ port=config[CONF_PORT],
+ adbkey=adbkey,
+ adb_server_ip=config.get(CONF_ADB_SERVER_IP),
+ adb_server_port=config.get(CONF_ADB_SERVER_PORT, DEFAULT_ADB_SERVER_PORT),
+ state_detection_rules=state_detection_rules,
+ device_class=config[CONF_DEVICE_CLASS],
+ auth_timeout_s=timeout,
+ signer=signer,
+ log_errors=False,
)
if not aftv.available:
diff --git a/homeassistant/components/androidtv/entity.py b/homeassistant/components/androidtv/entity.py
index 626dd0f7794..fa583bb2777 100644
--- a/homeassistant/components/androidtv/entity.py
+++ b/homeassistant/components/androidtv/entity.py
@@ -151,5 +151,5 @@ class AndroidTVEntity(Entity):
# Using "adb_shell" (Python ADB implementation)
self.exceptions = ADB_PYTHON_EXCEPTIONS
else:
- # Using "pure-python-adb" (communicate with ADB server)
+ # Communicate via ADB server
self.exceptions = ADB_TCP_EXCEPTIONS
diff --git a/homeassistant/components/androidtv/manifest.json b/homeassistant/components/androidtv/manifest.json
index 2d0b062c750..e30d03fc2d5 100644
--- a/homeassistant/components/androidtv/manifest.json
+++ b/homeassistant/components/androidtv/manifest.json
@@ -6,10 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/androidtv",
"integration_type": "device",
"iot_class": "local_polling",
- "loggers": ["adb_shell", "androidtv", "pure_python_adb"],
- "requirements": [
- "adb-shell[async]==0.4.4",
- "androidtv[async]==0.0.73",
- "pure-python-adb[async]==0.3.0.dev0"
- ]
+ "loggers": ["adb_shell", "androidtv"],
+ "requirements": ["adb-shell[async]==0.4.4", "androidtv[async]==0.0.75"]
}
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 3500e4ff47b..4df25247881 100644
--- a/homeassistant/components/androidtv_remote/config_flow.py
+++ b/homeassistant/components/androidtv_remote/config_flow.py
@@ -156,7 +156,12 @@ class AndroidTVRemoteConfigFlow(ConfigFlow, domain=DOMAIN):
# and one of them, which could end up being in discovery_info.host, is from a
# different device. If any of the discovery_info.ip_addresses matches the
# existing host, don't update the host.
- if existing_config_entry and len(discovery_info.ip_addresses) > 1:
+ if (
+ existing_config_entry
+ # Ignored entries don't have host
+ and CONF_HOST in existing_config_entry.data
+ and len(discovery_info.ip_addresses) > 1
+ ):
existing_host = existing_config_entry.data[CONF_HOST]
if existing_host != self.host:
if existing_host in [
diff --git a/homeassistant/components/androidtv_remote/manifest.json b/homeassistant/components/androidtv_remote/manifest.json
index a06152fa570..d9c2dd05c44 100644
--- a/homeassistant/components/androidtv_remote/manifest.json
+++ b/homeassistant/components/androidtv_remote/manifest.json
@@ -7,7 +7,6 @@
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["androidtvremote2"],
- "quality_scale": "platinum",
"requirements": ["androidtvremote2==0.1.2"],
"zeroconf": ["_androidtvremote2._tcp.local."]
}
diff --git a/homeassistant/components/androidtv_remote/strings.json b/homeassistant/components/androidtv_remote/strings.json
index 33970171d40..e41cbcf9a76 100644
--- a/homeassistant/components/androidtv_remote/strings.json
+++ b/homeassistant/components/androidtv_remote/strings.json
@@ -44,12 +44,12 @@
}
},
"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_icon": "Application Icon",
+ "app_icon": "Application icon",
"app_delete": "Check to delete this application"
}
}
diff --git a/homeassistant/components/anel_pwrctrl/manifest.json b/homeassistant/components/anel_pwrctrl/manifest.json
index 48cc3b96ec0..67c881a3db2 100644
--- a/homeassistant/components/anel_pwrctrl/manifest.json
+++ b/homeassistant/components/anel_pwrctrl/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/anel_pwrctrl",
"iot_class": "local_polling",
"loggers": ["anel_pwrctrl"],
+ "quality_scale": "legacy",
"requirements": ["anel-pwrctrl-homeassistant==0.0.1.dev2"]
}
diff --git a/homeassistant/components/aosmith/manifest.json b/homeassistant/components/aosmith/manifest.json
index 4cd1eb32cd1..a928a6677cb 100644
--- a/homeassistant/components/aosmith/manifest.json
+++ b/homeassistant/components/aosmith/manifest.json
@@ -5,5 +5,5 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/aosmith",
"iot_class": "cloud_polling",
- "requirements": ["py-aosmith==1.0.10"]
+ "requirements": ["py-aosmith==1.0.12"]
}
diff --git a/homeassistant/components/apache_kafka/manifest.json b/homeassistant/components/apache_kafka/manifest.json
index f6593631bc0..05baaac32a2 100644
--- a/homeassistant/components/apache_kafka/manifest.json
+++ b/homeassistant/components/apache_kafka/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/apache_kafka",
"iot_class": "local_push",
"loggers": ["aiokafka", "kafka_python"],
+ "quality_scale": "legacy",
"requirements": ["aiokafka==0.10.0"]
}
diff --git a/homeassistant/components/apcupsd/manifest.json b/homeassistant/components/apcupsd/manifest.json
index b20e0c8aacf..3713b74fff7 100644
--- a/homeassistant/components/apcupsd/manifest.json
+++ b/homeassistant/components/apcupsd/manifest.json
@@ -6,6 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/apcupsd",
"iot_class": "local_polling",
"loggers": ["apcaccess"],
- "quality_scale": "silver",
"requirements": ["aioapcaccess==0.4.2"]
}
diff --git a/homeassistant/components/apple_tv/config_flow.py b/homeassistant/components/apple_tv/config_flow.py
index b0741cc9c61..5cb92ed892a 100644
--- a/homeassistant/components/apple_tv/config_flow.py
+++ b/homeassistant/components/apple_tv/config_flow.py
@@ -98,7 +98,6 @@ class AppleTVConfigFlow(ConfigFlow, domain=DOMAIN):
VERSION = 1
scan_filter: str | None = None
- all_identifiers: set[str]
atv: BaseConfig | None = None
atv_identifiers: list[str] | None = None
_host: str # host in zeroconf discovery info, should not be accessed by other flows
@@ -118,6 +117,7 @@ class AppleTVConfigFlow(ConfigFlow, domain=DOMAIN):
def __init__(self) -> None:
"""Initialize a new AppleTVConfigFlow."""
self.credentials: dict[int, str | None] = {} # Protocol -> credentials
+ self.all_identifiers: set[str] = set()
@property
def device_identifier(self) -> str | None:
diff --git a/homeassistant/components/apple_tv/manifest.json b/homeassistant/components/apple_tv/manifest.json
index b4e1b354878..b10a14af32b 100644
--- a/homeassistant/components/apple_tv/manifest.json
+++ b/homeassistant/components/apple_tv/manifest.json
@@ -7,7 +7,7 @@
"documentation": "https://www.home-assistant.io/integrations/apple_tv",
"iot_class": "local_push",
"loggers": ["pyatv", "srptools"],
- "requirements": ["pyatv==0.15.1"],
+ "requirements": ["pyatv==0.16.0"],
"zeroconf": [
"_mediaremotetv._tcp.local.",
"_companion-link._tcp.local.",
diff --git a/homeassistant/components/apprise/manifest.json b/homeassistant/components/apprise/manifest.json
index 838611e4798..ebe27d42471 100644
--- a/homeassistant/components/apprise/manifest.json
+++ b/homeassistant/components/apprise/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/apprise",
"iot_class": "cloud_push",
"loggers": ["apprise"],
- "requirements": ["apprise==1.9.0"]
+ "quality_scale": "legacy",
+ "requirements": ["apprise==1.9.1"]
}
diff --git a/homeassistant/components/aprilaire/coordinator.py b/homeassistant/components/aprilaire/coordinator.py
index 737fd768140..6b132cfcc95 100644
--- a/homeassistant/components/aprilaire/coordinator.py
+++ b/homeassistant/components/aprilaire/coordinator.py
@@ -120,6 +120,8 @@ class AprilaireCoordinator(BaseDataUpdateCoordinatorProtocol):
"""Wait for the client to be ready."""
if not self.data or Attribute.MAC_ADDRESS not in self.data:
+ await self.client.read_mac_address()
+
data = await self.client.wait_for_response(
FunctionalDomain.IDENTIFICATION, 2, WAIT_TIMEOUT
)
@@ -130,12 +132,9 @@ class AprilaireCoordinator(BaseDataUpdateCoordinatorProtocol):
return False
- if not self.data or Attribute.NAME not in self.data:
- await self.client.wait_for_response(
- FunctionalDomain.IDENTIFICATION, 4, WAIT_TIMEOUT
- )
-
if not self.data or Attribute.THERMOSTAT_MODES not in self.data:
+ await self.client.read_thermostat_iaq_available()
+
await self.client.wait_for_response(
FunctionalDomain.CONTROL, 7, WAIT_TIMEOUT
)
@@ -144,10 +143,16 @@ class AprilaireCoordinator(BaseDataUpdateCoordinatorProtocol):
not self.data
or Attribute.INDOOR_TEMPERATURE_CONTROLLING_SENSOR_STATUS not in self.data
):
+ await self.client.read_sensors()
+
await self.client.wait_for_response(
FunctionalDomain.SENSORS, 2, WAIT_TIMEOUT
)
+ await self.client.read_thermostat_status()
+
+ await self.client.read_iaq_status()
+
await ready_callback(True)
return True
diff --git a/homeassistant/components/aprilaire/manifest.json b/homeassistant/components/aprilaire/manifest.json
index 179a101885b..577de8ae88d 100644
--- a/homeassistant/components/aprilaire/manifest.json
+++ b/homeassistant/components/aprilaire/manifest.json
@@ -7,5 +7,5 @@
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["pyaprilaire"],
- "requirements": ["pyaprilaire==0.7.4"]
+ "requirements": ["pyaprilaire==0.7.7"]
}
diff --git a/homeassistant/components/aprs/manifest.json b/homeassistant/components/aprs/manifest.json
index 63826f5a385..7518405f1ec 100644
--- a/homeassistant/components/aprs/manifest.json
+++ b/homeassistant/components/aprs/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/aprs",
"iot_class": "cloud_push",
"loggers": ["aprslib", "geographiclib", "geopy"],
+ "quality_scale": "legacy",
"requirements": ["aprslib==0.7.2", "geopy==2.3.0"]
}
diff --git a/homeassistant/components/apsystems/__init__.py b/homeassistant/components/apsystems/__init__.py
index 372ce52e049..c437f5584db 100644
--- a/homeassistant/components/apsystems/__init__.py
+++ b/homeassistant/components/apsystems/__init__.py
@@ -38,6 +38,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ApSystemsConfigEntry) ->
ip_address=entry.data[CONF_IP_ADDRESS],
port=entry.data.get(CONF_PORT, DEFAULT_PORT),
timeout=8,
+ enable_debounce=True,
)
coordinator = ApSystemsDataCoordinator(hass, api)
await coordinator.async_config_entry_first_refresh()
diff --git a/homeassistant/components/apsystems/coordinator.py b/homeassistant/components/apsystems/coordinator.py
index b6e951343f7..2535c66c4ac 100644
--- a/homeassistant/components/apsystems/coordinator.py
+++ b/homeassistant/components/apsystems/coordinator.py
@@ -5,12 +5,17 @@ from __future__ import annotations
from dataclasses import dataclass
from datetime import timedelta
-from APsystemsEZ1 import APsystemsEZ1M, ReturnAlarmInfo, ReturnOutputData
+from APsystemsEZ1 import (
+ APsystemsEZ1M,
+ InverterReturnedError,
+ ReturnAlarmInfo,
+ ReturnOutputData,
+)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
-from .const import LOGGER
+from .const import DOMAIN, LOGGER
@dataclass
@@ -24,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__(
@@ -41,8 +48,14 @@ 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:
- output_data = await self.api.get_output_data()
- alarm_info = await self.api.get_alarm_info()
+ try:
+ output_data = await self.api.get_output_data()
+ alarm_info = await self.api.get_alarm_info()
+ except InverterReturnedError:
+ raise UpdateFailed(
+ translation_domain=DOMAIN, translation_key="inverter_error"
+ ) from None
return ApSystemsSensorData(output_data=output_data, alarm_info=alarm_info)
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/apsystems/manifest.json b/homeassistant/components/apsystems/manifest.json
index 9376d21ba28..a58530b05e2 100644
--- a/homeassistant/components/apsystems/manifest.json
+++ b/homeassistant/components/apsystems/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/apsystems",
"integration_type": "device",
"iot_class": "local_polling",
- "requirements": ["apsystems-ez1==2.2.1"]
+ "requirements": ["apsystems-ez1==2.4.0"]
}
diff --git a/homeassistant/components/apsystems/number.py b/homeassistant/components/apsystems/number.py
index 01e991f5188..b5ed60a7754 100644
--- a/homeassistant/components/apsystems/number.py
+++ b/homeassistant/components/apsystems/number.py
@@ -2,6 +2,8 @@
from __future__ import annotations
+from aiohttp import ClientConnectorError
+
from homeassistant.components.number import NumberDeviceClass, NumberEntity, NumberMode
from homeassistant.const import UnitOfPower
from homeassistant.core import HomeAssistant
@@ -20,7 +22,7 @@ async def async_setup_entry(
) -> None:
"""Set up the sensor platform."""
- add_entities([ApSystemsMaxOutputNumber(config_entry.runtime_data)])
+ add_entities([ApSystemsMaxOutputNumber(config_entry.runtime_data)], True)
class ApSystemsMaxOutputNumber(ApSystemsEntity, NumberEntity):
@@ -45,7 +47,13 @@ class ApSystemsMaxOutputNumber(ApSystemsEntity, NumberEntity):
async def async_update(self) -> None:
"""Set the state with the value fetched from the inverter."""
- self._attr_native_value = await self._api.get_max_power()
+ try:
+ status = await self._api.get_max_power()
+ except (TimeoutError, ClientConnectorError):
+ self._attr_available = False
+ else:
+ self._attr_available = True
+ self._attr_native_value = status
async def async_set_native_value(self, value: float) -> None:
"""Set the desired output power."""
diff --git a/homeassistant/components/apsystems/strings.json b/homeassistant/components/apsystems/strings.json
index e02f86c2730..b3a10ca49a7 100644
--- a/homeassistant/components/apsystems/strings.json
+++ b/homeassistant/components/apsystems/strings.json
@@ -72,5 +72,10 @@
"name": "Inverter status"
}
}
+ },
+ "exceptions": {
+ "inverter_error": {
+ "message": "Inverter returned an error"
+ }
}
}
diff --git a/homeassistant/components/apsystems/switch.py b/homeassistant/components/apsystems/switch.py
index 93a21ec9f05..73914845445 100644
--- a/homeassistant/components/apsystems/switch.py
+++ b/homeassistant/components/apsystems/switch.py
@@ -5,6 +5,7 @@ from __future__ import annotations
from typing import Any
from aiohttp.client_exceptions import ClientConnectionError
+from APsystemsEZ1 import InverterReturnedError
from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity
from homeassistant.core import HomeAssistant
@@ -40,7 +41,7 @@ class ApSystemsInverterSwitch(ApSystemsEntity, SwitchEntity):
"""Update switch status and availability."""
try:
status = await self._api.get_device_power_status()
- except (TimeoutError, ClientConnectionError):
+ except (TimeoutError, ClientConnectionError, InverterReturnedError):
self._attr_available = False
else:
self._attr_available = True
diff --git a/homeassistant/components/aqualogic/manifest.json b/homeassistant/components/aqualogic/manifest.json
index 783e4c8c204..cc807e4bb19 100644
--- a/homeassistant/components/aqualogic/manifest.json
+++ b/homeassistant/components/aqualogic/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/aqualogic",
"iot_class": "local_push",
"loggers": ["aqualogic"],
+ "quality_scale": "legacy",
"requirements": ["aqualogic==2.6"]
}
diff --git a/homeassistant/components/aquostv/manifest.json b/homeassistant/components/aquostv/manifest.json
index 1bac2bdfb5f..6fc1092d33c 100644
--- a/homeassistant/components/aquostv/manifest.json
+++ b/homeassistant/components/aquostv/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/aquostv",
"iot_class": "local_polling",
"loggers": ["sharp_aquos_rc"],
+ "quality_scale": "legacy",
"requirements": ["sharp_aquos_rc==0.3.2"]
}
diff --git a/homeassistant/components/aranet/manifest.json b/homeassistant/components/aranet/manifest.json
index 6cce7554dd1..ac45e352bb6 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.0"]
}
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/arest/manifest.json b/homeassistant/components/arest/manifest.json
index 53732d15064..be43b3aafc9 100644
--- a/homeassistant/components/arest/manifest.json
+++ b/homeassistant/components/arest/manifest.json
@@ -3,5 +3,6 @@
"name": "aREST",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/arest",
- "iot_class": "local_polling"
+ "iot_class": "local_polling",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/arris_tg2492lg/manifest.json b/homeassistant/components/arris_tg2492lg/manifest.json
index c36423d287a..98778de5f2a 100644
--- a/homeassistant/components/arris_tg2492lg/manifest.json
+++ b/homeassistant/components/arris_tg2492lg/manifest.json
@@ -6,5 +6,6 @@
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["arris_tg2492lg"],
+ "quality_scale": "legacy",
"requirements": ["arris-tg2492lg==2.2.0"]
}
diff --git a/homeassistant/components/aruba/device_tracker.py b/homeassistant/components/aruba/device_tracker.py
index ef622ef9826..911fab441e5 100644
--- a/homeassistant/components/aruba/device_tracker.py
+++ b/homeassistant/components/aruba/device_tracker.py
@@ -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 0d1fabf51b8..29fba6d9a58 100644
--- a/homeassistant/components/aruba/manifest.json
+++ b/homeassistant/components/aruba/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/aruba",
"iot_class": "local_polling",
"loggers": ["pexpect", "ptyprocess"],
- "requirements": ["pexpect==4.6.0"]
+ "quality_scale": "legacy",
+ "requirements": ["pexpect==4.9.0"]
}
diff --git a/homeassistant/components/arwn/manifest.json b/homeassistant/components/arwn/manifest.json
index 15eb656e974..8cabb045b64 100644
--- a/homeassistant/components/arwn/manifest.json
+++ b/homeassistant/components/arwn/manifest.json
@@ -4,5 +4,6 @@
"codeowners": [],
"dependencies": ["mqtt"],
"documentation": "https://www.home-assistant.io/integrations/arwn",
- "iot_class": "local_polling"
+ "iot_class": "local_polling",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/assist_pipeline/__init__.py b/homeassistant/components/assist_pipeline/__init__.py
index ec6d8a646b6..851c873bb12 100644
--- a/homeassistant/components/assist_pipeline/__init__.py
+++ b/homeassistant/components/assist_pipeline/__init__.py
@@ -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/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py
index a55e23ae051..c3a5b93ca6a 100644
--- a/homeassistant/components/assist_pipeline/pipeline.py
+++ b/homeassistant/components/assist_pipeline/pipeline.py
@@ -16,6 +16,7 @@ import time
from typing import Any, Literal, cast
import wave
+import hass_nabucasa
import voluptuous as vol
from homeassistant.components import (
@@ -29,8 +30,10 @@ from homeassistant.components import (
from homeassistant.components.tts import (
generate_media_source_id as tts_generate_media_source_id,
)
+from homeassistant.const import MATCH_ALL
from homeassistant.core import Context, HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers import intent
from homeassistant.helpers.collection import (
CHANGE_UPDATED,
CollectionError,
@@ -109,6 +112,7 @@ PIPELINE_FIELDS: VolDictType = {
vol.Required("tts_voice"): vol.Any(str, None),
vol.Required("wake_word_entity"): vol.Any(str, None),
vol.Required("wake_word_id"): vol.Any(str, None),
+ vol.Optional("prefer_local_intents"): bool,
}
STORED_PIPELINE_RUNS = 10
@@ -322,6 +326,7 @@ async def async_update_pipeline(
tts_voice: str | None | UndefinedType = UNDEFINED,
wake_word_entity: str | None | UndefinedType = UNDEFINED,
wake_word_id: str | None | UndefinedType = UNDEFINED,
+ prefer_local_intents: bool | UndefinedType = UNDEFINED,
) -> None:
"""Update a pipeline."""
pipeline_data: PipelineData = hass.data[DOMAIN]
@@ -345,6 +350,7 @@ async def async_update_pipeline(
("tts_voice", tts_voice),
("wake_word_entity", wake_word_entity),
("wake_word_id", wake_word_id),
+ ("prefer_local_intents", prefer_local_intents),
)
if val is not UNDEFINED
}
@@ -398,6 +404,7 @@ class Pipeline:
tts_voice: str | None
wake_word_entity: str | None
wake_word_id: str | None
+ prefer_local_intents: bool = False
id: str = field(default_factory=ulid_util.ulid_now)
@@ -421,6 +428,7 @@ class Pipeline:
tts_voice=data["tts_voice"],
wake_word_entity=data["wake_word_entity"],
wake_word_id=data["wake_word_id"],
+ prefer_local_intents=data.get("prefer_local_intents", False),
)
def to_json(self) -> dict[str, Any]:
@@ -438,6 +446,7 @@ class Pipeline:
"tts_voice": self.tts_voice,
"wake_word_entity": self.wake_word_entity,
"wake_word_id": self.wake_word_id,
+ "prefer_local_intents": self.prefer_local_intents,
}
@@ -910,6 +919,11 @@ class PipelineRun:
)
except (asyncio.CancelledError, TimeoutError):
raise # expected
+ except hass_nabucasa.auth.Unauthenticated as src_error:
+ raise SpeechToTextError(
+ code="cloud-auth-failed",
+ message="Home Assistant Cloud authentication failed",
+ ) from src_error
except Exception as src_error:
_LOGGER.exception("Unexpected error during speech-to-text")
raise SpeechToTextError(
@@ -996,35 +1010,91 @@ 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:
raise RuntimeError("Recognize intent was not prepared")
+ if self.pipeline.conversation_language == MATCH_ALL:
+ # LLMs support all languages ('*') so use pipeline language for
+ # intent fallback.
+ input_language = self.pipeline.language
+ else:
+ input_language = self.pipeline.conversation_language
+
self.process_event(
PipelineEvent(
PipelineEventType.INTENT_START,
{
"engine": self.intent_agent,
- "language": self.pipeline.conversation_language,
+ "language": input_language,
"intent_input": intent_input,
"conversation_id": conversation_id,
"device_id": device_id,
+ "prefer_local_intents": self.pipeline.prefer_local_intents,
},
)
)
try:
- conversation_result = await conversation.async_converse(
- hass=self.hass,
+ user_input = conversation.ConversationInput(
text=intent_input,
+ context=self.context,
conversation_id=conversation_id,
device_id=device_id,
- context=self.context,
- language=self.pipeline.conversation_language,
+ 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
+ if user_input.agent_id != conversation.HOME_ASSISTANT_AGENT:
+ # Sentence triggers override conversation agent
+ if (
+ trigger_response_text
+ := await conversation.async_handle_sentence_triggers(
+ self.hass, user_input
+ )
+ ) is not None:
+ # Sentence trigger matched
+ trigger_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,
+ )
+ # Try local intents first, if preferred.
+ elif self.pipeline.prefer_local_intents and (
+ intent_response := await conversation.async_handle_intents(
+ self.hass, user_input
+ )
+ ):
+ # Local intent matched
+ conversation_result = conversation.ConversationResult(
+ response=intent_response,
+ conversation_id=user_input.conversation_id,
+ )
+ processed_locally = True
+
+ if conversation_result is None:
+ # Fall back to pipeline conversation agent
+ conversation_result = await conversation.async_converse(
+ hass=self.hass,
+ text=user_input.text,
+ conversation_id=user_input.conversation_id,
+ device_id=user_input.device_id,
+ context=user_input.context,
+ language=user_input.language,
+ agent_id=user_input.agent_id,
+ )
except Exception as src_error:
_LOGGER.exception("Unexpected error during intent recognition")
raise IntentRecognitionError(
@@ -1037,7 +1107,10 @@ class PipelineRun:
self.process_event(
PipelineEvent(
PipelineEventType.INTENT_END,
- {"intent_output": conversation_result.as_dict()},
+ {
+ "processed_locally": processed_locally,
+ "intent_output": conversation_result.as_dict(),
+ },
)
)
@@ -1324,8 +1397,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."""
@@ -1415,6 +1493,7 @@ class PipelineInput:
intent_input,
self.conversation_id,
self.device_id,
+ self.conversation_extra_system_prompt,
)
if tts_input.strip():
current_stage = PipelineStage.TTS
diff --git a/homeassistant/components/assist_pipeline/vad.py b/homeassistant/components/assist_pipeline/vad.py
index deae5b9b7b3..d4647fafe2a 100644
--- a/homeassistant/components/assist_pipeline/vad.py
+++ b/homeassistant/components/assist_pipeline/vad.py
@@ -75,7 +75,7 @@ class AudioBuffer:
class VoiceCommandSegmenter:
"""Segments an audio stream into voice commands."""
- speech_seconds: float = 0.1
+ speech_seconds: float = 0.3
"""Seconds of speech before voice command has started."""
command_seconds: float = 1.0
@@ -140,7 +140,7 @@ class VoiceCommandSegmenter:
self._timeout_seconds_left -= chunk_seconds
if self._timeout_seconds_left <= 0:
- _LOGGER.warning(
+ _LOGGER.debug(
"VAD end of speech detection timed out after %s seconds",
self.timeout_seconds,
)
diff --git a/homeassistant/components/asuswrt/strings.json b/homeassistant/components/asuswrt/strings.json
index bab40f281f5..9d50f50c7e9 100644
--- a/homeassistant/components/asuswrt/strings.json
+++ b/homeassistant/components/asuswrt/strings.json
@@ -31,8 +31,8 @@
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"abort": {
- "invalid_unique_id": "Impossible to determine a valid unique id for the device",
- "no_unique_id": "A device without a valid unique id is already configured. Configuration of multiple instance is not possible"
+ "invalid_unique_id": "Impossible to determine a valid unique ID for the device",
+ "no_unique_id": "A device without a valid unique ID is already configured. Configuration of multiple instances is not possible"
}
},
"options": {
@@ -42,7 +42,7 @@
"consider_home": "Seconds to wait before considering a device away",
"track_unknown": "Track unknown / unnamed devices",
"interface": "The interface that you want statistics from (e.g. eth0, eth1 etc)",
- "dnsmasq": "The location in the router of the dnsmasq.leases files",
+ "dnsmasq": "The location of the dnsmasq.leases file in the router",
"require_ip": "Devices must have IP (for access point mode)"
}
}
diff --git a/homeassistant/components/atag/climate.py b/homeassistant/components/atag/climate.py
index daeb64f7f0a..a362b71fbc8 100644
--- a/homeassistant/components/atag/climate.py
+++ b/homeassistant/components/atag/climate.py
@@ -46,7 +46,6 @@ class AtagThermostat(AtagEntity, ClimateEntity):
_attr_supported_features = (
ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE
)
- _enable_turn_on_off_backwards_compatibility = False
def __init__(self, coordinator: AtagDataUpdateCoordinator, atag_id: str) -> None:
"""Initialize an Atag climate device."""
diff --git a/homeassistant/components/aten_pe/manifest.json b/homeassistant/components/aten_pe/manifest.json
index 3b4ade637cb..1e2c74f2636 100644
--- a/homeassistant/components/aten_pe/manifest.json
+++ b/homeassistant/components/aten_pe/manifest.json
@@ -4,5 +4,6 @@
"codeowners": ["@mtdcr"],
"documentation": "https://www.home-assistant.io/integrations/aten_pe",
"iot_class": "local_polling",
+ "quality_scale": "legacy",
"requirements": ["atenpdu==0.3.2"]
}
diff --git a/homeassistant/components/atome/manifest.json b/homeassistant/components/atome/manifest.json
index cafe24e2e13..f00dd5ea757 100644
--- a/homeassistant/components/atome/manifest.json
+++ b/homeassistant/components/atome/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/atome",
"iot_class": "cloud_polling",
"loggers": ["pyatome"],
+ "quality_scale": "legacy",
"requirements": ["pyAtome==0.1.1"]
}
diff --git a/homeassistant/components/august/manifest.json b/homeassistant/components/august/manifest.json
index 4bc7e77d2d8..652f1a7b966 100644
--- a/homeassistant/components/august/manifest.json
+++ b/homeassistant/components/august/manifest.json
@@ -28,5 +28,5 @@
"documentation": "https://www.home-assistant.io/integrations/august",
"iot_class": "cloud_push",
"loggers": ["pubnub", "yalexs"],
- "requirements": ["yalexs==8.10.0", "yalexs-ble==2.5.0"]
+ "requirements": ["yalexs==8.10.0", "yalexs-ble==2.5.6"]
}
diff --git a/homeassistant/components/aussie_broadband/config_flow.py b/homeassistant/components/aussie_broadband/config_flow.py
index 5bc6ed1aa5c..72ff0b3b2b2 100644
--- a/homeassistant/components/aussie_broadband/config_flow.py
+++ b/homeassistant/components/aussie_broadband/config_flow.py
@@ -22,13 +22,14 @@ class AussieBroadbandConfigFlow(ConfigFlow, domain=DOMAIN):
VERSION = 1
+ _reauth_username: str
+
def __init__(self) -> None:
"""Initialize the config flow."""
self.data: dict = {}
self.options: dict = {CONF_SERVICES: []}
self.services: list[dict[str, Any]] = []
self.client: AussieBB | None = None
- self._reauth_username: str | None = None
async def async_auth(self, user_input: dict[str, str]) -> dict[str, str] | None:
"""Reusable Auth Helper."""
@@ -92,7 +93,7 @@ class AussieBroadbandConfigFlow(ConfigFlow, domain=DOMAIN):
errors: dict[str, str] | None = None
- if user_input and self._reauth_username:
+ if user_input:
data = {
CONF_USERNAME: self._reauth_username,
CONF_PASSWORD: user_input[CONF_PASSWORD],
diff --git a/homeassistant/components/aussie_broadband/manifest.json b/homeassistant/components/aussie_broadband/manifest.json
index 877a46a3650..ea402f03b0e 100644
--- a/homeassistant/components/aussie_broadband/manifest.json
+++ b/homeassistant/components/aussie_broadband/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/aussie_broadband",
"iot_class": "cloud_polling",
"loggers": ["aussiebb"],
- "requirements": ["pyaussiebb==0.0.15"]
+ "requirements": ["pyaussiebb==0.1.5"]
}
diff --git a/homeassistant/components/autarco/__init__.py b/homeassistant/components/autarco/__init__.py
index 0e29b25ad80..f42bfdf4a0e 100644
--- a/homeassistant/components/autarco/__init__.py
+++ b/homeassistant/components/autarco/__init__.py
@@ -4,11 +4,12 @@ from __future__ import annotations
import asyncio
-from autarco import Autarco
+from autarco import Autarco, AutarcoConnectionError
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform
from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .coordinator import AutarcoDataUpdateCoordinator
@@ -25,7 +26,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: AutarcoConfigEntry) -> b
password=entry.data[CONF_PASSWORD],
session=async_get_clientsession(hass),
)
- account_sites = await client.get_account()
+
+ try:
+ account_sites = await client.get_account()
+ except AutarcoConnectionError as err:
+ await client.close()
+ raise ConfigEntryNotReady from err
coordinators: list[AutarcoDataUpdateCoordinator] = [
AutarcoDataUpdateCoordinator(hass, client, site) for site in account_sites
diff --git a/homeassistant/components/autarco/config_flow.py b/homeassistant/components/autarco/config_flow.py
index a66f14047a7..294fa685fb8 100644
--- a/homeassistant/components/autarco/config_flow.py
+++ b/homeassistant/components/autarco/config_flow.py
@@ -2,6 +2,7 @@
from __future__ import annotations
+from collections.abc import Mapping
from typing import Any
from autarco import Autarco, AutarcoAuthenticationError, AutarcoConnectionError
@@ -20,6 +21,12 @@ DATA_SCHEMA = vol.Schema(
}
)
+STEP_REAUTH_SCHEMA = vol.Schema(
+ {
+ vol.Required(CONF_PASSWORD): str,
+ }
+)
+
class AutarcoConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Autarco."""
@@ -55,3 +62,40 @@ class AutarcoConfigFlow(ConfigFlow, domain=DOMAIN):
errors=errors,
data_schema=DATA_SCHEMA,
)
+
+ async def async_step_reauth(
+ self, entry_data: Mapping[str, Any]
+ ) -> ConfigFlowResult:
+ """Handle re-authentication request from Autarco."""
+ 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 confirmation."""
+ errors = {}
+
+ reauth_entry = self._get_reauth_entry()
+ if user_input is not None:
+ client = Autarco(
+ email=reauth_entry.data[CONF_EMAIL],
+ password=user_input[CONF_PASSWORD],
+ session=async_get_clientsession(self.hass),
+ )
+ try:
+ await client.get_account()
+ except AutarcoAuthenticationError:
+ errors["base"] = "invalid_auth"
+ except AutarcoConnectionError:
+ errors["base"] = "cannot_connect"
+ else:
+ return self.async_update_reload_and_abort(
+ reauth_entry,
+ data_updates=user_input,
+ )
+ return self.async_show_form(
+ step_id="reauth_confirm",
+ description_placeholders={"email": reauth_entry.data[CONF_EMAIL]},
+ data_schema=STEP_REAUTH_SCHEMA,
+ errors=errors,
+ )
diff --git a/homeassistant/components/autarco/coordinator.py b/homeassistant/components/autarco/coordinator.py
index 5dd19478ae8..dd8786bca25 100644
--- a/homeassistant/components/autarco/coordinator.py
+++ b/homeassistant/components/autarco/coordinator.py
@@ -7,6 +7,7 @@ from typing import NamedTuple
from autarco import (
AccountSite,
Autarco,
+ AutarcoAuthenticationError,
AutarcoConnectionError,
Battery,
Inverter,
@@ -16,6 +17,7 @@ from autarco import (
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 DOMAIN, LOGGER, SCAN_INTERVAL
@@ -60,8 +62,10 @@ class AutarcoDataUpdateCoordinator(DataUpdateCoordinator[AutarcoData]):
inverters = await self.client.get_inverters(self.account_site.public_key)
if site.has_battery:
battery = await self.client.get_battery(self.account_site.public_key)
- except AutarcoConnectionError as error:
- raise UpdateFailed(error) from error
+ except AutarcoAuthenticationError as err:
+ raise ConfigEntryAuthFailed(err) from err
+ except AutarcoConnectionError as err:
+ raise UpdateFailed(err) from err
return AutarcoData(
solar=solar,
inverters=inverters,
diff --git a/homeassistant/components/autarco/quality_scale.yaml b/homeassistant/components/autarco/quality_scale.yaml
new file mode 100644
index 00000000000..d2e1455af7e
--- /dev/null
+++ b/homeassistant/components/autarco/quality_scale.yaml
@@ -0,0 +1,99 @@
+rules:
+ # Bronze
+ action-setup:
+ status: exempt
+ comment: |
+ This integration does not provide additional actions.
+ appropriate-polling: done
+ brands: done
+ common-modules:
+ status: todo
+ comment: |
+ The entity.py file is not used in this integration.
+ 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
+ action-exceptions:
+ status: exempt
+ comment: |
+ This integration does not provide additional actions.
+ config-entry-unloading: done
+ docs-configuration-parameters:
+ status: exempt
+ comment: |
+ This integration does not have an options flow.
+ docs-installation-parameters: done
+ entity-unavailable: done
+ integration-owner: done
+ log-when-unavailable: done
+ parallel-updates:
+ status: exempt
+ comment: |
+ This integration only polls data using a coordinator.
+ Since the integration is read-only and poll-only (only provide sensor
+ data), there is no need to implement parallel updates.
+ reauthentication-flow: done
+ test-coverage: done
+
+ # Gold
+ devices: done
+ diagnostics: done
+ discovery-update-info:
+ status: exempt
+ comment: |
+ This integration cannot be discovered, it is a connecting to a service
+ provider, which uses the users home address to get the data.
+ discovery:
+ status: exempt
+ comment: |
+ This integration cannot be discovered, it is a connecting to a service
+ provider, which uses the users home address to get the data.
+ docs-data-update: done
+ docs-examples: todo
+ docs-known-limitations: todo
+ docs-supported-devices:
+ status: exempt
+ comment: |
+ This is an service, which doesn't integrate with any devices.
+ docs-supported-functions: done
+ docs-troubleshooting: todo
+ docs-use-cases: done
+ dynamic-devices: todo
+ entity-category: done
+ entity-device-class: done
+ entity-disabled-by-default:
+ status: exempt
+ comment: |
+ This integration does not have any entities that should disabled by default.
+ entity-translations: done
+ exception-translations: done
+ icon-translations: done
+ reconfiguration-flow: todo
+ repair-issues:
+ status: exempt
+ comment: |
+ This integration doesn't have any cases where raising an issue is needed.
+ stale-devices: todo
+ # Platinum
+ async-dependency: done
+ inject-websession: done
+ strict-typing: done
diff --git a/homeassistant/components/autarco/strings.json b/homeassistant/components/autarco/strings.json
index 8eda5fe0411..a053cd36e09 100644
--- a/homeassistant/components/autarco/strings.json
+++ b/homeassistant/components/autarco/strings.json
@@ -2,7 +2,7 @@
"config": {
"step": {
"user": {
- "description": "Connect to your Autarco account to get information about your solar panels.",
+ "description": "Connect to your Autarco account, to get information about your sites.",
"data": {
"email": "[%key:common::config_flow::data::email%]",
"password": "[%key:common::config_flow::data::password%]"
@@ -11,6 +11,16 @@
"email": "The email address of your Autarco account.",
"password": "The password of your Autarco account."
}
+ },
+ "reauth_confirm": {
+ "title": "[%key:common::config_flow::title::reauth%]",
+ "description": "The password for {email} is no longer valid.",
+ "data": {
+ "password": "[%key:common::config_flow::data::password%]"
+ },
+ "data_description": {
+ "password": "[%key:component::autarco::config::step::user::data_description::password%]"
+ }
}
},
"error": {
@@ -18,7 +28,8 @@
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
},
"abort": {
- "already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
+ "already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
+ "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
}
},
"entity": {
diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py
index 4fcd8a1416d..bd8af526d75 100644
--- a/homeassistant/components/automation/__init__.py
+++ b/homeassistant/components/automation/__init__.py
@@ -6,7 +6,6 @@ from abc import ABC, abstractmethod
import asyncio
from collections.abc import Callable, Mapping
from dataclasses import dataclass
-from functools import partial
import logging
from typing import Any, Protocol, cast
@@ -51,12 +50,6 @@ from homeassistant.core import (
from homeassistant.exceptions import HomeAssistantError, ServiceNotFound, TemplateError
from homeassistant.helpers import condition
import homeassistant.helpers.config_validation as cv
-from homeassistant.helpers.deprecation import (
- DeprecatedConstant,
- all_with_deprecated_constants,
- check_if_deprecated_constant,
- dir_with_deprecated_constants,
-)
from homeassistant.helpers.entity import ToggleEntity
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.issue_registry import (
@@ -86,12 +79,7 @@ from homeassistant.helpers.trace import (
trace_get,
trace_path,
)
-from homeassistant.helpers.trigger import (
- TriggerActionType,
- TriggerData,
- TriggerInfo,
- async_initialize_triggers,
-)
+from homeassistant.helpers.trigger import async_initialize_triggers
from homeassistant.helpers.typing import ConfigType
from homeassistant.loader import bind_hass
from homeassistant.util.dt import parse_datetime
@@ -137,20 +125,6 @@ class IfAction(Protocol):
"""AND all conditions."""
-# AutomationActionType, AutomationTriggerData,
-# and AutomationTriggerInfo are deprecated as of 2022.9.
-# Can be removed in 2025.1
-_DEPRECATED_AutomationActionType = DeprecatedConstant(
- TriggerActionType, "TriggerActionType", "2025.1"
-)
-_DEPRECATED_AutomationTriggerData = DeprecatedConstant(
- TriggerData, "TriggerData", "2025.1"
-)
-_DEPRECATED_AutomationTriggerInfo = DeprecatedConstant(
- TriggerInfo, "TriggerInfo", "2025.1"
-)
-
-
@bind_hass
def is_on(hass: HomeAssistant, entity_id: str) -> bool:
"""Return true if specified automation entity_id is on.
@@ -477,6 +451,7 @@ class UnavailableAutomationEntity(BaseAutomationEntity):
)
async def async_will_remove_from_hass(self) -> None:
+ """Run when entity will be removed from hass."""
await super().async_will_remove_from_hass()
async_delete_issue(
self.hass, DOMAIN, f"{self.entity_id}_validation_{self._validation_status}"
@@ -1219,11 +1194,3 @@ def websocket_config(
"config": automation.raw_config,
},
)
-
-
-# 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/avea/manifest.json b/homeassistant/components/avea/manifest.json
index 43c46c96e66..7e6c080481e 100644
--- a/homeassistant/components/avea/manifest.json
+++ b/homeassistant/components/avea/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/avea",
"iot_class": "local_polling",
"loggers": ["avea"],
+ "quality_scale": "legacy",
"requirements": ["avea==1.5.1"]
}
diff --git a/homeassistant/components/avion/manifest.json b/homeassistant/components/avion/manifest.json
index 505dca870a7..8488e949af3 100644
--- a/homeassistant/components/avion/manifest.json
+++ b/homeassistant/components/avion/manifest.json
@@ -4,5 +4,6 @@
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/avion",
"iot_class": "assumed_state",
+ "quality_scale": "legacy",
"requirements": ["avion==0.10"]
}
diff --git a/homeassistant/components/aws/config_flow.py b/homeassistant/components/aws/config_flow.py
index 3175e6bc56c..090d9747a64 100644
--- a/homeassistant/components/aws/config_flow.py
+++ b/homeassistant/components/aws/config_flow.py
@@ -14,7 +14,4 @@ class AWSFlowHandler(ConfigFlow, domain=DOMAIN):
async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult:
"""Import a config entry."""
- if self._async_current_entries():
- return self.async_abort(reason="single_instance_allowed")
-
return self.async_create_entry(title="configuration.yaml", data=import_data)
diff --git a/homeassistant/components/aws/manifest.json b/homeassistant/components/aws/manifest.json
index 6238bffce36..12149e4388a 100644
--- a/homeassistant/components/aws/manifest.json
+++ b/homeassistant/components/aws/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/aws",
"iot_class": "cloud_push",
"loggers": ["aiobotocore", "botocore"],
+ "quality_scale": "legacy",
"requirements": ["aiobotocore==2.13.1", "botocore==1.34.131"]
}
diff --git a/homeassistant/components/axis/manifest.json b/homeassistant/components/axis/manifest.json
index d2265307d47..9758af60178 100644
--- a/homeassistant/components/axis/manifest.json
+++ b/homeassistant/components/axis/manifest.json
@@ -29,8 +29,7 @@
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["axis"],
- "quality_scale": "platinum",
- "requirements": ["axis==63"],
+ "requirements": ["axis==64"],
"ssdp": [
{
"manufacturer": "AXIS"
diff --git a/homeassistant/components/azure_event_hub/config_flow.py b/homeassistant/components/azure_event_hub/config_flow.py
index 60ac9bff8cd..baed866042e 100644
--- a/homeassistant/components/azure_event_hub/config_flow.py
+++ b/homeassistant/components/azure_event_hub/config_flow.py
@@ -102,8 +102,6 @@ class AEHConfigFlow(ConfigFlow, domain=DOMAIN):
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial user step."""
- if self._async_current_entries():
- return self.async_abort(reason="single_instance_allowed")
if user_input is None:
return self.async_show_form(step_id=STEP_USER, data_schema=BASE_SCHEMA)
@@ -160,8 +158,6 @@ class AEHConfigFlow(ConfigFlow, domain=DOMAIN):
async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult:
"""Import config from configuration.yaml."""
- if self._async_current_entries():
- return self.async_abort(reason="single_instance_allowed")
if CONF_SEND_INTERVAL in import_data:
self._options[CONF_SEND_INTERVAL] = import_data.pop(CONF_SEND_INTERVAL)
if CONF_MAX_DELAY in import_data:
diff --git a/homeassistant/components/azure_event_hub/manifest.json b/homeassistant/components/azure_event_hub/manifest.json
index c6d5835fd1d..45fbf8c4a56 100644
--- a/homeassistant/components/azure_event_hub/manifest.json
+++ b/homeassistant/components/azure_event_hub/manifest.json
@@ -6,5 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/azure_event_hub",
"iot_class": "cloud_push",
"loggers": ["azure"],
- "requirements": ["azure-eventhub==5.11.1"]
+ "requirements": ["azure-eventhub==5.11.1"],
+ "single_config_entry": true
}
diff --git a/homeassistant/components/azure_event_hub/strings.json b/homeassistant/components/azure_event_hub/strings.json
index 3319a29a154..d17c4a385c0 100644
--- a/homeassistant/components/azure_event_hub/strings.json
+++ b/homeassistant/components/azure_event_hub/strings.json
@@ -31,7 +31,6 @@
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
- "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]",
"cannot_connect": "Connecting with the credentials from the configuration.yaml failed, please remove from yaml and use the config flow.",
"unknown": "Connecting with the credentials from the configuration.yaml failed with an unknown error, please remove from yaml and use the config flow."
}
diff --git a/homeassistant/components/azure_service_bus/manifest.json b/homeassistant/components/azure_service_bus/manifest.json
index 059f6300aec..31c1edac686 100644
--- a/homeassistant/components/azure_service_bus/manifest.json
+++ b/homeassistant/components/azure_service_bus/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/azure_service_bus",
"iot_class": "cloud_push",
"loggers": ["azure"],
+ "quality_scale": "legacy",
"requirements": ["azure-servicebus==7.10.0"]
}
diff --git a/homeassistant/components/backup/__init__.py b/homeassistant/components/backup/__init__.py
index 200cb4a3f65..00b226a9fee 100644
--- a/homeassistant/components/backup/__init__.py
+++ b/homeassistant/components/backup/__init__.py
@@ -5,36 +5,89 @@ from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.hassio import is_hassio
from homeassistant.helpers.typing import ConfigType
-from .const import DATA_MANAGER, DOMAIN, LOGGER
+# Pre-import backup to avoid it being imported
+# later when the import executor is busy and delaying
+# startup
+from . import backup # noqa: F401
+from .agent import (
+ BackupAgent,
+ BackupAgentError,
+ BackupAgentPlatformProtocol,
+ LocalBackupAgent,
+)
+from .const import DATA_MANAGER, DOMAIN
from .http import async_register_http_views
-from .manager import BackupManager
+from .manager import (
+ BackupManager,
+ BackupPlatformProtocol,
+ BackupReaderWriter,
+ BackupReaderWriterError,
+ CoreBackupReaderWriter,
+ CreateBackupEvent,
+ IncorrectPasswordError,
+ ManagerBackup,
+ NewBackup,
+ WrittenBackup,
+)
+from .models import AddonInfo, AgentBackup, Folder
from .websocket import async_register_websocket_handlers
+__all__ = [
+ "AddonInfo",
+ "AgentBackup",
+ "ManagerBackup",
+ "BackupAgent",
+ "BackupAgentError",
+ "BackupAgentPlatformProtocol",
+ "BackupPlatformProtocol",
+ "BackupReaderWriter",
+ "BackupReaderWriterError",
+ "CreateBackupEvent",
+ "Folder",
+ "IncorrectPasswordError",
+ "LocalBackupAgent",
+ "NewBackup",
+ "WrittenBackup",
+]
+
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Backup integration."""
- backup_manager = BackupManager(hass)
- hass.data[DATA_MANAGER] = backup_manager
-
with_hassio = is_hassio(hass)
+ reader_writer: BackupReaderWriter
+ if not with_hassio:
+ reader_writer = CoreBackupReaderWriter(hass)
+ else:
+ # pylint: disable-next=import-outside-toplevel, hass-component-root-import
+ from homeassistant.components.hassio.backup import SupervisorBackupReaderWriter
+
+ reader_writer = SupervisorBackupReaderWriter(hass)
+
+ backup_manager = BackupManager(hass, reader_writer)
+ hass.data[DATA_MANAGER] = backup_manager
+ await backup_manager.async_setup()
+
async_register_websocket_handlers(hass, with_hassio)
- if with_hassio:
- if DOMAIN in config:
- LOGGER.error(
- "The backup integration is not supported on this installation method, "
- "please remove it from your configuration"
- )
- return True
-
async def async_handle_create_service(call: ServiceCall) -> None:
"""Service handler for creating backups."""
- await backup_manager.async_create_backup()
+ agent_id = list(backup_manager.local_backup_agents)[0]
+ await backup_manager.async_create_backup(
+ agent_ids=[agent_id],
+ include_addons=None,
+ include_all_addons=False,
+ include_database=True,
+ include_folders=None,
+ include_homeassistant=True,
+ name=None,
+ password=None,
+ )
- hass.services.async_register(DOMAIN, "create", async_handle_create_service)
+ if not with_hassio:
+ hass.services.async_register(DOMAIN, "create", async_handle_create_service)
async_register_http_views(hass)
diff --git a/homeassistant/components/backup/agent.py b/homeassistant/components/backup/agent.py
new file mode 100644
index 00000000000..44bc9b298e8
--- /dev/null
+++ b/homeassistant/components/backup/agent.py
@@ -0,0 +1,121 @@
+"""Backup agents for the Backup integration."""
+
+from __future__ import annotations
+
+import abc
+from collections.abc import AsyncIterator, Callable, Coroutine
+from pathlib import Path
+from typing import Any, Protocol
+
+from propcache 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."""
+
+
+class BackupAgentUnreachableError(BackupAgentError):
+ """Raised when the agent can't reach its API."""
+
+ _message = "The backup agent is unreachable."
+
+
+class BackupAgent(abc.ABC):
+ """Backup agent interface."""
+
+ domain: str
+ name: str
+
+ @cached_property
+ def agent_id(self) -> str:
+ """Return the agent_id."""
+ return f"{self.domain}.{self.name}"
+
+ @abc.abstractmethod
+ 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.
+ """
+
+ @abc.abstractmethod
+ 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.
+ """
+
+ @abc.abstractmethod
+ 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.
+ """
+
+ @abc.abstractmethod
+ async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]:
+ """List backups."""
+
+ @abc.abstractmethod
+ async def async_get_backup(
+ self,
+ backup_id: str,
+ **kwargs: Any,
+ ) -> AgentBackup | None:
+ """Return a backup."""
+
+
+class LocalBackupAgent(BackupAgent):
+ """Local backup agent."""
+
+ @abc.abstractmethod
+ def get_backup_path(self, backup_id: str) -> Path:
+ """Return the local path to a backup.
+
+ The method should return the path to the backup file with the specified id.
+ """
+
+
+class BackupAgentPlatformProtocol(Protocol):
+ """Define the format of backup platforms which implement backup agents."""
+
+ async def async_get_backup_agents(
+ self,
+ hass: HomeAssistant,
+ **kwargs: Any,
+ ) -> list[BackupAgent]:
+ """Return a list of backup agents."""
+
+ @callback
+ def async_register_backup_agents_listener(
+ self,
+ 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.
+ """
diff --git a/homeassistant/components/backup/backup.py b/homeassistant/components/backup/backup.py
new file mode 100644
index 00000000000..ef4924161c2
--- /dev/null
+++ b/homeassistant/components/backup/backup.py
@@ -0,0 +1,125 @@
+"""Local backup support for Core and Container installations."""
+
+from __future__ import annotations
+
+from collections.abc import AsyncIterator, Callable, Coroutine
+import json
+from pathlib import Path
+from tarfile import TarError
+from typing import Any
+
+from homeassistant.core import HomeAssistant
+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
+
+
+async def async_get_backup_agents(
+ hass: HomeAssistant,
+ **kwargs: Any,
+) -> list[BackupAgent]:
+ """Return the local backup agent."""
+ if is_hassio(hass):
+ return []
+ return [CoreLocalBackupAgent(hass)]
+
+
+class CoreLocalBackupAgent(LocalBackupAgent):
+ """Local backup agent for Core and Container installations."""
+
+ domain = DOMAIN
+ name = "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._loaded_backups = False
+
+ async def _load_backups(self) -> None:
+ """Load data of stored backup files."""
+ backups = await self._hass.async_add_executor_job(self._read_backups)
+ LOGGER.debug("Loaded %s local backups", len(backups))
+ self._backups = backups
+ self._loaded_backups = True
+
+ def _read_backups(self) -> dict[str, AgentBackup]:
+ """Read backups from disk."""
+ backups: dict[str, AgentBackup] = {}
+ for backup_path in self._backup_dir.glob("*.tar"):
+ try:
+ backup = read_backup(backup_path)
+ backups[backup.backup_id] = backup
+ except (OSError, TarError, json.JSONDecodeError, KeyError) as err:
+ LOGGER.warning("Unable to read backup %s: %s", backup_path, err)
+ return backups
+
+ async def async_download_backup(
+ self,
+ backup_id: str,
+ **kwargs: Any,
+ ) -> AsyncIterator[bytes]:
+ """Download a backup file."""
+ raise NotImplementedError
+
+ async def async_upload_backup(
+ self,
+ *,
+ open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]],
+ backup: AgentBackup,
+ **kwargs: Any,
+ ) -> None:
+ """Upload a backup."""
+ self._backups[backup.backup_id] = 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())
+
+ async def async_get_backup(
+ self,
+ backup_id: str,
+ **kwargs: Any,
+ ) -> AgentBackup | None:
+ """Return a backup."""
+ if not self._loaded_backups:
+ await self._load_backups()
+
+ if not (backup := self._backups.get(backup_id)):
+ return None
+
+ backup_path = self.get_backup_path(backup_id)
+ if not await self._hass.async_add_executor_job(backup_path.exists):
+ LOGGER.debug(
+ (
+ "Removing tracked backup (%s) that does not exists on the expected"
+ " path %s"
+ ),
+ backup.backup_id,
+ backup_path,
+ )
+ self._backups.pop(backup_id)
+ return None
+
+ 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"
+
+ 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
+
+ backup_path = self.get_backup_path(backup_id)
+ 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
new file mode 100644
index 00000000000..3c5d5d39f7e
--- /dev/null
+++ b/homeassistant/components/backup/config.py
@@ -0,0 +1,479 @@
+"""Provide persistent configuration for the backup integration."""
+
+from __future__ import annotations
+
+import asyncio
+from collections.abc import Callable
+from dataclasses import dataclass, field, replace
+from datetime import datetime, timedelta
+from enum import StrEnum
+from typing import TYPE_CHECKING, Self, TypedDict
+
+from cronsim import CronSim
+
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers.event import async_call_later, async_track_point_in_time
+from homeassistant.helpers.typing import UNDEFINED, UndefinedType
+from homeassistant.util import dt as dt_util
+
+from .const import LOGGER
+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 * * {}"
+
+
+class StoredBackupConfig(TypedDict):
+ """Represent the stored backup config."""
+
+ create_backup: StoredCreateBackupConfig
+ last_attempted_automatic_backup: str | None
+ last_completed_automatic_backup: str | None
+ retention: StoredRetentionConfig
+ schedule: StoredBackupSchedule
+
+
+@dataclass(kw_only=True)
+class BackupConfigData:
+ """Represent loaded backup config data."""
+
+ create_backup: CreateBackupConfig
+ last_attempted_automatic_backup: datetime | None = None
+ last_completed_automatic_backup: datetime | None = None
+ retention: RetentionConfig
+ schedule: BackupSchedule
+
+ @classmethod
+ def from_dict(cls, data: StoredBackupConfig) -> Self:
+ """Initialize backup config data from a dict."""
+ include_folders_data = data["create_backup"]["include_folders"]
+ if include_folders_data:
+ include_folders = [Folder(folder) for folder in include_folders_data]
+ else:
+ include_folders = None
+ retention = data["retention"]
+
+ if last_attempted_str := data["last_attempted_automatic_backup"]:
+ last_attempted = dt_util.parse_datetime(last_attempted_str)
+ else:
+ last_attempted = None
+
+ if last_attempted_str := data["last_completed_automatic_backup"]:
+ last_completed = dt_util.parse_datetime(last_attempted_str)
+ else:
+ last_completed = None
+
+ return cls(
+ create_backup=CreateBackupConfig(
+ agent_ids=data["create_backup"]["agent_ids"],
+ include_addons=data["create_backup"]["include_addons"],
+ include_all_addons=data["create_backup"]["include_all_addons"],
+ include_database=data["create_backup"]["include_database"],
+ include_folders=include_folders,
+ name=data["create_backup"]["name"],
+ password=data["create_backup"]["password"],
+ ),
+ last_attempted_automatic_backup=last_attempted,
+ last_completed_automatic_backup=last_completed,
+ retention=RetentionConfig(
+ copies=retention["copies"],
+ days=retention["days"],
+ ),
+ schedule=BackupSchedule(state=ScheduleState(data["schedule"]["state"])),
+ )
+
+ def to_dict(self) -> StoredBackupConfig:
+ """Convert backup config data to a dict."""
+ if self.last_attempted_automatic_backup:
+ last_attempted = self.last_attempted_automatic_backup.isoformat()
+ else:
+ last_attempted = None
+
+ if self.last_completed_automatic_backup:
+ last_completed = self.last_completed_automatic_backup.isoformat()
+ else:
+ last_completed = None
+
+ return StoredBackupConfig(
+ create_backup=self.create_backup.to_dict(),
+ last_attempted_automatic_backup=last_attempted,
+ last_completed_automatic_backup=last_completed,
+ retention=self.retention.to_dict(),
+ schedule=self.schedule.to_dict(),
+ )
+
+
+class BackupConfig:
+ """Handle backup config."""
+
+ def __init__(self, hass: HomeAssistant, manager: BackupManager) -> None:
+ """Initialize backup config."""
+ self.data = BackupConfigData(
+ create_backup=CreateBackupConfig(),
+ retention=RetentionConfig(),
+ schedule=BackupSchedule(),
+ )
+ self._manager = manager
+
+ def load(self, stored_config: StoredBackupConfig) -> None:
+ """Load config."""
+ self.data = BackupConfigData.from_dict(stored_config)
+ self.data.retention.apply(self._manager)
+ self.data.schedule.apply(self._manager)
+
+ async def update(
+ self,
+ *,
+ create_backup: CreateBackupParametersDict | UndefinedType = UNDEFINED,
+ retention: RetentionParametersDict | UndefinedType = UNDEFINED,
+ schedule: ScheduleState | UndefinedType = UNDEFINED,
+ ) -> None:
+ """Update config."""
+ if create_backup is not UNDEFINED:
+ self.data.create_backup = replace(self.data.create_backup, **create_backup)
+ if retention is not UNDEFINED:
+ new_retention = RetentionConfig(**retention)
+ if new_retention != self.data.retention:
+ self.data.retention = new_retention
+ self.data.retention.apply(self._manager)
+ if schedule is not UNDEFINED:
+ new_schedule = BackupSchedule(state=schedule)
+ if new_schedule.to_dict() != self.data.schedule.to_dict():
+ self.data.schedule = new_schedule
+ self.data.schedule.apply(self._manager)
+
+ self._manager.store.save()
+
+
+@dataclass(kw_only=True)
+class RetentionConfig:
+ """Represent the backup retention configuration."""
+
+ copies: int | None = None
+ days: int | None = None
+
+ def apply(self, manager: BackupManager) -> None:
+ """Apply backup retention configuration."""
+ if self.days is not None:
+ LOGGER.debug(
+ "Scheduling next automatic delete of backups older than %s in 1 day",
+ self.days,
+ )
+ self._schedule_next(manager)
+ else:
+ LOGGER.debug("Unscheduling next automatic delete")
+ self._unschedule_next(manager)
+
+ def to_dict(self) -> StoredRetentionConfig:
+ """Convert backup retention configuration to a dict."""
+ return StoredRetentionConfig(
+ copies=self.copies,
+ days=self.days,
+ )
+
+ @callback
+ def _schedule_next(
+ self,
+ manager: BackupManager,
+ ) -> None:
+ """Schedule the next delete after days."""
+ self._unschedule_next(manager)
+
+ async def _delete_backups(now: datetime) -> None:
+ """Delete backups older than days."""
+ self._schedule_next(manager)
+
+ def _backups_filter(
+ backups: dict[str, ManagerBackup],
+ ) -> dict[str, ManagerBackup]:
+ """Return backups older than days to delete."""
+ # we need to check here since we await before
+ # this filter is applied
+ if self.days is None:
+ return {}
+ now = dt_util.utcnow()
+ return {
+ backup_id: backup
+ for backup_id, backup in backups.items()
+ if dt_util.parse_datetime(backup.date, raise_on_error=True)
+ + timedelta(days=self.days)
+ < now
+ }
+
+ await _delete_filtered_backups(manager, _backups_filter)
+
+ manager.remove_next_delete_event = async_call_later(
+ manager.hass, timedelta(days=1), _delete_backups
+ )
+
+ @callback
+ def _unschedule_next(self, manager: BackupManager) -> None:
+ """Unschedule the next delete after days."""
+ if (remove_next_event := manager.remove_next_delete_event) is not None:
+ remove_next_event()
+ manager.remove_next_delete_event = None
+
+
+class StoredRetentionConfig(TypedDict):
+ """Represent the stored backup retention configuration."""
+
+ copies: int | None
+ days: int | None
+
+
+class RetentionParametersDict(TypedDict, total=False):
+ """Represent the parameters for retention."""
+
+ copies: int | None
+ days: int | None
+
+
+class StoredBackupSchedule(TypedDict):
+ """Represent the stored backup schedule configuration."""
+
+ state: ScheduleState
+
+
+class ScheduleState(StrEnum):
+ """Represent the schedule state."""
+
+ NEVER = "never"
+ DAILY = "daily"
+ MONDAY = "mon"
+ TUESDAY = "tue"
+ WEDNESDAY = "wed"
+ THURSDAY = "thu"
+ FRIDAY = "fri"
+ SATURDAY = "sat"
+ SUNDAY = "sun"
+
+
+@dataclass(kw_only=True)
+class BackupSchedule:
+ """Represent the backup schedule."""
+
+ state: ScheduleState = ScheduleState.NEVER
+ cron_event: CronSim | None = field(init=False, default=None)
+
+ @callback
+ def apply(
+ self,
+ manager: BackupManager,
+ ) -> None:
+ """Apply a new schedule.
+
+ There are only three possible state types: never, daily, or weekly.
+ """
+ if self.state is ScheduleState.NEVER:
+ self._unschedule_next(manager)
+ return
+
+ if self.state is ScheduleState.DAILY:
+ self._schedule_next(CRON_PATTERN_DAILY, manager)
+ else:
+ self._schedule_next(
+ CRON_PATTERN_WEEKLY.format(self.state.value),
+ manager,
+ )
+
+ @callback
+ def _schedule_next(
+ self,
+ cron_pattern: str,
+ manager: BackupManager,
+ ) -> None:
+ """Schedule the next backup."""
+ self._unschedule_next(manager)
+ now = dt_util.now()
+ if (cron_event := self.cron_event) is None:
+ seed_time = manager.config.data.last_completed_automatic_backup or now
+ cron_event = self.cron_event = CronSim(cron_pattern, seed_time)
+ next_time = next(cron_event)
+
+ 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)
+ 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))
+
+ 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,
+ )
+ except BackupManagerError as err:
+ LOGGER.error("Error creating backup: %s", err)
+ except Exception: # noqa: BLE001
+ LOGGER.exception("Unexpected error creating automatic backup")
+
+ 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)
+
+ @callback
+ def _unschedule_next(self, manager: BackupManager) -> None:
+ """Unschedule the next backup."""
+ if (remove_next_event := manager.remove_next_backup_event) is not None:
+ remove_next_event()
+ manager.remove_next_backup_event = None
+
+
+@dataclass(kw_only=True)
+class CreateBackupConfig:
+ """Represent the config for async_create_backup."""
+
+ agent_ids: list[str] = field(default_factory=list)
+ include_addons: list[str] | None = None
+ include_all_addons: bool = False
+ include_database: bool = True
+ include_folders: list[Folder] | None = None
+ name: str | None = None
+ password: str | None = None
+
+ def to_dict(self) -> StoredCreateBackupConfig:
+ """Convert create backup config to a dict."""
+ return {
+ "agent_ids": self.agent_ids,
+ "include_addons": self.include_addons,
+ "include_all_addons": self.include_all_addons,
+ "include_database": self.include_database,
+ "include_folders": self.include_folders,
+ "name": self.name,
+ "password": self.password,
+ }
+
+
+class StoredCreateBackupConfig(TypedDict):
+ """Represent the stored config for async_create_backup."""
+
+ agent_ids: list[str]
+ include_addons: list[str] | None
+ include_all_addons: bool
+ include_database: bool
+ include_folders: list[Folder] | None
+ name: str | None
+ password: str | None
+
+
+class CreateBackupParametersDict(TypedDict, total=False):
+ """Represent the parameters for async_create_backup."""
+
+ agent_ids: list[str]
+ include_addons: list[str] | None
+ include_all_addons: bool
+ include_database: bool
+ include_folders: list[Folder] | None
+ name: str | None
+ 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 = {
+ 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(
+ backups: dict[str, ManagerBackup],
+ ) -> dict[str, ManagerBackup]:
+ """Return oldest backups more numerous than copies to delete."""
+ # we need to check here since we await before
+ # this filter is applied
+ if manager.config.data.retention.copies is None:
+ return {}
+ return dict(
+ sorted(
+ backups.items(),
+ key=lambda backup_item: backup_item[1].date,
+ )[: max(len(backups) - manager.config.data.retention.copies, 0)]
+ )
+
+ await _delete_filtered_backups(manager, _backups_filter)
diff --git a/homeassistant/components/backup/const.py b/homeassistant/components/backup/const.py
index f613f7cc352..c2070a37b2d 100644
--- a/homeassistant/components/backup/const.py
+++ b/homeassistant/components/backup/const.py
@@ -10,6 +10,7 @@ from homeassistant.util.hass_dict import HassKey
if TYPE_CHECKING:
from .manager import BackupManager
+BUF_SIZE = 2**20 * 4 # 4MB
DOMAIN = "backup"
DATA_MANAGER: HassKey[BackupManager] = HassKey(DOMAIN)
LOGGER = getLogger(__package__)
@@ -22,6 +23,12 @@ EXCLUDE_FROM_BACKUP = [
"*.log.*",
"*.log",
"backups/*.tar",
+ "tmp_backups/*.tar",
"OZW_Log.txt",
"tts/*",
]
+
+EXCLUDE_DATABASE_FROM_BACKUP = [
+ "home-assistant_v2.db",
+ "home-assistant_v2.db-wal",
+]
diff --git a/homeassistant/components/backup/http.py b/homeassistant/components/backup/http.py
index 4cc4e61c9e4..73a8c8eb602 100644
--- a/homeassistant/components/backup/http.py
+++ b/homeassistant/components/backup/http.py
@@ -2,49 +2,105 @@
from __future__ import annotations
+import asyncio
from http import HTTPStatus
+from typing import cast
+from aiohttp import BodyPartReader
from aiohttp.hdrs import CONTENT_DISPOSITION
-from aiohttp.web import FileResponse, Request, Response
+from aiohttp.web import FileResponse, Request, Response, StreamResponse
-from homeassistant.components.http import KEY_HASS, HomeAssistantView
+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 .const import DOMAIN
-from .manager import BaseBackupManager
+from .const import DATA_MANAGER
@callback
def async_register_http_views(hass: HomeAssistant) -> None:
"""Register the http views."""
hass.http.register_view(DownloadBackupView)
+ hass.http.register_view(UploadBackupView)
class DownloadBackupView(HomeAssistantView):
"""Generate backup view."""
- url = "/api/backup/download/{slug}"
+ url = "/api/backup/download/{backup_id}"
name = "api:backup:download"
async def get(
self,
request: Request,
- slug: str,
- ) -> FileResponse | Response:
+ backup_id: str,
+ ) -> StreamResponse | FileResponse | Response:
"""Download a backup file."""
if not request["hass_user"].is_admin:
return Response(status=HTTPStatus.UNAUTHORIZED)
+ try:
+ agent_id = request.query.getone("agent_id")
+ except KeyError:
+ return Response(status=HTTPStatus.BAD_REQUEST)
- manager: BaseBackupManager = request.app[KEY_HASS].data[DOMAIN]
- backup = await manager.async_get_backup(slug=slug)
+ manager = request.app[KEY_HASS].data[DATA_MANAGER]
+ if agent_id not in manager.backup_agents:
+ return Response(status=HTTPStatus.BAD_REQUEST)
+ agent = manager.backup_agents[agent_id]
+ backup = await agent.async_get_backup(backup_id)
- if backup is None or not backup.path.exists():
+ # We don't need to check if the path exists, aiohttp.FileResponse will handle
+ # that
+ if backup is None:
return Response(status=HTTPStatus.NOT_FOUND)
- return FileResponse(
- path=backup.path.as_posix(),
- headers={
- CONTENT_DISPOSITION: f"attachment; filename={slugify(backup.name)}.tar"
- },
- )
+ headers = {
+ CONTENT_DISPOSITION: f"attachment; filename={slugify(backup.name)}.tar"
+ }
+ if agent_id in manager.local_backup_agents:
+ local_agent = manager.local_backup_agents[agent_id]
+ path = local_agent.get_backup_path(backup_id)
+ return FileResponse(path=path.as_posix(), headers=headers)
+
+ stream = await agent.async_download_backup(backup_id)
+ response = StreamResponse(status=HTTPStatus.OK, headers=headers)
+ await response.prepare(request)
+ async for chunk in stream:
+ await response.write(chunk)
+ return response
+
+
+class UploadBackupView(HomeAssistantView):
+ """Generate backup view."""
+
+ url = "/api/backup/upload"
+ name = "api:backup:upload"
+
+ @require_admin
+ async def post(self, request: Request) -> Response:
+ """Upload a backup file."""
+ try:
+ agent_ids = request.query.getall("agent_id")
+ except KeyError:
+ return Response(status=HTTPStatus.BAD_REQUEST)
+ manager = request.app[KEY_HASS].data[DATA_MANAGER]
+ reader = await request.multipart()
+ contents = cast(BodyPartReader, await reader.next())
+
+ try:
+ 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}",
+ status=HTTPStatus.INTERNAL_SERVER_ERROR,
+ )
+ except HomeAssistantError as err:
+ return Response(
+ body=f"Can't upload backup file: {err}",
+ status=HTTPStatus.INTERNAL_SERVER_ERROR,
+ )
+ except asyncio.CancelledError:
+ return Response(status=HTTPStatus.INTERNAL_SERVER_ERROR)
+
+ return Response(status=HTTPStatus.CREATED)
diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py
index b3cb69861b9..ba1c457561f 100644
--- a/homeassistant/components/backup/manager.py
+++ b/homeassistant/components/backup/manager.py
@@ -4,45 +4,185 @@ from __future__ import annotations
import abc
import asyncio
-from dataclasses import asdict, dataclass
+from collections.abc import AsyncIterator, Callable, Coroutine
+from dataclasses import dataclass
+from enum import StrEnum
import hashlib
import io
import json
from pathlib import Path
+import shutil
import tarfile
-from tarfile import TarError
import time
-from typing import Any, Protocol, cast
+from typing import TYPE_CHECKING, Any, Protocol, TypedDict
+import aiohttp
from securetar import SecureTarFile, atomic_contents_add
-from homeassistant.backup_restore import RESTORE_BACKUP_FILE
+from homeassistant.backup_restore import RESTORE_BACKUP_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 integration_platform
+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.json import json_loads_object
-from .const import DOMAIN, EXCLUDE_FROM_BACKUP, LOGGER
-
-BUF_SIZE = 2**20 * 4 # 4MB
+from .agent import (
+ BackupAgent,
+ BackupAgentError,
+ BackupAgentPlatformProtocol,
+ LocalBackupAgent,
+)
+from .config import BackupConfig, delete_backups_exceeding_configured_count
+from .const import (
+ BUF_SIZE,
+ DATA_MANAGER,
+ DOMAIN,
+ EXCLUDE_DATABASE_FROM_BACKUP,
+ EXCLUDE_FROM_BACKUP,
+ LOGGER,
+)
+from .models import AgentBackup, BackupManagerError, Folder
+from .store import BackupStore
+from .util import make_backup_dir, read_backup, validate_password
-@dataclass(slots=True)
-class Backup:
+@dataclass(frozen=True, kw_only=True, slots=True)
+class NewBackup:
+ """New backup class."""
+
+ backup_job_id: str
+
+
+@dataclass(frozen=True, kw_only=True, slots=True)
+class ManagerBackup(AgentBackup):
"""Backup class."""
- slug: str
- name: str
- date: str
- path: Path
- size: float
+ agent_ids: list[str]
+ failed_agent_ids: list[str]
+ with_automatic_settings: bool | None
- def as_dict(self) -> dict:
- """Return a dict representation of this backup."""
- return {**asdict(self), "path": self.path.as_posix()}
+
+@dataclass(frozen=True, kw_only=True, slots=True)
+class WrittenBackup:
+ """Written backup class."""
+
+ backup: AgentBackup
+ open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]]
+ release_stream: Callable[[], Coroutine[Any, Any, None]]
+
+
+class BackupManagerState(StrEnum):
+ """Backup state type."""
+
+ IDLE = "idle"
+ CREATE_BACKUP = "create_backup"
+ RECEIVE_BACKUP = "receive_backup"
+ RESTORE_BACKUP = "restore_backup"
+
+
+class CreateBackupStage(StrEnum):
+ """Create backup stage enum."""
+
+ ADDON_REPOSITORIES = "addon_repositories"
+ ADDONS = "addons"
+ AWAIT_ADDON_RESTARTS = "await_addon_restarts"
+ DOCKER_CONFIG = "docker_config"
+ FINISHING_FILE = "finishing_file"
+ FOLDERS = "folders"
+ HOME_ASSISTANT = "home_assistant"
+ UPLOAD_TO_AGENTS = "upload_to_agents"
+
+
+class CreateBackupState(StrEnum):
+ """Create backup state enum."""
+
+ COMPLETED = "completed"
+ FAILED = "failed"
+ IN_PROGRESS = "in_progress"
+
+
+class ReceiveBackupStage(StrEnum):
+ """Receive backup stage enum."""
+
+ RECEIVE_FILE = "receive_file"
+ UPLOAD_TO_AGENTS = "upload_to_agents"
+
+
+class ReceiveBackupState(StrEnum):
+ """Receive backup state enum."""
+
+ COMPLETED = "completed"
+ FAILED = "failed"
+ IN_PROGRESS = "in_progress"
+
+
+class RestoreBackupStage(StrEnum):
+ """Restore backup stage enum."""
+
+ ADDON_REPOSITORIES = "addon_repositories"
+ ADDONS = "addons"
+ AWAIT_ADDON_RESTARTS = "await_addon_restarts"
+ AWAIT_HOME_ASSISTANT_RESTART = "await_home_assistant_restart"
+ CHECK_HOME_ASSISTANT = "check_home_assistant"
+ DOCKER_CONFIG = "docker_config"
+ DOWNLOAD_FROM_AGENT = "download_from_agent"
+ FOLDERS = "folders"
+ HOME_ASSISTANT = "home_assistant"
+ REMOVE_DELTA_ADDONS = "remove_delta_addons"
+
+
+class RestoreBackupState(StrEnum):
+ """Receive backup state enum."""
+
+ COMPLETED = "completed"
+ FAILED = "failed"
+ IN_PROGRESS = "in_progress"
+
+
+@dataclass(frozen=True, kw_only=True, slots=True)
+class ManagerStateEvent:
+ """Backup state class."""
+
+ manager_state: BackupManagerState
+
+
+@dataclass(frozen=True, kw_only=True, slots=True)
+class IdleEvent(ManagerStateEvent):
+ """Backup manager idle."""
+
+ manager_state: BackupManagerState = BackupManagerState.IDLE
+
+
+@dataclass(frozen=True, kw_only=True, slots=True)
+class CreateBackupEvent(ManagerStateEvent):
+ """Backup in progress."""
+
+ manager_state: BackupManagerState = BackupManagerState.CREATE_BACKUP
+ stage: CreateBackupStage | None
+ state: CreateBackupState
+
+
+@dataclass(frozen=True, kw_only=True, slots=True)
+class ReceiveBackupEvent(ManagerStateEvent):
+ """Backup receive."""
+
+ manager_state: BackupManagerState = BackupManagerState.RECEIVE_BACKUP
+ stage: ReceiveBackupStage | None
+ state: ReceiveBackupState
+
+
+@dataclass(frozen=True, kw_only=True, slots=True)
+class RestoreBackupEvent(ManagerStateEvent):
+ """Backup restore."""
+
+ manager_state: BackupManagerState = BackupManagerState.RESTORE_BACKUP
+ stage: RestoreBackupStage | None
+ state: RestoreBackupState
class BackupPlatformProtocol(Protocol):
@@ -55,40 +195,179 @@ class BackupPlatformProtocol(Protocol):
"""Perform operations after a backup finishes."""
-class BaseBackupManager(abc.ABC):
+class BackupReaderWriter(abc.ABC):
+ """Abstract class for reading and writing backups."""
+
+ @abc.abstractmethod
+ async def async_create_backup(
+ self,
+ *,
+ agent_ids: list[str],
+ backup_name: str,
+ extra_metadata: dict[str, bool | str],
+ include_addons: list[str] | None,
+ include_all_addons: bool,
+ include_database: bool,
+ include_folders: list[Folder] | None,
+ include_homeassistant: bool,
+ on_progress: Callable[[ManagerStateEvent], None],
+ password: str | None,
+ ) -> tuple[NewBackup, asyncio.Task[WrittenBackup]]:
+ """Create a backup."""
+
+ @abc.abstractmethod
+ async def async_receive_backup(
+ self,
+ *,
+ agent_ids: list[str],
+ stream: AsyncIterator[bytes],
+ suggested_filename: str,
+ ) -> WrittenBackup:
+ """Receive a backup."""
+
+ @abc.abstractmethod
+ async def async_restore_backup(
+ self,
+ backup_id: str,
+ *,
+ agent_id: str,
+ open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]],
+ password: str | None,
+ restore_addons: list[str] | None,
+ restore_database: bool,
+ restore_folders: list[Folder] | None,
+ restore_homeassistant: bool,
+ ) -> None:
+ """Restore a backup."""
+
+
+class BackupReaderWriterError(HomeAssistantError):
+ """Backup reader/writer error."""
+
+
+class IncorrectPasswordError(BackupReaderWriterError):
+ """Raised when the password is incorrect."""
+
+
+class BackupManager:
"""Define the format that backup managers can have."""
- def __init__(self, hass: HomeAssistant) -> None:
+ def __init__(self, hass: HomeAssistant, reader_writer: BackupReaderWriter) -> None:
"""Initialize the backup manager."""
self.hass = hass
- self.backing_up = False
- self.backups: dict[str, Backup] = {}
- self.loaded_platforms = False
self.platforms: dict[str, BackupPlatformProtocol] = {}
+ self.backup_agent_platforms: dict[str, BackupAgentPlatformProtocol] = {}
+ self.backup_agents: dict[str, BackupAgent] = {}
+ self.local_backup_agents: dict[str, LocalBackupAgent] = {}
+
+ self.config = BackupConfig(hass, self)
+ self._reader_writer = reader_writer
+ self.known_backups = KnownBackups(self)
+ self.store = BackupStore(hass, self)
+
+ # Tasks and flags tracking backup and restore progress
+ self._backup_task: asyncio.Task[WrittenBackup] | None = None
+ self._backup_finish_task: asyncio.Task[None] | None = None
+
+ # Backup schedule and retention listeners
+ self.remove_next_backup_event: Callable[[], None] | None = None
+ self.remove_next_delete_event: Callable[[], None] | None = None
+
+ # Latest backup event and backup event subscribers
+ self.last_event: ManagerStateEvent = IdleEvent()
+ self._backup_event_subscriptions: list[Callable[[ManagerStateEvent], None]] = []
+
+ async def async_setup(self) -> None:
+ """Set up the backup manager."""
+ stored = await self.store.load()
+ if stored:
+ self.config.load(stored["config"])
+ self.known_backups.load(stored["backups"])
+
+ await self.load_platforms()
+
+ @property
+ def state(self) -> BackupManagerState:
+ """Return the state of the backup manager."""
+ return self.last_event.manager_state
@callback
- def _add_platform(
+ def _add_platform_pre_post_handler(
self,
- hass: HomeAssistant,
integration_domain: str,
platform: BackupPlatformProtocol,
) -> None:
- """Add a platform to the backup manager."""
+ """Add a backup platform."""
if not hasattr(platform, "async_pre_backup") or not hasattr(
platform, "async_post_backup"
):
- LOGGER.warning(
- "%s does not implement required functions for the backup platform",
- integration_domain,
- )
return
+
self.platforms[integration_domain] = platform
- async def async_pre_backup_actions(self, **kwargs: Any) -> None:
- """Perform pre backup actions."""
- if not self.loaded_platforms:
- await self.load_platforms()
+ @callback
+ def _async_add_backup_agent_platform(
+ self,
+ integration_domain: str,
+ platform: BackupAgentPlatformProtocol,
+ ) -> None:
+ """Add backup agent platform to the backup manager."""
+ if not hasattr(platform, "async_get_backup_agents"):
+ return
+ self.backup_agent_platforms[integration_domain] = platform
+
+ @callback
+ def listener() -> None:
+ LOGGER.debug("Loading backup agents for %s", integration_domain)
+ self.hass.async_create_task(
+ self._async_reload_backup_agents(integration_domain)
+ )
+
+ if hasattr(platform, "async_register_backup_agents_listener"):
+ platform.async_register_backup_agents_listener(self.hass, listener=listener)
+
+ listener()
+
+ async def _async_reload_backup_agents(self, domain: str) -> None:
+ """Add backup agent platform to the backup manager."""
+ platform = self.backup_agent_platforms[domain]
+
+ # Remove all agents for the domain
+ for agent_id in list(self.backup_agents):
+ if self.backup_agents[agent_id].domain == domain:
+ del self.backup_agents[agent_id]
+ for agent_id in list(self.local_backup_agents):
+ if self.local_backup_agents[agent_id].domain == domain:
+ del self.local_backup_agents[agent_id]
+
+ # Add new agents
+ agents = await platform.async_get_backup_agents(self.hass)
+ self.backup_agents.update({agent.agent_id: agent for agent in agents})
+ self.local_backup_agents.update(
+ {
+ agent.agent_id: agent
+ for agent in agents
+ if isinstance(agent, LocalBackupAgent)
+ }
+ )
+
+ async def _add_platform(
+ self,
+ hass: HomeAssistant,
+ integration_domain: str,
+ platform: Any,
+ ) -> None:
+ """Add a backup platform manager."""
+ self._add_platform_pre_post_handler(integration_domain, platform)
+ self._async_add_backup_agent_platform(integration_domain, platform)
+ LOGGER.debug("Backup platform %s loaded", integration_domain)
+ LOGGER.debug("%s platforms loaded in total", len(self.platforms))
+ LOGGER.debug("%s agents loaded in total", len(self.backup_agents))
+ LOGGER.debug("%s local agents loaded in total", len(self.local_backup_agents))
+
+ async def async_pre_backup_actions(self) -> None:
+ """Perform pre backup actions."""
pre_backup_results = await asyncio.gather(
*(
platform.async_pre_backup(self.hass)
@@ -98,13 +377,12 @@ class BaseBackupManager(abc.ABC):
)
for result in pre_backup_results:
if isinstance(result, Exception):
- raise result
+ raise BackupManagerError(
+ f"Error during pre-backup: {result}"
+ ) from result
- async def async_post_backup_actions(self, **kwargs: Any) -> None:
+ async def async_post_backup_actions(self) -> None:
"""Perform post backup actions."""
- if not self.loaded_platforms:
- await self.load_platforms()
-
post_backup_results = await asyncio.gather(
*(
platform.async_post_backup(self.hass)
@@ -114,165 +392,844 @@ class BaseBackupManager(abc.ABC):
)
for result in post_backup_results:
if isinstance(result, Exception):
- raise result
+ raise BackupManagerError(
+ f"Error during post-backup: {result}"
+ ) from result
async def load_platforms(self) -> None:
"""Load backup platforms."""
await integration_platform.async_process_integration_platforms(
- self.hass, DOMAIN, self._add_platform, wait_for_platforms=True
+ self.hass,
+ DOMAIN,
+ self._add_platform,
+ wait_for_platforms=True,
)
LOGGER.debug("Loaded %s platforms", len(self.platforms))
- self.loaded_platforms = True
+ LOGGER.debug("Loaded %s agents", len(self.backup_agents))
- @abc.abstractmethod
- async def async_restore_backup(self, slug: str, **kwargs: Any) -> None:
- """Restpre a backup."""
+ async def _async_upload_backup(
+ self,
+ *,
+ backup: AgentBackup,
+ agent_ids: list[str],
+ open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]],
+ ) -> dict[str, Exception]:
+ """Upload a backup to selected agents."""
+ agent_errors: dict[str, Exception] = {}
- @abc.abstractmethod
- async def async_create_backup(self, **kwargs: Any) -> Backup:
- """Generate a backup."""
+ LOGGER.debug("Uploading backup %s to agents %s", backup.backup_id, agent_ids)
- @abc.abstractmethod
- async def async_get_backups(self, **kwargs: Any) -> dict[str, Backup]:
+ sync_backup_results = await asyncio.gather(
+ *(
+ self.backup_agents[agent_id].async_upload_backup(
+ open_stream=open_stream,
+ backup=backup,
+ )
+ for agent_id in agent_ids
+ ),
+ return_exceptions=True,
+ )
+ for idx, result in enumerate(sync_backup_results):
+ 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
+ continue
+ if isinstance(result, Exception):
+ # trap bugs from agents
+ agent_errors[agent_ids[idx]] = result
+ LOGGER.error("Unexpected error: %s", result, exc_info=result)
+ continue
+ if isinstance(result, BaseException):
+ raise result
+
+ return agent_errors
+
+ async def async_get_backups(
+ self,
+ ) -> tuple[dict[str, ManagerBackup], dict[str, Exception]]:
"""Get backups.
- Return a dictionary of Backup instances keyed by their slug.
+ Return a dictionary of Backup instances keyed by their ID.
"""
+ backups: dict[str, ManagerBackup] = {}
+ agent_errors: dict[str, Exception] = {}
+ agent_ids = list(self.backup_agents)
- @abc.abstractmethod
- async def async_get_backup(self, *, slug: str, **kwargs: Any) -> Backup | None:
+ list_backups_results = await asyncio.gather(
+ *(agent.async_list_backups() for agent in self.backup_agents.values()),
+ return_exceptions=True,
+ )
+ for idx, result in enumerate(list_backups_results):
+ if isinstance(result, BackupAgentError):
+ agent_errors[agent_ids[idx]] = result
+ continue
+ if isinstance(result, BaseException):
+ raise result # unexpected error
+ for agent_backup in result:
+ if (backup_id := agent_backup.backup_id) not in backups:
+ if known_backup := self.known_backups.get(backup_id):
+ failed_agent_ids = known_backup.failed_agent_ids
+ else:
+ failed_agent_ids = []
+ with_automatic_settings = self.is_our_automatic_backup(
+ agent_backup, await instance_id.async_get(self.hass)
+ )
+ backups[backup_id] = ManagerBackup(
+ agent_ids=[],
+ addons=agent_backup.addons,
+ backup_id=backup_id,
+ date=agent_backup.date,
+ database_included=agent_backup.database_included,
+ extra_metadata=agent_backup.extra_metadata,
+ failed_agent_ids=failed_agent_ids,
+ folders=agent_backup.folders,
+ 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])
+
+ return (backups, agent_errors)
+
+ async def async_get_backup(
+ self, backup_id: str
+ ) -> tuple[ManagerBackup | None, dict[str, Exception]]:
"""Get a backup."""
+ backup: ManagerBackup | None = None
+ agent_errors: dict[str, Exception] = {}
+ agent_ids = list(self.backup_agents)
- @abc.abstractmethod
- async def async_remove_backup(self, *, slug: str, **kwargs: Any) -> None:
- """Remove a backup."""
+ get_backup_results = await asyncio.gather(
+ *(
+ agent.async_get_backup(backup_id)
+ for agent in self.backup_agents.values()
+ ),
+ return_exceptions=True,
+ )
+ for idx, result in enumerate(get_backup_results):
+ if isinstance(result, BackupAgentError):
+ agent_errors[agent_ids[idx]] = result
+ continue
+ if isinstance(result, BaseException):
+ raise result # unexpected error
+ if not result:
+ continue
+ if backup is None:
+ if known_backup := self.known_backups.get(backup_id):
+ failed_agent_ids = known_backup.failed_agent_ids
+ else:
+ failed_agent_ids = []
+ with_automatic_settings = self.is_our_automatic_backup(
+ result, await instance_id.async_get(self.hass)
+ )
+ backup = ManagerBackup(
+ agent_ids=[],
+ addons=result.addons,
+ backup_id=result.backup_id,
+ date=result.date,
+ database_included=result.database_included,
+ extra_metadata=result.extra_metadata,
+ failed_agent_ids=failed_agent_ids,
+ folders=result.folders,
+ 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])
+ return (backup, agent_errors)
-class BackupManager(BaseBackupManager):
- """Backup manager for the Backup integration."""
+ @staticmethod
+ def is_our_automatic_backup(
+ backup: AgentBackup, our_instance_id: str
+ ) -> bool | None:
+ """Check if a backup was created by us and return automatic_settings flag.
- def __init__(self, hass: HomeAssistant) -> None:
- """Initialize the backup manager."""
- super().__init__(hass=hass)
- self.backup_dir = Path(hass.config.path("backups"))
- self.loaded_backups = False
-
- async def load_backups(self) -> None:
- """Load data of stored backup files."""
- backups = await self.hass.async_add_executor_job(self._read_backups)
- LOGGER.debug("Loaded %s backups", len(backups))
- self.backups = backups
- self.loaded_backups = True
-
- def _read_backups(self) -> dict[str, Backup]:
- """Read backups from disk."""
- backups: dict[str, Backup] = {}
- for backup_path in self.backup_dir.glob("*.tar"):
- try:
- with tarfile.open(backup_path, "r:", bufsize=BUF_SIZE) as backup_file:
- if data_file := backup_file.extractfile("./backup.json"):
- data = json_loads_object(data_file.read())
- backup = Backup(
- slug=cast(str, data["slug"]),
- name=cast(str, data["name"]),
- date=cast(str, data["date"]),
- path=backup_path,
- size=round(backup_path.stat().st_size / 1_048_576, 2),
- )
- backups[backup.slug] = backup
- except (OSError, TarError, json.JSONDecodeError, KeyError) as err:
- LOGGER.warning("Unable to read backup %s: %s", backup_path, err)
- return backups
-
- async def async_get_backups(self, **kwargs: Any) -> dict[str, Backup]:
- """Return backups."""
- if not self.loaded_backups:
- await self.load_backups()
-
- return self.backups
-
- async def async_get_backup(self, *, slug: str, **kwargs: Any) -> Backup | None:
- """Return a backup."""
- if not self.loaded_backups:
- await self.load_backups()
-
- if not (backup := self.backups.get(slug)):
+ Returns `None` if the backup was not created by us, or if the
+ automatic_settings flag is not a boolean.
+ """
+ if backup.extra_metadata.get("instance_id") != our_instance_id:
return None
+ with_automatic_settings = backup.extra_metadata.get("with_automatic_settings")
+ if not isinstance(with_automatic_settings, bool):
+ return None
+ return with_automatic_settings
- if not backup.path.exists():
- LOGGER.debug(
- (
- "Removing tracked backup (%s) that does not exists on the expected"
- " path %s"
- ),
- backup.slug,
- backup.path,
+ async def async_delete_backup(self, backup_id: str) -> dict[str, Exception]:
+ """Delete a backup."""
+ agent_errors: dict[str, Exception] = {}
+ agent_ids = list(self.backup_agents)
+
+ delete_backup_results = await asyncio.gather(
+ *(
+ agent.async_delete_backup(backup_id)
+ for agent in self.backup_agents.values()
+ ),
+ return_exceptions=True,
+ )
+ for idx, result in enumerate(delete_backup_results):
+ if isinstance(result, BackupAgentError):
+ agent_errors[agent_ids[idx]] = result
+ continue
+ if isinstance(result, BaseException):
+ raise result # unexpected error
+
+ if not agent_errors:
+ self.known_backups.remove(backup_id)
+
+ return agent_errors
+
+ async def async_receive_backup(
+ self,
+ *,
+ agent_ids: list[str],
+ contents: aiohttp.BodyPartReader,
+ ) -> None:
+ """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)
+ )
+ try:
+ await self._async_receive_backup(agent_ids=agent_ids, contents=contents)
+ except Exception:
+ self.async_on_backup_event(
+ ReceiveBackupEvent(stage=None, state=ReceiveBackupState.FAILED)
)
- self.backups.pop(slug)
- return None
+ raise
+ else:
+ self.async_on_backup_event(
+ ReceiveBackupEvent(stage=None, state=ReceiveBackupState.COMPLETED)
+ )
+ finally:
+ self.async_on_backup_event(IdleEvent())
- return backup
+ async def _async_receive_backup(
+ self,
+ *,
+ agent_ids: list[str],
+ contents: aiohttp.BodyPartReader,
+ ) -> None:
+ """Receive and store a backup file from upload."""
+ contents.chunk_size = BUF_SIZE
+ self.async_on_backup_event(
+ ReceiveBackupEvent(
+ stage=ReceiveBackupStage.RECEIVE_FILE,
+ state=ReceiveBackupState.IN_PROGRESS,
+ )
+ )
+ written_backup = await self._reader_writer.async_receive_backup(
+ agent_ids=agent_ids,
+ stream=contents,
+ suggested_filename=contents.filename or "backup.tar",
+ )
+ self.async_on_backup_event(
+ ReceiveBackupEvent(
+ stage=ReceiveBackupStage.UPLOAD_TO_AGENTS,
+ state=ReceiveBackupState.IN_PROGRESS,
+ )
+ )
+ agent_errors = await self._async_upload_backup(
+ backup=written_backup.backup,
+ agent_ids=agent_ids,
+ open_stream=written_backup.open_stream,
+ )
+ await written_backup.release_stream()
+ self.known_backups.add(written_backup.backup, agent_errors)
- async def async_remove_backup(self, *, slug: str, **kwargs: Any) -> None:
- """Remove a backup."""
- if (backup := await self.async_get_backup(slug=slug)) is None:
- return
+ async def async_create_backup(
+ self,
+ *,
+ agent_ids: list[str],
+ include_addons: list[str] | None,
+ include_all_addons: bool,
+ include_database: bool,
+ include_folders: list[Folder] | None,
+ include_homeassistant: bool,
+ name: str | None,
+ password: str | None,
+ with_automatic_settings: bool = False,
+ ) -> NewBackup:
+ """Create a backup."""
+ new_backup = await self.async_initiate_backup(
+ agent_ids=agent_ids,
+ include_addons=include_addons,
+ include_all_addons=include_all_addons,
+ include_database=include_database,
+ include_folders=include_folders,
+ include_homeassistant=include_homeassistant,
+ name=name,
+ password=password,
+ raise_task_error=True,
+ with_automatic_settings=with_automatic_settings,
+ )
+ assert self._backup_finish_task
+ await self._backup_finish_task
+ return new_backup
- await self.hass.async_add_executor_job(backup.path.unlink, True)
- LOGGER.debug("Removed backup located at %s", backup.path)
- self.backups.pop(slug)
+ async def async_initiate_backup(
+ self,
+ *,
+ agent_ids: list[str],
+ include_addons: list[str] | None,
+ include_all_addons: bool,
+ include_database: bool,
+ include_folders: list[Folder] | None,
+ include_homeassistant: bool,
+ name: str | None,
+ password: str | None,
+ raise_task_error: bool = False,
+ with_automatic_settings: bool = False,
+ ) -> NewBackup:
+ """Initiate generating a backup."""
+ if self.state is not BackupManagerState.IDLE:
+ raise BackupManagerError(f"Backup manager busy: {self.state}")
- async def async_create_backup(self, **kwargs: Any) -> Backup:
- """Generate a backup."""
- if self.backing_up:
- raise HomeAssistantError("Backup already in progress")
+ if with_automatic_settings:
+ self.config.data.last_attempted_automatic_backup = dt_util.now()
+ self.store.save()
+
+ self.async_on_backup_event(
+ CreateBackupEvent(stage=None, state=CreateBackupState.IN_PROGRESS)
+ )
+ try:
+ return await self._async_create_backup(
+ agent_ids=agent_ids,
+ include_addons=include_addons,
+ include_all_addons=include_all_addons,
+ include_database=include_database,
+ include_folders=include_folders,
+ include_homeassistant=include_homeassistant,
+ name=name,
+ password=password,
+ raise_task_error=raise_task_error,
+ with_automatic_settings=with_automatic_settings,
+ )
+ except Exception:
+ self.async_on_backup_event(
+ CreateBackupEvent(stage=None, state=CreateBackupState.FAILED)
+ )
+ self.async_on_backup_event(IdleEvent())
+ if with_automatic_settings:
+ self._update_issue_backup_failed()
+ raise
+
+ async def _async_create_backup(
+ self,
+ *,
+ agent_ids: list[str],
+ include_addons: list[str] | None,
+ include_all_addons: bool,
+ include_database: bool,
+ include_folders: list[Folder] | None,
+ include_homeassistant: bool,
+ name: str | None,
+ password: str | None,
+ raise_task_error: bool,
+ 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 := [
+ 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 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}"
+ )
try:
- self.backing_up = True
- await self.async_pre_backup_actions()
- backup_name = f"Core {HAVERSION}"
- date_str = dt_util.now().isoformat()
- slug = _generate_slug(date_str, backup_name)
+ (
+ new_backup,
+ self._backup_task,
+ ) = await self._reader_writer.async_create_backup(
+ agent_ids=agent_ids,
+ backup_name=backup_name,
+ extra_metadata={
+ "instance_id": await instance_id.async_get(self.hass),
+ "with_automatic_settings": with_automatic_settings,
+ },
+ include_addons=include_addons,
+ include_all_addons=include_all_addons,
+ include_database=include_database,
+ include_folders=include_folders,
+ include_homeassistant=include_homeassistant,
+ on_progress=self.async_on_backup_event,
+ password=password,
+ )
+ except BackupReaderWriterError as err:
+ 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),
+ name="backup_manager_finish_backup",
+ )
+ if not raise_task_error:
+
+ def log_finish_task_error(task: asyncio.Task[None]) -> None:
+ if task.done() and not task.cancelled() and (err := task.exception()):
+ if isinstance(err, BackupManagerError):
+ LOGGER.error("Error creating backup: %s", err)
+ else:
+ LOGGER.error("Unexpected error: %s", err, exc_info=err)
+
+ backup_finish_task.add_done_callback(log_finish_task_error)
+
+ return new_backup
+
+ async def _async_finish_backup(
+ self, agent_ids: list[str], with_automatic_settings: bool
+ ) -> None:
+ """Finish a backup."""
+ if TYPE_CHECKING:
+ assert self._backup_task is not None
+ backup_success = False
+ try:
+ written_backup = await self._backup_task
+ except Exception as err:
+ if with_automatic_settings:
+ self._update_issue_backup_failed()
+
+ if isinstance(err, BackupReaderWriterError):
+ raise BackupManagerError(str(err)) from err
+ raise # unexpected error
+ else:
+ LOGGER.debug(
+ "Generated new backup with backup_id %s, uploading to agents %s",
+ written_backup.backup.backup_id,
+ agent_ids,
+ )
+ self.async_on_backup_event(
+ CreateBackupEvent(
+ stage=CreateBackupStage.UPLOAD_TO_AGENTS,
+ state=CreateBackupState.IN_PROGRESS,
+ )
+ )
+
+ try:
+ agent_errors = await self._async_upload_backup(
+ backup=written_backup.backup,
+ agent_ids=agent_ids,
+ open_stream=written_backup.open_stream,
+ )
+ finally:
+ await written_backup.release_stream()
+ self.known_backups.add(written_backup.backup, agent_errors)
+ if not agent_errors:
+ if with_automatic_settings:
+ # create backup was successful, update last_completed_automatic_backup
+ self.config.data.last_completed_automatic_backup = dt_util.now()
+ self.store.save()
+ backup_success = True
+
+ if with_automatic_settings:
+ self._update_issue_after_agent_upload(agent_errors)
+ # delete old backups more numerous than copies
+ # try this regardless of agent errors above
+ await delete_backups_exceeding_configured_count(self)
+
+ 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,
+ )
+ )
+ self.async_on_backup_event(IdleEvent())
+
+ async def async_restore_backup(
+ self,
+ backup_id: str,
+ *,
+ agent_id: str,
+ password: str | None,
+ restore_addons: list[str] | None,
+ restore_database: bool,
+ restore_folders: list[Folder] | None,
+ restore_homeassistant: bool,
+ ) -> None:
+ """Initiate restoring a backup."""
+ if self.state is not BackupManagerState.IDLE:
+ raise BackupManagerError(f"Backup manager busy: {self.state}")
+
+ self.async_on_backup_event(
+ RestoreBackupEvent(stage=None, state=RestoreBackupState.IN_PROGRESS)
+ )
+ try:
+ await self._async_restore_backup(
+ backup_id=backup_id,
+ agent_id=agent_id,
+ password=password,
+ restore_addons=restore_addons,
+ restore_database=restore_database,
+ restore_folders=restore_folders,
+ restore_homeassistant=restore_homeassistant,
+ )
+ self.async_on_backup_event(
+ RestoreBackupEvent(stage=None, state=RestoreBackupState.COMPLETED)
+ )
+ except Exception:
+ self.async_on_backup_event(
+ RestoreBackupEvent(stage=None, state=RestoreBackupState.FAILED)
+ )
+ raise
+ finally:
+ self.async_on_backup_event(IdleEvent())
+
+ async def _async_restore_backup(
+ self,
+ backup_id: str,
+ *,
+ agent_id: str,
+ password: str | None,
+ restore_addons: list[str] | None,
+ restore_database: bool,
+ restore_folders: list[Folder] | None,
+ restore_homeassistant: bool,
+ ) -> None:
+ """Initiate restoring a backup."""
+ agent = self.backup_agents[agent_id]
+ if not await agent.async_get_backup(backup_id):
+ raise BackupManagerError(
+ f"Backup {backup_id} not found in agent {agent_id}"
+ )
+
+ async def open_backup() -> AsyncIterator[bytes]:
+ return await agent.async_download_backup(backup_id)
+
+ await self._reader_writer.async_restore_backup(
+ backup_id=backup_id,
+ open_stream=open_backup,
+ agent_id=agent_id,
+ password=password,
+ restore_addons=restore_addons,
+ restore_database=restore_database,
+ restore_folders=restore_folders,
+ restore_homeassistant=restore_homeassistant,
+ )
+
+ @callback
+ def async_on_backup_event(
+ self,
+ event: ManagerStateEvent,
+ ) -> None:
+ """Forward event to subscribers."""
+ if (current_state := self.state) != (new_state := event.manager_state):
+ LOGGER.debug("Backup state: %s -> %s", current_state, new_state)
+ self.last_event = event
+ for subscription in self._backup_event_subscriptions:
+ subscription(event)
+
+ @callback
+ def async_subscribe_events(
+ self,
+ on_event: Callable[[ManagerStateEvent], None],
+ ) -> Callable[[], None]:
+ """Subscribe events."""
+
+ def remove_subscription() -> None:
+ self._backup_event_subscriptions.remove(on_event)
+
+ self._backup_event_subscriptions.append(on_event)
+ return remove_subscription
+
+ def _update_issue_backup_failed(self) -> None:
+ """Update issue registry when a backup fails."""
+ ir.async_create_issue(
+ self.hass,
+ DOMAIN,
+ "automatic_backup_failed",
+ is_fixable=False,
+ is_persistent=True,
+ learn_more_url="homeassistant://config/backup",
+ severity=ir.IssueSeverity.WARNING,
+ translation_key="automatic_backup_failed_create",
+ )
+
+ def _update_issue_after_agent_upload(
+ self, agent_errors: dict[str, Exception]
+ ) -> None:
+ """Update issue registry after a backup is uploaded to agents."""
+ if not agent_errors:
+ ir.async_delete_issue(self.hass, DOMAIN, "automatic_backup_failed")
+ return
+ ir.async_create_issue(
+ self.hass,
+ DOMAIN,
+ "automatic_backup_failed",
+ is_fixable=False,
+ is_persistent=True,
+ learn_more_url="homeassistant://config/backup",
+ severity=ir.IssueSeverity.WARNING,
+ translation_key="automatic_backup_failed_upload_agents",
+ translation_placeholders={"failed_agents": ", ".join(agent_errors)},
+ )
+
+
+class KnownBackups:
+ """Track known backups."""
+
+ def __init__(self, manager: BackupManager) -> None:
+ """Initialize."""
+ self._backups: dict[str, KnownBackup] = {}
+ self._manager = manager
+
+ def load(self, stored_backups: list[StoredKnownBackup]) -> None:
+ """Load backups."""
+ self._backups = {
+ backup["backup_id"]: KnownBackup(
+ backup_id=backup["backup_id"],
+ failed_agent_ids=backup["failed_agent_ids"],
+ )
+ for backup in stored_backups
+ }
+
+ def to_list(self) -> list[StoredKnownBackup]:
+ """Convert known backups to a dict."""
+ return [backup.to_dict() for backup in self._backups.values()]
+
+ def add(
+ self,
+ backup: AgentBackup,
+ agent_errors: dict[str, Exception],
+ ) -> None:
+ """Add a backup."""
+ self._backups[backup.backup_id] = KnownBackup(
+ backup_id=backup.backup_id,
+ failed_agent_ids=list(agent_errors),
+ )
+ self._manager.store.save()
+
+ def get(self, backup_id: str) -> KnownBackup | None:
+ """Get a backup."""
+ return self._backups.get(backup_id)
+
+ def remove(self, backup_id: str) -> None:
+ """Remove a backup."""
+ if backup_id not in self._backups:
+ return
+ self._backups.pop(backup_id)
+ self._manager.store.save()
+
+
+@dataclass(kw_only=True)
+class KnownBackup:
+ """Persistent backup data."""
+
+ backup_id: str
+ failed_agent_ids: list[str]
+
+ def to_dict(self) -> StoredKnownBackup:
+ """Convert known backup to a dict."""
+ return {
+ "backup_id": self.backup_id,
+ "failed_agent_ids": self.failed_agent_ids,
+ }
+
+
+class StoredKnownBackup(TypedDict):
+ """Stored persistent backup data."""
+
+ backup_id: str
+ failed_agent_ids: list[str]
+
+
+class CoreBackupReaderWriter(BackupReaderWriter):
+ """Class for reading and writing backups in core and container installations."""
+
+ _local_agent_id = f"{DOMAIN}.local"
+
+ def __init__(self, hass: HomeAssistant) -> None:
+ """Initialize the backup reader/writer."""
+ self._hass = hass
+ self.temp_backup_dir = Path(hass.config.path("tmp_backups"))
+
+ async def async_create_backup(
+ self,
+ *,
+ agent_ids: list[str],
+ backup_name: str,
+ extra_metadata: dict[str, bool | str],
+ include_addons: list[str] | None,
+ include_all_addons: bool,
+ include_database: bool,
+ include_folders: list[Folder] | None,
+ include_homeassistant: bool,
+ on_progress: Callable[[ManagerStateEvent], None],
+ password: str | None,
+ ) -> tuple[NewBackup, asyncio.Task[WrittenBackup]]:
+ """Initiate generating a backup."""
+ date_str = dt_util.now().isoformat()
+ backup_id = _generate_backup_id(date_str, backup_name)
+
+ if include_addons or include_all_addons or include_folders:
+ raise BackupReaderWriterError(
+ "Addons and folders are not supported by core backup"
+ )
+ if not include_homeassistant:
+ raise BackupReaderWriterError("Home Assistant must be included in backup")
+
+ backup_task = self._hass.async_create_task(
+ self._async_create_backup(
+ agent_ids=agent_ids,
+ backup_id=backup_id,
+ backup_name=backup_name,
+ extra_metadata=extra_metadata,
+ include_database=include_database,
+ date_str=date_str,
+ on_progress=on_progress,
+ password=password,
+ ),
+ name="backup_manager_create_backup",
+ eager_start=False, # To ensure the task is not started before we return
+ )
+
+ return (NewBackup(backup_job_id=backup_id), backup_task)
+
+ async def _async_create_backup(
+ self,
+ *,
+ agent_ids: list[str],
+ backup_id: str,
+ backup_name: str,
+ date_str: str,
+ extra_metadata: dict[str, bool | str],
+ include_database: bool,
+ on_progress: Callable[[ManagerStateEvent], None],
+ password: str | None,
+ ) -> WrittenBackup:
+ """Generate a backup."""
+ manager = self._hass.data[DATA_MANAGER]
+
+ 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)
+
+ on_progress(
+ CreateBackupEvent(
+ stage=CreateBackupStage.HOME_ASSISTANT,
+ state=CreateBackupState.IN_PROGRESS,
+ )
+ )
+ try:
+ # Inform integrations a backup is about to be made
+ await manager.async_pre_backup_actions()
backup_data = {
- "slug": slug,
- "name": backup_name,
- "date": date_str,
- "type": "partial",
- "folders": ["homeassistant"],
- "homeassistant": {"version": HAVERSION},
"compressed": True,
+ "date": date_str,
+ "extra": extra_metadata,
+ "homeassistant": {
+ "exclude_database": not include_database,
+ "version": HAVERSION,
+ },
+ "name": backup_name,
+ "protected": password is not None,
+ "slug": backup_id,
+ "type": "partial",
+ "version": 2,
}
- tar_file_path = Path(self.backup_dir, f"{backup_data['slug']}.tar")
- size_in_bytes = await self.hass.async_add_executor_job(
+
+ tar_file_path, size_in_bytes = await self._hass.async_add_executor_job(
self._mkdir_and_generate_backup_contents,
- tar_file_path,
backup_data,
+ include_database,
+ password,
+ local_agent_tar_file_path,
)
- backup = Backup(
- slug=slug,
- name=backup_name,
+ except (BackupManagerError, OSError, tarfile.TarError, ValueError) as err:
+ # BackupManagerError from async_pre_backup_actions
+ # OSError from file operations
+ # TarError from tarfile
+ # 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,
- path=tar_file_path,
- size=round(size_in_bytes / 1_048_576, 2),
+ extra_metadata=extra_metadata,
+ folders=[],
+ homeassistant_included=True,
+ homeassistant_version=HAVERSION,
+ name=backup_name,
+ protected=password is not None,
+ size=size_in_bytes,
+ )
+
+ async_add_executor_job = self._hass.async_add_executor_job
+
+ async def send_backup() -> AsyncIterator[bytes]:
+ try:
+ f = await async_add_executor_job(tar_file_path.open, "rb")
+ try:
+ while chunk := await async_add_executor_job(f.read, 2**20):
+ yield chunk
+ finally:
+ await async_add_executor_job(f.close)
+ except OSError as err:
+ raise BackupReaderWriterError(str(err)) from err
+
+ async def open_backup() -> AsyncIterator[bytes]:
+ return send_backup()
+
+ async def remove_backup() -> None:
+ if local_agent_tar_file_path:
+ return
+ try:
+ await async_add_executor_job(tar_file_path.unlink, True)
+ except OSError as err:
+ raise BackupReaderWriterError(str(err)) from err
+
+ return WrittenBackup(
+ backup=backup, open_stream=open_backup, release_stream=remove_backup
)
- if self.loaded_backups:
- self.backups[slug] = backup
- LOGGER.debug("Generated new backup with slug %s", slug)
- return backup
finally:
- self.backing_up = False
- await self.async_post_backup_actions()
+ # Inform integrations the backup is done
+ try:
+ await manager.async_post_backup_actions()
+ except BackupManagerError as err:
+ raise BackupReaderWriterError(str(err)) from err
def _mkdir_and_generate_backup_contents(
self,
- tar_file_path: Path,
backup_data: dict[str, Any],
- ) -> int:
+ database_included: bool,
+ password: str | None,
+ tar_file_path: Path | None,
+ ) -> tuple[Path, int]:
"""Generate backup contents and return the size."""
- if not self.backup_dir.exists():
- LOGGER.debug("Creating backup directory")
- self.backup_dir.mkdir()
+ if not tar_file_path:
+ tar_file_path = self.temp_backup_dir / f"{backup_data['slug']}.tar"
+ make_backup_dir(tar_file_path.parent)
+
+ excludes = EXCLUDE_FROM_BACKUP
+ if not database_included:
+ excludes = excludes + EXCLUDE_DATABASE_FROM_BACKUP
outer_secure_tarfile = SecureTarFile(
tar_file_path, "w", gzip=False, bufsize=BUF_SIZE
@@ -285,37 +1242,143 @@ class BackupManager(BaseBackupManager):
tar_info.mtime = int(time.time())
outer_secure_tarfile_tarfile.addfile(tar_info, fileobj=fileobj)
with outer_secure_tarfile.create_inner_tar(
- "./homeassistant.tar.gz", gzip=True
+ "./homeassistant.tar.gz",
+ gzip=True,
+ key=password_to_key(password) if password is not None else None,
) as core_tar:
atomic_contents_add(
tar_file=core_tar,
- origin_path=Path(self.hass.config.path()),
- excludes=EXCLUDE_FROM_BACKUP,
+ origin_path=Path(self._hass.config.path()),
+ excludes=excludes,
arcname="data",
)
+ return (tar_file_path, tar_file_path.stat().st_size)
- return tar_file_path.stat().st_size
+ async def async_receive_backup(
+ self,
+ *,
+ agent_ids: list[str],
+ stream: AsyncIterator[bytes],
+ suggested_filename: str,
+ ) -> WrittenBackup:
+ """Receive a backup."""
+ temp_file = Path(self.temp_backup_dir, suggested_filename)
- async def async_restore_backup(self, slug: str, **kwargs: Any) -> None:
+ async_add_executor_job = self._hass.async_add_executor_job
+ await async_add_executor_job(make_backup_dir, self.temp_backup_dir)
+ f = await async_add_executor_job(temp_file.open, "wb")
+ try:
+ async for chunk in stream:
+ await async_add_executor_job(f.write, chunk)
+ finally:
+ await async_add_executor_job(f.close)
+
+ try:
+ backup = await async_add_executor_job(read_backup, temp_file)
+ except (OSError, tarfile.TarError, json.JSONDecodeError, KeyError) as err:
+ LOGGER.warning("Unable to parse backup %s: %s", temp_file, err)
+ raise
+
+ 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)
+ 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:
+ tar_file_path = temp_file
+
+ async def send_backup() -> AsyncIterator[bytes]:
+ f = await async_add_executor_job(tar_file_path.open, "rb")
+ try:
+ while chunk := await async_add_executor_job(f.read, 2**20):
+ yield chunk
+ finally:
+ await async_add_executor_job(f.close)
+
+ async def open_backup() -> AsyncIterator[bytes]:
+ return send_backup()
+
+ async def remove_backup() -> None:
+ if self._local_agent_id in agent_ids:
+ return
+ await async_add_executor_job(temp_file.unlink, True)
+
+ return WrittenBackup(
+ backup=backup, open_stream=open_backup, release_stream=remove_backup
+ )
+
+ async def async_restore_backup(
+ self,
+ backup_id: str,
+ open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]],
+ *,
+ agent_id: str,
+ password: str | None,
+ restore_addons: list[str] | None,
+ restore_database: bool,
+ restore_folders: list[Folder] | None,
+ restore_homeassistant: bool,
+ ) -> None:
"""Restore a backup.
This will write the restore information to .HA_RESTORE which
will be handled during startup by the restore_backup module.
"""
- if (backup := await self.async_get_backup(slug=slug)) is None:
- raise HomeAssistantError(f"Backup {slug} not found")
+
+ if restore_addons or restore_folders:
+ raise BackupReaderWriterError(
+ "Addons and folders are not supported in core restore"
+ )
+ if not restore_homeassistant and not restore_database:
+ raise BackupReaderWriterError(
+ "Home Assistant or database must be included in restore"
+ )
+
+ manager = self._hass.data[DATA_MANAGER]
+ if agent_id in manager.local_backup_agents:
+ local_agent = manager.local_backup_agents[agent_id]
+ path = local_agent.get_backup_path(backup_id)
+ remove_after_restore = False
+ else:
+ async_add_executor_job = self._hass.async_add_executor_job
+ path = self.temp_backup_dir / f"{backup_id}.tar"
+ stream = await open_stream()
+ await async_add_executor_job(make_backup_dir, self.temp_backup_dir)
+ f = await async_add_executor_job(path.open, "wb")
+ try:
+ async for chunk in stream:
+ await async_add_executor_job(f.write, chunk)
+ finally:
+ await async_add_executor_job(f.close)
+
+ remove_after_restore = True
+
+ password_valid = await self._hass.async_add_executor_job(
+ validate_password, path, password
+ )
+ if not password_valid:
+ raise IncorrectPasswordError("The password provided is incorrect.")
def _write_restore_file() -> None:
"""Write the restore file."""
- Path(self.hass.config.path(RESTORE_BACKUP_FILE)).write_text(
- json.dumps({"path": backup.path.as_posix()}),
+ Path(self._hass.config.path(RESTORE_BACKUP_FILE)).write_text(
+ json.dumps(
+ {
+ "path": path.as_posix(),
+ "password": password,
+ "remove_after_restore": remove_after_restore,
+ "restore_database": restore_database,
+ "restore_homeassistant": restore_homeassistant,
+ }
+ ),
encoding="utf-8",
)
- await self.hass.async_add_executor_job(_write_restore_file)
- await self.hass.services.async_call("homeassistant", "restart", {})
+ await self._hass.async_add_executor_job(_write_restore_file)
+ await self._hass.services.async_call("homeassistant", "restart", blocking=True)
-def _generate_slug(date: str, name: str) -> str:
- """Generate a backup slug."""
+def _generate_backup_id(date: str, name: str) -> str:
+ """Generate a backup ID."""
return hashlib.sha1(f"{date} - {name}".lower().encode()).hexdigest()[:8]
diff --git a/homeassistant/components/backup/manifest.json b/homeassistant/components/backup/manifest.json
index 1ec9b748cda..b399043e013 100644
--- a/homeassistant/components/backup/manifest.json
+++ b/homeassistant/components/backup/manifest.json
@@ -1,11 +1,12 @@
{
"domain": "backup",
"name": "Backup",
+ "after_dependencies": ["hassio"],
"codeowners": ["@home-assistant/core"],
"dependencies": ["http", "websocket_api"],
"documentation": "https://www.home-assistant.io/integrations/backup",
"integration_type": "system",
"iot_class": "calculated",
"quality_scale": "internal",
- "requirements": ["securetar==2024.2.1"]
+ "requirements": ["cronsim==2.6", "securetar==2024.11.0"]
}
diff --git a/homeassistant/components/backup/models.py b/homeassistant/components/backup/models.py
new file mode 100644
index 00000000000..81c00d699c6
--- /dev/null
+++ b/homeassistant/components/backup/models.py
@@ -0,0 +1,75 @@
+"""Models for the backup integration."""
+
+from __future__ import annotations
+
+from dataclasses import asdict, dataclass
+from enum import StrEnum
+from typing import Any, Self
+
+from homeassistant.exceptions import HomeAssistantError
+
+
+@dataclass(frozen=True, kw_only=True)
+class AddonInfo:
+ """Addon information."""
+
+ name: str
+ slug: str
+ version: str
+
+
+class Folder(StrEnum):
+ """Folder type."""
+
+ SHARE = "share"
+ ADDONS = "addons/local"
+ SSL = "ssl"
+ MEDIA = "media"
+
+
+@dataclass(frozen=True, kw_only=True)
+class AgentBackup:
+ """Base backup class."""
+
+ addons: list[AddonInfo]
+ backup_id: str
+ date: str
+ database_included: bool
+ extra_metadata: dict[str, bool | str]
+ folders: list[Folder]
+ homeassistant_included: bool
+ homeassistant_version: str | None # None if homeassistant_included is False
+ name: str
+ protected: bool
+ size: int
+
+ def as_dict(self) -> dict:
+ """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."""
+ return cls(
+ addons=[AddonInfo(**addon) for addon in data["addons"]],
+ backup_id=data["backup_id"],
+ date=data["date"],
+ database_included=data["database_included"],
+ extra_metadata=data["extra_metadata"],
+ folders=[Folder(folder) for folder in data["folders"]],
+ homeassistant_included=data["homeassistant_included"],
+ homeassistant_version=data["homeassistant_version"],
+ name=data["name"],
+ protected=data["protected"],
+ size=data["size"],
+ )
+
+
+class BackupManagerError(HomeAssistantError):
+ """Backup manager error."""
diff --git a/homeassistant/components/backup/store.py b/homeassistant/components/backup/store.py
new file mode 100644
index 00000000000..ddabead24f9
--- /dev/null
+++ b/homeassistant/components/backup/store.py
@@ -0,0 +1,52 @@
+"""Store backup configuration."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, TypedDict
+
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers.storage import Store
+
+from .const import DOMAIN
+
+if TYPE_CHECKING:
+ from .config import StoredBackupConfig
+ from .manager import BackupManager, StoredKnownBackup
+
+STORE_DELAY_SAVE = 30
+STORAGE_KEY = DOMAIN
+STORAGE_VERSION = 1
+
+
+class StoredBackupData(TypedDict):
+ """Represent the stored backup config."""
+
+ backups: list[StoredKnownBackup]
+ config: StoredBackupConfig
+
+
+class BackupStore:
+ """Store backup config."""
+
+ def __init__(self, hass: HomeAssistant, manager: BackupManager) -> None:
+ """Initialize the backup manager."""
+ self._hass = hass
+ self._manager = manager
+ self._store: Store[StoredBackupData] = Store(hass, STORAGE_VERSION, STORAGE_KEY)
+
+ async def load(self) -> StoredBackupData | None:
+ """Load the store."""
+ return await self._store.async_load()
+
+ @callback
+ def save(self) -> None:
+ """Save config."""
+ self._store.async_delay_save(self._data_to_save, STORE_DELAY_SAVE)
+
+ @callback
+ def _data_to_save(self) -> StoredBackupData:
+ """Return data to save."""
+ return {
+ "backups": self._manager.known_backups.to_list(),
+ "config": self._manager.config.data.to_dict(),
+ }
diff --git a/homeassistant/components/backup/strings.json b/homeassistant/components/backup/strings.json
index 6ad3416b1b9..43ae57cc781 100644
--- a/homeassistant/components/backup/strings.json
+++ b/homeassistant/components/backup/strings.json
@@ -1,4 +1,14 @@
{
+ "issues": {
+ "automatic_backup_failed_create": {
+ "title": "Automatic backup could not be created",
+ "description": "The automatic backup could not be created. Please check the logs for more information. Another attempt will be made at the next scheduled time if a backup schedule is configured."
+ },
+ "automatic_backup_failed_upload_agents": {
+ "title": "Automatic backup could not be uploaded to the configured locations",
+ "description": "The automatic backup could not be uploaded to the configured locations {failed_agents}. Please check the logs for more information. Another attempt will be made at the next scheduled time if a backup schedule is configured."
+ }
+ },
"services": {
"create": {
"name": "Create backup",
diff --git a/homeassistant/components/backup/util.py b/homeassistant/components/backup/util.py
new file mode 100644
index 00000000000..930625c52ca
--- /dev/null
+++ b/homeassistant/components/backup/util.py
@@ -0,0 +1,148 @@
+"""Local backup support for Core and Container installations."""
+
+from __future__ import annotations
+
+import asyncio
+from pathlib import Path
+from queue import SimpleQueue
+import tarfile
+from typing import cast
+
+import aiohttp
+from securetar import SecureTarFile
+
+from homeassistant.backup_restore import password_to_key
+from homeassistant.core import HomeAssistant
+from homeassistant.util.json import JsonObjectType, json_loads_object
+
+from .const import BUF_SIZE, LOGGER
+from .models import AddonInfo, AgentBackup, Folder
+
+
+def make_backup_dir(path: Path) -> None:
+ """Create a backup directory if it does not exist."""
+ path.mkdir(exist_ok=True)
+
+
+def read_backup(backup_path: Path) -> AgentBackup:
+ """Read a backup from disk."""
+
+ with tarfile.open(backup_path, "r:", bufsize=BUF_SIZE) as backup_file:
+ if not (data_file := backup_file.extractfile("./backup.json")):
+ raise KeyError("backup.json not found in tar file")
+ data = json_loads_object(data_file.read())
+ addons = [
+ AddonInfo(
+ name=cast(str, addon["name"]),
+ slug=cast(str, addon["slug"]),
+ version=cast(str, addon["version"]),
+ )
+ for addon in cast(list[JsonObjectType], data.get("addons", []))
+ ]
+
+ folders = [
+ Folder(folder)
+ for folder in cast(list[str], data.get("folders", []))
+ if folder != "homeassistant"
+ ]
+
+ homeassistant_included = False
+ homeassistant_version: str | None = None
+ database_included = False
+ if (
+ homeassistant := cast(JsonObjectType, data.get("homeassistant"))
+ ) and "version" in homeassistant:
+ homeassistant_included = True
+ homeassistant_version = cast(str, homeassistant["version"])
+ database_included = not cast(
+ bool, homeassistant.get("exclude_database", False)
+ )
+
+ return AgentBackup(
+ addons=addons,
+ backup_id=cast(str, data["slug"]),
+ database_included=database_included,
+ date=cast(str, data["date"]),
+ extra_metadata=cast(dict[str, bool | str], data.get("extra", {})),
+ folders=folders,
+ homeassistant_included=homeassistant_included,
+ homeassistant_version=homeassistant_version,
+ name=cast(str, data["name"]),
+ protected=cast(bool, data.get("protected", False)),
+ size=backup_path.stat().st_size,
+ )
+
+
+def validate_password(path: Path, password: str | None) -> bool:
+ """Validate the password."""
+ with tarfile.open(path, "r:", bufsize=BUF_SIZE) as backup_file:
+ compressed = False
+ ha_tar_name = "homeassistant.tar"
+ try:
+ ha_tar = backup_file.extractfile(ha_tar_name)
+ except KeyError:
+ compressed = True
+ ha_tar_name = "homeassistant.tar.gz"
+ try:
+ ha_tar = backup_file.extractfile(ha_tar_name)
+ except KeyError:
+ LOGGER.error("No homeassistant.tar or homeassistant.tar.gz found")
+ return False
+ try:
+ with SecureTarFile(
+ path, # Not used
+ gzip=compressed,
+ key=password_to_key(password) if password is not None else None,
+ mode="r",
+ fileobj=ha_tar,
+ ):
+ # If we can read the tar file, the password is correct
+ return True
+ except tarfile.ReadError:
+ LOGGER.debug("Invalid password")
+ return False
+ except Exception: # noqa: BLE001
+ LOGGER.exception("Unexpected error validating password")
+ return False
+
+
+async def receive_file(
+ hass: HomeAssistant, contents: aiohttp.BodyPartReader, path: Path
+) -> None:
+ """Receive a file from a stream and write it to a file."""
+ queue: SimpleQueue[tuple[bytes, asyncio.Future[None] | None] | None] = SimpleQueue()
+
+ def _sync_queue_consumer() -> None:
+ with path.open("wb") as file_handle:
+ while True:
+ if (_chunk_future := queue.get()) is None:
+ break
+ _chunk, _future = _chunk_future
+ if _future is not None:
+ hass.loop.call_soon_threadsafe(_future.set_result, None)
+ file_handle.write(_chunk)
+
+ fut: asyncio.Future[None] | None = None
+ try:
+ fut = hass.async_add_executor_job(_sync_queue_consumer)
+ megabytes_sending = 0
+ while chunk := await contents.read_chunk(BUF_SIZE):
+ megabytes_sending += 1
+ if megabytes_sending % 5 != 0:
+ queue.put_nowait((chunk, None))
+ continue
+
+ chunk_future = hass.loop.create_future()
+ queue.put_nowait((chunk, chunk_future))
+ await asyncio.wait(
+ (fut, chunk_future),
+ return_when=asyncio.FIRST_COMPLETED,
+ )
+ if fut.done():
+ # The executor job failed
+ break
+
+ queue.put_nowait(None) # terminate queue consumer
+ finally:
+ if fut is not None:
+ await fut
diff --git a/homeassistant/components/backup/websocket.py b/homeassistant/components/backup/websocket.py
index 3ac8a7ace3e..0139b7fdb77 100644
--- a/homeassistant/components/backup/websocket.py
+++ b/homeassistant/components/backup/websocket.py
@@ -7,22 +7,31 @@ import voluptuous as vol
from homeassistant.components import websocket_api
from homeassistant.core import HomeAssistant, callback
+from .config import ScheduleState
from .const import DATA_MANAGER, LOGGER
+from .manager import IncorrectPasswordError, ManagerStateEvent
+from .models import Folder
@callback
def async_register_websocket_handlers(hass: HomeAssistant, with_hassio: bool) -> None:
"""Register websocket commands."""
+ websocket_api.async_register_command(hass, backup_agents_info)
+
if with_hassio:
websocket_api.async_register_command(hass, handle_backup_end)
websocket_api.async_register_command(hass, handle_backup_start)
- return
websocket_api.async_register_command(hass, handle_details)
websocket_api.async_register_command(hass, handle_info)
websocket_api.async_register_command(hass, handle_create)
- websocket_api.async_register_command(hass, handle_remove)
+ websocket_api.async_register_command(hass, handle_create_with_automatic_settings)
+ websocket_api.async_register_command(hass, handle_delete)
websocket_api.async_register_command(hass, handle_restore)
+ websocket_api.async_register_command(hass, handle_subscribe_events)
+
+ websocket_api.async_register_command(hass, handle_config_info)
+ websocket_api.async_register_command(hass, handle_config_update)
@websocket_api.require_admin
@@ -35,12 +44,16 @@ async def handle_info(
) -> None:
"""List all stored backups."""
manager = hass.data[DATA_MANAGER]
- backups = await manager.async_get_backups()
+ backups, agent_errors = await manager.async_get_backups()
connection.send_result(
msg["id"],
{
- "backups": list(backups.values()),
- "backing_up": manager.backing_up,
+ "agent_errors": {
+ agent_id: str(err) for agent_id, err in agent_errors.items()
+ },
+ "backups": [backup.as_frontend_json() for backup in backups.values()],
+ "last_attempted_automatic_backup": manager.config.data.last_attempted_automatic_backup,
+ "last_completed_automatic_backup": manager.config.data.last_completed_automatic_backup,
},
)
@@ -49,7 +62,7 @@ async def handle_info(
@websocket_api.websocket_command(
{
vol.Required("type"): "backup/details",
- vol.Required("slug"): str,
+ vol.Required("backup_id"): str,
}
)
@websocket_api.async_response
@@ -58,12 +71,17 @@ async def handle_details(
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
- """Get backup details for a specific slug."""
- backup = await hass.data[DATA_MANAGER].async_get_backup(slug=msg["slug"])
+ """Get backup details for a specific backup."""
+ backup, agent_errors = await hass.data[DATA_MANAGER].async_get_backup(
+ msg["backup_id"]
+ )
connection.send_result(
msg["id"],
{
- "backup": backup,
+ "agent_errors": {
+ agent_id: str(err) for agent_id, err in agent_errors.items()
+ },
+ "backup": backup.as_frontend_json() if backup else None,
},
)
@@ -71,26 +89,39 @@ async def handle_details(
@websocket_api.require_admin
@websocket_api.websocket_command(
{
- vol.Required("type"): "backup/remove",
- vol.Required("slug"): str,
+ vol.Required("type"): "backup/delete",
+ vol.Required("backup_id"): str,
}
)
@websocket_api.async_response
-async def handle_remove(
+async def handle_delete(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
- """Remove a backup."""
- await hass.data[DATA_MANAGER].async_remove_backup(slug=msg["slug"])
- connection.send_result(msg["id"])
+ """Delete a backup."""
+ agent_errors = await hass.data[DATA_MANAGER].async_delete_backup(msg["backup_id"])
+ connection.send_result(
+ msg["id"],
+ {
+ "agent_errors": {
+ agent_id: str(err) for agent_id, err in agent_errors.items()
+ }
+ },
+ )
@websocket_api.require_admin
@websocket_api.websocket_command(
{
vol.Required("type"): "backup/restore",
- vol.Required("slug"): str,
+ 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)],
+ vol.Optional("restore_homeassistant", default=True): bool,
}
)
@websocket_api.async_response
@@ -100,12 +131,36 @@ async def handle_restore(
msg: dict[str, Any],
) -> None:
"""Restore a backup."""
- await hass.data[DATA_MANAGER].async_restore_backup(msg["slug"])
- connection.send_result(msg["id"])
+ try:
+ await hass.data[DATA_MANAGER].async_restore_backup(
+ msg["backup_id"],
+ agent_id=msg["agent_id"],
+ password=msg.get("password"),
+ restore_addons=msg.get("restore_addons"),
+ restore_database=msg["restore_database"],
+ restore_folders=msg.get("restore_folders"),
+ restore_homeassistant=msg["restore_homeassistant"],
+ )
+ 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/generate"})
+@websocket_api.websocket_command(
+ {
+ vol.Required("type"): "backup/generate",
+ vol.Required("agent_ids"): [str],
+ vol.Optional("include_addons"): [str],
+ vol.Optional("include_all_addons", default=False): bool,
+ 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,
+ }
+)
@websocket_api.async_response
async def handle_create(
hass: HomeAssistant,
@@ -113,7 +168,46 @@ async def handle_create(
msg: dict[str, Any],
) -> None:
"""Generate a backup."""
- backup = await hass.data[DATA_MANAGER].async_create_backup()
+
+ backup = await hass.data[DATA_MANAGER].async_initiate_backup(
+ agent_ids=msg["agent_ids"],
+ include_addons=msg.get("include_addons"),
+ include_all_addons=msg["include_all_addons"],
+ include_database=msg["include_database"],
+ include_folders=msg.get("include_folders"),
+ include_homeassistant=msg["include_homeassistant"],
+ name=msg.get("name"),
+ password=msg.get("password"),
+ )
+ connection.send_result(msg["id"], backup)
+
+
+@websocket_api.require_admin
+@websocket_api.websocket_command(
+ {
+ vol.Required("type"): "backup/generate_with_automatic_settings",
+ }
+)
+@websocket_api.async_response
+async def handle_create_with_automatic_settings(
+ hass: HomeAssistant,
+ connection: websocket_api.ActiveConnection,
+ msg: dict[str, Any],
+) -> None:
+ """Generate a backup with stored settings."""
+
+ config_data = hass.data[DATA_MANAGER].config.data
+ backup = await hass.data[DATA_MANAGER].async_initiate_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,
+ )
connection.send_result(msg["id"], backup)
@@ -127,7 +221,6 @@ async def handle_backup_start(
) -> None:
"""Backup start notification."""
manager = hass.data[DATA_MANAGER]
- manager.backing_up = True
LOGGER.debug("Backup start notification")
try:
@@ -149,7 +242,6 @@ async def handle_backup_end(
) -> None:
"""Backup end notification."""
manager = hass.data[DATA_MANAGER]
- manager.backing_up = False
LOGGER.debug("Backup end notification")
try:
@@ -159,3 +251,101 @@ async def handle_backup_end(
return
connection.send_result(msg["id"])
+
+
+@websocket_api.require_admin
+@websocket_api.websocket_command({vol.Required("type"): "backup/agents/info"})
+@websocket_api.async_response
+async def backup_agents_info(
+ hass: HomeAssistant,
+ connection: websocket_api.ActiveConnection,
+ msg: dict[str, Any],
+) -> None:
+ """Return backup agents info."""
+ manager = hass.data[DATA_MANAGER]
+ connection.send_result(
+ msg["id"],
+ {
+ "agents": [{"agent_id": agent_id} for agent_id in manager.backup_agents],
+ },
+ )
+
+
+@websocket_api.require_admin
+@websocket_api.websocket_command({vol.Required("type"): "backup/config/info"})
+@websocket_api.async_response
+async def handle_config_info(
+ hass: HomeAssistant,
+ connection: websocket_api.ActiveConnection,
+ msg: dict[str, Any],
+) -> None:
+ """Send the stored backup config."""
+ manager = hass.data[DATA_MANAGER]
+ connection.send_result(
+ msg["id"],
+ {
+ "config": manager.config.data.to_dict(),
+ },
+ )
+
+
+@websocket_api.require_admin
+@websocket_api.websocket_command(
+ {
+ vol.Required("type"): "backup/config/update",
+ vol.Optional("create_backup"): vol.Schema(
+ {
+ vol.Optional("agent_ids"): vol.All([str], vol.Unique()),
+ vol.Optional("include_addons"): vol.Any(
+ vol.All([str], vol.Unique()), None
+ ),
+ vol.Optional("include_all_addons"): bool,
+ vol.Optional("include_database"): bool,
+ vol.Optional("include_folders"): vol.Any(
+ vol.All([vol.Coerce(Folder)], vol.Unique()), None
+ ),
+ vol.Optional("name"): vol.Any(str, None),
+ vol.Optional("password"): vol.Any(str, None),
+ },
+ ),
+ vol.Optional("retention"): vol.Schema(
+ {
+ vol.Optional("copies"): vol.Any(int, None),
+ vol.Optional("days"): vol.Any(int, None),
+ },
+ ),
+ vol.Optional("schedule"): vol.All(str, vol.Coerce(ScheduleState)),
+ }
+)
+@websocket_api.async_response
+async def handle_config_update(
+ hass: HomeAssistant,
+ connection: websocket_api.ActiveConnection,
+ msg: dict[str, Any],
+) -> None:
+ """Update the stored backup config."""
+ manager = hass.data[DATA_MANAGER]
+ changes = dict(msg)
+ changes.pop("id")
+ changes.pop("type")
+ await manager.config.update(**changes)
+ connection.send_result(msg["id"])
+
+
+@websocket_api.require_admin
+@websocket_api.websocket_command({vol.Required("type"): "backup/subscribe_events"})
+@websocket_api.async_response
+async def handle_subscribe_events(
+ hass: HomeAssistant,
+ connection: websocket_api.ActiveConnection,
+ msg: dict[str, Any],
+) -> None:
+ """Subscribe to backup events."""
+
+ def on_event(event: ManagerStateEvent) -> None:
+ connection.send_message(websocket_api.event_message(msg["id"], event))
+
+ manager = hass.data[DATA_MANAGER]
+ on_event(manager.last_event)
+ connection.subscriptions[msg["id"]] = manager.async_subscribe_events(on_event)
+ connection.send_result(msg["id"])
diff --git a/homeassistant/components/baf/climate.py b/homeassistant/components/baf/climate.py
index 38407813d37..c30d49e8c9d 100644
--- a/homeassistant/components/baf/climate.py
+++ b/homeassistant/components/baf/climate.py
@@ -40,7 +40,6 @@ class BAFAutoComfort(BAFEntity, ClimateEntity):
_attr_temperature_unit = UnitOfTemperature.CELSIUS
_attr_hvac_modes = [HVACMode.OFF, HVACMode.FAN_ONLY]
_attr_translation_key = "auto_comfort"
- _enable_turn_on_off_backwards_compatibility = False
@callback
def _async_update_attrs(self) -> None:
diff --git a/homeassistant/components/baf/fan.py b/homeassistant/components/baf/fan.py
index d0ba668373a..8f7aab40b79 100644
--- a/homeassistant/components/baf/fan.py
+++ b/homeassistant/components/baf/fan.py
@@ -46,7 +46,7 @@ class BAFFan(BAFEntity, FanEntity):
| FanEntityFeature.TURN_OFF
| FanEntityFeature.TURN_ON
)
- _enable_turn_on_off_backwards_compatibility = False
+
_attr_preset_modes = [PRESET_MODE_AUTO]
_attr_speed_count = SPEED_COUNT
_attr_name = None
diff --git a/homeassistant/components/baf/light.py b/homeassistant/components/baf/light.py
index 2fb36ed874f..4c0b1e353fe 100644
--- a/homeassistant/components/baf/light.py
+++ b/homeassistant/components/baf/light.py
@@ -8,16 +8,12 @@ from aiobafi6 import Device, OffOnAuto
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
- ATTR_COLOR_TEMP,
+ ATTR_COLOR_TEMP_KELVIN,
ColorMode,
LightEntity,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from homeassistant.util.color import (
- color_temperature_kelvin_to_mired,
- color_temperature_mired_to_kelvin,
-)
from . import BAFConfigEntry
from .entity import BAFEntity
@@ -77,25 +73,17 @@ class BAFStandaloneLight(BAFLight):
def __init__(self, device: Device) -> None:
"""Init a standalone light."""
super().__init__(device)
- self._attr_min_mireds = color_temperature_kelvin_to_mired(
- device.light_warmest_color_temperature
- )
- self._attr_max_mireds = color_temperature_kelvin_to_mired(
- device.light_coolest_color_temperature
- )
+ self._attr_max_color_temp_kelvin = device.light_warmest_color_temperature
+ self._attr_min_color_temp_kelvin = device.light_coolest_color_temperature
@callback
def _async_update_attrs(self) -> None:
"""Update attrs from device."""
super()._async_update_attrs()
- self._attr_color_temp = color_temperature_kelvin_to_mired(
- self._device.light_color_temperature
- )
+ self._attr_color_temp_kelvin = self._device.light_color_temperature
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn on the light."""
- if (color_temp := kwargs.get(ATTR_COLOR_TEMP)) is not None:
- self._device.light_color_temperature = color_temperature_mired_to_kelvin(
- color_temp
- )
+ if (color_temp := kwargs.get(ATTR_COLOR_TEMP_KELVIN)) is not None:
+ self._device.light_color_temperature = color_temp
await super().async_turn_on(**kwargs)
diff --git a/homeassistant/components/baidu/manifest.json b/homeassistant/components/baidu/manifest.json
index 8213b7cbe5e..32f14100b81 100644
--- a/homeassistant/components/baidu/manifest.json
+++ b/homeassistant/components/baidu/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/baidu",
"iot_class": "cloud_push",
"loggers": ["aip"],
+ "quality_scale": "legacy",
"requirements": ["baidu-aip==1.6.6"]
}
diff --git a/homeassistant/components/balboa/climate.py b/homeassistant/components/balboa/climate.py
index d27fd459676..76b02f0e165 100644
--- a/homeassistant/components/balboa/climate.py
+++ b/homeassistant/components/balboa/climate.py
@@ -65,7 +65,6 @@ class BalboaClimateEntity(BalboaEntity, ClimateEntity):
)
_attr_translation_key = DOMAIN
_attr_name = None
- _enable_turn_on_off_backwards_compatibility = False
def __init__(self, client: SpaClient) -> None:
"""Initialize the climate entity."""
diff --git a/homeassistant/components/balboa/fan.py b/homeassistant/components/balboa/fan.py
index 67c1d9a9a62..3ecfec53a1e 100644
--- a/homeassistant/components/balboa/fan.py
+++ b/homeassistant/components/balboa/fan.py
@@ -38,7 +38,7 @@ class BalboaPumpFanEntity(BalboaEntity, FanEntity):
| FanEntityFeature.TURN_OFF
| FanEntityFeature.TURN_ON
)
- _enable_turn_on_off_backwards_compatibility = False
+
_attr_translation_key = "pump"
def __init__(self, control: SpaControl) -> None:
diff --git a/homeassistant/components/bang_olufsen/__init__.py b/homeassistant/components/bang_olufsen/__init__.py
index c8ba1f1c3dc..b80e625e8d4 100644
--- a/homeassistant/components/bang_olufsen/__init__.py
+++ b/homeassistant/components/bang_olufsen/__init__.py
@@ -8,6 +8,7 @@ from aiohttp.client_exceptions import (
ClientConnectorError,
ClientOSError,
ServerTimeoutError,
+ WSMessageTypeError,
)
from mozart_api.exceptions import ApiException
from mozart_api.mozart_client import MozartClient
@@ -33,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:
@@ -62,6 +63,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: BangOlufsenConfigEntry)
ServerTimeoutError,
ApiException,
TimeoutError,
+ WSMessageTypeError,
) as error:
await client.close_api_client()
raise ConfigEntryNotReady(f"Unable to connect to {entry.title}") from error
diff --git a/homeassistant/components/bang_olufsen/const.py b/homeassistant/components/bang_olufsen/const.py
index 1e06f153cdb..c5ee5d1a26e 100644
--- a/homeassistant/components/bang_olufsen/const.py
+++ b/homeassistant/components/bang_olufsen/const.py
@@ -17,62 +17,9 @@ from homeassistant.components.media_player import (
class BangOlufsenSource:
"""Class used for associating device source ids with friendly names. May not include all sources."""
- URI_STREAMER: Final[Source] = Source(
- name="Audio Streamer",
- id="uriStreamer",
- is_seekable=False,
- is_enabled=True,
- is_playable=True,
- )
- BLUETOOTH: Final[Source] = Source(
- name="Bluetooth",
- id="bluetooth",
- is_seekable=False,
- is_enabled=True,
- is_playable=True,
- )
- CHROMECAST: Final[Source] = Source(
- name="Chromecast built-in",
- id="chromeCast",
- is_seekable=False,
- is_enabled=True,
- is_playable=True,
- )
- LINE_IN: Final[Source] = Source(
- name="Line-In",
- id="lineIn",
- is_seekable=False,
- is_enabled=True,
- is_playable=True,
- )
- SPDIF: Final[Source] = Source(
- name="Optical",
- id="spdif",
- is_seekable=False,
- is_enabled=True,
- is_playable=True,
- )
- NET_RADIO: Final[Source] = Source(
- name="B&O Radio",
- id="netRadio",
- is_seekable=False,
- is_enabled=True,
- is_playable=True,
- )
- DEEZER: Final[Source] = Source(
- name="Deezer",
- id="deezer",
- is_seekable=True,
- is_enabled=True,
- is_playable=True,
- )
- TIDAL: Final[Source] = Source(
- name="Tidal",
- id="tidal",
- is_seekable=True,
- is_enabled=True,
- is_playable=True,
- )
+ LINE_IN: Final[Source] = Source(name="Line-In", id="lineIn")
+ SPDIF: Final[Source] = Source(name="Optical", id="spdif")
+ URI_STREAMER: Final[Source] = Source(name="Audio Streamer", id="uriStreamer")
BANG_OLUFSEN_STATES: dict[str, MediaPlayerState] = {
@@ -132,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"
@@ -256,10 +204,73 @@ 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",
+ "cd",
+ "ph",
+ "radio",
+ "tp1",
+ "tp2",
+)
+BEOLINK_JOIN_SOURCES = (
+ *BEOLINK_JOIN_SOURCES_TO_UPPER,
+ "beoradio",
+ "deezer",
+ "spotify",
+ "tidal",
+)
diff --git a/homeassistant/components/bang_olufsen/diagnostics.py b/homeassistant/components/bang_olufsen/diagnostics.py
new file mode 100644
index 00000000000..cab7eae5e25
--- /dev/null
+++ b/homeassistant/components/bang_olufsen/diagnostics.py
@@ -0,0 +1,40 @@
+"""Support for Bang & Olufsen diagnostics."""
+
+from __future__ import annotations
+
+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 . import BangOlufsenConfigEntry
+from .const import DOMAIN
+
+
+async def async_get_config_entry_diagnostics(
+ hass: HomeAssistant, config_entry: BangOlufsenConfigEntry
+) -> dict[str, Any]:
+ """Return diagnostics for a config entry."""
+
+ data: dict = {
+ "config_entry": config_entry.as_dict(),
+ "websocket_connected": config_entry.runtime_data.client.websocket_connected,
+ }
+
+ if TYPE_CHECKING:
+ assert config_entry.unique_id
+
+ # Add media_player entity's state
+ entity_registry = er.async_get(hass)
+ if entity_id := entity_registry.async_get_entity_id(
+ MEDIA_PLAYER_DOMAIN, DOMAIN, config_entry.unique_id
+ ):
+ if state := hass.states.get(entity_id):
+ state_dict = dict(state.as_dict())
+
+ # Remove context as it is not relevant
+ state_dict.pop("context")
+ data["media_player"] = state_dict
+
+ return data
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/manifest.json b/homeassistant/components/bang_olufsen/manifest.json
index b4a92d4da25..b29fe9731de 100644
--- a/homeassistant/components/bang_olufsen/manifest.json
+++ b/homeassistant/components/bang_olufsen/manifest.json
@@ -6,6 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/bang_olufsen",
"integration_type": "device",
"iot_class": "local_push",
- "requirements": ["mozart-api==4.1.1.116.0"],
+ "requirements": ["mozart-api==4.1.1.116.4"],
"zeroconf": ["_bangolufsen._tcp.local."]
}
diff --git a/homeassistant/components/bang_olufsen/media_player.py b/homeassistant/components/bang_olufsen/media_player.py
index 5dd45573672..282ecdd2ae5 100644
--- a/homeassistant/components/bang_olufsen/media_player.py
+++ b/homeassistant/components/bang_olufsen/media_player.py
@@ -74,6 +74,8 @@ from .const import (
BANG_OLUFSEN_REPEAT_FROM_HA,
BANG_OLUFSEN_REPEAT_TO_HA,
BANG_OLUFSEN_STATES,
+ BEOLINK_JOIN_SOURCES,
+ BEOLINK_JOIN_SOURCES_TO_UPPER,
CONF_BEOLINK_JID,
CONNECTION_STATUS,
DOMAIN,
@@ -86,6 +88,8 @@ from .const import (
from .entity import BangOlufsenEntity
from .util import get_serial_number_from_jid
+PARALLEL_UPDATES = 0
+
SCAN_INTERVAL = timedelta(seconds=30)
_LOGGER = logging.getLogger(__name__)
@@ -133,7 +137,10 @@ async def async_setup_entry(
platform.async_register_entity_service(
name="beolink_join",
- schema={vol.Optional("beolink_jid"): jid_regex},
+ schema={
+ vol.Optional("beolink_jid"): jid_regex,
+ vol.Optional("source_id"): vol.In(BEOLINK_JOIN_SOURCES),
+ },
func="async_beolink_join",
)
@@ -180,7 +187,6 @@ async def async_setup_entry(
class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity):
"""Representation of a media player."""
- _attr_icon = "mdi:speaker-wireless"
_attr_name = None
_attr_device_class = MediaPlayerDeviceClass.SPEAKER
@@ -688,36 +694,6 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity):
@property
def source(self) -> str | None:
"""Return the current audio source."""
-
- # Try to fix some of the source_change chromecast weirdness.
- if hasattr(self._playback_metadata, "title"):
- # source_change is chromecast but line in is selected.
- if self._playback_metadata.title == BangOlufsenSource.LINE_IN.name:
- return BangOlufsenSource.LINE_IN.name
-
- # source_change is chromecast but bluetooth is selected.
- if self._playback_metadata.title == BangOlufsenSource.BLUETOOTH.name:
- return BangOlufsenSource.BLUETOOTH.name
-
- # source_change is line in, bluetooth or optical but stale metadata is sent through the WebSocket,
- # And the source has not changed.
- if self._source_change.id in (
- BangOlufsenSource.BLUETOOTH.id,
- BangOlufsenSource.LINE_IN.id,
- BangOlufsenSource.SPDIF.id,
- ):
- return BangOlufsenSource.CHROMECAST.name
-
- # source_change is chromecast and there is metadata but no artwork. Bluetooth does support metadata but not artwork
- # So i assume that it is bluetooth and not chromecast
- if (
- hasattr(self._playback_metadata, "art")
- and self._playback_metadata.art is not None
- and len(self._playback_metadata.art) == 0
- and self._source_change.id == BangOlufsenSource.CHROMECAST.id
- ):
- return BangOlufsenSource.BLUETOOTH.name
-
return self._source_change.name
@property
@@ -1014,12 +990,23 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity):
await self.async_beolink_leave()
# Custom actions:
- async def async_beolink_join(self, beolink_jid: str | None = None) -> None:
+ async def async_beolink_join(
+ self, beolink_jid: str | None = None, source_id: str | None = None
+ ) -> None:
"""Join a Beolink multi-room experience."""
+ # Touch to join
if beolink_jid is None:
await self._client.join_latest_beolink_experience()
- else:
+ # Join a peer
+ elif beolink_jid and source_id is None:
await self._client.join_beolink_peer(jid=beolink_jid)
+ # Join a peer and select specific source
+ elif beolink_jid and source_id:
+ # Beolink Converter NL/ML sources need to be in upper case
+ if source_id in BEOLINK_JOIN_SOURCES_TO_UPPER:
+ source_id = source_id.upper()
+
+ await self._client.join_beolink_peer(jid=beolink_jid, source=source_id)
async def async_beolink_expand(
self, beolink_jids: list[str] | None = None, all_discovered: bool = False
diff --git a/homeassistant/components/bang_olufsen/services.yaml b/homeassistant/components/bang_olufsen/services.yaml
index e5d61420dff..7c3a2d659bd 100644
--- a/homeassistant/components/bang_olufsen/services.yaml
+++ b/homeassistant/components/bang_olufsen/services.yaml
@@ -48,6 +48,23 @@ beolink_join:
example: 1111.2222222.33333333@products.bang-olufsen.com
selector:
text:
+ source_id:
+ required: false
+ example: tidal
+ selector:
+ select:
+ translation_key: "source_ids"
+ options:
+ - beoradio
+ - deezer
+ - spotify
+ - tidal
+ - radio
+ - tp1
+ - tp2
+ - cd
+ - aux_a
+ - ph
beolink_leave:
target:
diff --git a/homeassistant/components/bang_olufsen/strings.json b/homeassistant/components/bang_olufsen/strings.json
index aef6f953524..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": {
@@ -11,7 +16,7 @@
"invalid_ip": "Invalid IPv4 address"
},
"abort": {
- "already_configured": "[%key:common::config_flow::abort::single_instance_allowed%]",
+ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]"
},
"flow_title": "{name}",
@@ -29,6 +34,166 @@
}
}
},
+ "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": {
+ "beoradio": "ASE Beoradio",
+ "deezer": "ASE / Mozart Deezer",
+ "spotify": "ASE / Mozart Spotify",
+ "tidal": "Mozart Tidal",
+ "aux_a": "Beolink Converter NL/ML AUX_A",
+ "cd": "Beolink Converter NL/ML CD",
+ "ph": "Beolink Converter NL/ML PH",
+ "radio": "Beolink Converter NL/ML RADIO",
+ "tp1": "Beolink Converter NL/ML TP1",
+ "tp2": "Beolink Converter NL/ML TP2"
+ }
+ }
+ },
"services": {
"beolink_allstandby": {
"name": "Beolink all standby",
@@ -61,6 +226,10 @@
"beolink_jid": {
"name": "Beolink JID",
"description": "Manually specify Beolink JID to join."
+ },
+ "source_id": {
+ "name": "Source",
+ "description": "Specify which source to join, behavior varies between hardware platforms. Source names prefaced by a platform name can only be used when connecting to that platform. For example \"ASE Beoradio\" can only be used when joining an ASE device, while ”ASE / Mozart Deezer” can be used with ASE or Mozart devices. A defined Beolink JID is required."
}
},
"sections": {
diff --git a/homeassistant/components/bang_olufsen/websocket.py b/homeassistant/components/bang_olufsen/websocket.py
index 913f7cb3241..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,
@@ -15,7 +17,7 @@ from mozart_api.models import (
VolumeState,
WebsocketNotificationTag,
)
-from mozart_api.mozart_client import MozartClient
+from mozart_api.mozart_client import BaseWebSocketResponse, MozartClient
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
@@ -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:
@@ -202,12 +220,13 @@ class BangOlufsenWebsocket(BangOlufsenBase):
sw_version=software_status.software_version,
)
- def on_all_notifications_raw(self, notification: dict) -> None:
+ def on_all_notifications_raw(self, notification: BaseWebSocketResponse) -> None:
"""Receive all notifications."""
+ debug_notification = {
+ "device_id": self._device.id,
+ "serial_number": int(self._unique_id),
+ **notification,
+ }
- # Add the device_id and serial_number to the notification
- notification["device_id"] = self._device.id
- notification["serial_number"] = int(self._unique_id)
-
- _LOGGER.debug("%s", notification)
- self.hass.bus.async_fire(BANG_OLUFSEN_WEBSOCKET_EVENT, notification)
+ _LOGGER.debug("%s", debug_notification)
+ self.hass.bus.async_fire(BANG_OLUFSEN_WEBSOCKET_EVENT, debug_notification)
diff --git a/homeassistant/components/bbox/manifest.json b/homeassistant/components/bbox/manifest.json
index 9035bea74bc..67e54ae2359 100644
--- a/homeassistant/components/bbox/manifest.json
+++ b/homeassistant/components/bbox/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/bbox",
"iot_class": "local_polling",
"loggers": ["pybbox"],
+ "quality_scale": "legacy",
"requirements": ["pybbox==0.0.5-alpha"]
}
diff --git a/homeassistant/components/beewi_smartclim/manifest.json b/homeassistant/components/beewi_smartclim/manifest.json
index 3555f9181bb..baf41be4345 100644
--- a/homeassistant/components/beewi_smartclim/manifest.json
+++ b/homeassistant/components/beewi_smartclim/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/beewi_smartclim",
"iot_class": "local_polling",
"loggers": ["beewi_smartclim"],
+ "quality_scale": "legacy",
"requirements": ["beewi-smartclim==0.0.10"]
}
diff --git a/homeassistant/components/binary_sensor/__init__.py b/homeassistant/components/binary_sensor/__init__.py
index baf6bf98547..f31c3d102b0 100644
--- a/homeassistant/components/binary_sensor/__init__.py
+++ b/homeassistant/components/binary_sensor/__init__.py
@@ -4,7 +4,6 @@ from __future__ import annotations
from datetime import timedelta
from enum import StrEnum
-from functools import partial
import logging
from typing import Literal, final
@@ -16,12 +15,6 @@ from homeassistant.const import STATE_OFF, STATE_ON, EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv
-from homeassistant.helpers.deprecation import (
- DeprecatedConstantEnum,
- 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.typing import ConfigType
@@ -126,94 +119,7 @@ class BinarySensorDeviceClass(StrEnum):
DEVICE_CLASSES_SCHEMA = vol.All(vol.Lower, vol.Coerce(BinarySensorDeviceClass))
-
-# DEVICE_CLASS* below are deprecated as of 2021.12
-# use the BinarySensorDeviceClass enum instead.
DEVICE_CLASSES = [cls.value for cls in BinarySensorDeviceClass]
-_DEPRECATED_DEVICE_CLASS_BATTERY = DeprecatedConstantEnum(
- BinarySensorDeviceClass.BATTERY, "2025.1"
-)
-_DEPRECATED_DEVICE_CLASS_BATTERY_CHARGING = DeprecatedConstantEnum(
- BinarySensorDeviceClass.BATTERY_CHARGING, "2025.1"
-)
-_DEPRECATED_DEVICE_CLASS_CO = DeprecatedConstantEnum(
- BinarySensorDeviceClass.CO, "2025.1"
-)
-_DEPRECATED_DEVICE_CLASS_COLD = DeprecatedConstantEnum(
- BinarySensorDeviceClass.COLD, "2025.1"
-)
-_DEPRECATED_DEVICE_CLASS_CONNECTIVITY = DeprecatedConstantEnum(
- BinarySensorDeviceClass.CONNECTIVITY, "2025.1"
-)
-_DEPRECATED_DEVICE_CLASS_DOOR = DeprecatedConstantEnum(
- BinarySensorDeviceClass.DOOR, "2025.1"
-)
-_DEPRECATED_DEVICE_CLASS_GARAGE_DOOR = DeprecatedConstantEnum(
- BinarySensorDeviceClass.GARAGE_DOOR, "2025.1"
-)
-_DEPRECATED_DEVICE_CLASS_GAS = DeprecatedConstantEnum(
- BinarySensorDeviceClass.GAS, "2025.1"
-)
-_DEPRECATED_DEVICE_CLASS_HEAT = DeprecatedConstantEnum(
- BinarySensorDeviceClass.HEAT, "2025.1"
-)
-_DEPRECATED_DEVICE_CLASS_LIGHT = DeprecatedConstantEnum(
- BinarySensorDeviceClass.LIGHT, "2025.1"
-)
-_DEPRECATED_DEVICE_CLASS_LOCK = DeprecatedConstantEnum(
- BinarySensorDeviceClass.LOCK, "2025.1"
-)
-_DEPRECATED_DEVICE_CLASS_MOISTURE = DeprecatedConstantEnum(
- BinarySensorDeviceClass.MOISTURE, "2025.1"
-)
-_DEPRECATED_DEVICE_CLASS_MOTION = DeprecatedConstantEnum(
- BinarySensorDeviceClass.MOTION, "2025.1"
-)
-_DEPRECATED_DEVICE_CLASS_MOVING = DeprecatedConstantEnum(
- BinarySensorDeviceClass.MOVING, "2025.1"
-)
-_DEPRECATED_DEVICE_CLASS_OCCUPANCY = DeprecatedConstantEnum(
- BinarySensorDeviceClass.OCCUPANCY, "2025.1"
-)
-_DEPRECATED_DEVICE_CLASS_OPENING = DeprecatedConstantEnum(
- BinarySensorDeviceClass.OPENING, "2025.1"
-)
-_DEPRECATED_DEVICE_CLASS_PLUG = DeprecatedConstantEnum(
- BinarySensorDeviceClass.PLUG, "2025.1"
-)
-_DEPRECATED_DEVICE_CLASS_POWER = DeprecatedConstantEnum(
- BinarySensorDeviceClass.POWER, "2025.1"
-)
-_DEPRECATED_DEVICE_CLASS_PRESENCE = DeprecatedConstantEnum(
- BinarySensorDeviceClass.PRESENCE, "2025.1"
-)
-_DEPRECATED_DEVICE_CLASS_PROBLEM = DeprecatedConstantEnum(
- BinarySensorDeviceClass.PROBLEM, "2025.1"
-)
-_DEPRECATED_DEVICE_CLASS_RUNNING = DeprecatedConstantEnum(
- BinarySensorDeviceClass.RUNNING, "2025.1"
-)
-_DEPRECATED_DEVICE_CLASS_SAFETY = DeprecatedConstantEnum(
- BinarySensorDeviceClass.SAFETY, "2025.1"
-)
-_DEPRECATED_DEVICE_CLASS_SMOKE = DeprecatedConstantEnum(
- BinarySensorDeviceClass.SMOKE, "2025.1"
-)
-_DEPRECATED_DEVICE_CLASS_SOUND = DeprecatedConstantEnum(
- BinarySensorDeviceClass.SOUND, "2025.1"
-)
-_DEPRECATED_DEVICE_CLASS_TAMPER = DeprecatedConstantEnum(
- BinarySensorDeviceClass.TAMPER, "2025.1"
-)
-_DEPRECATED_DEVICE_CLASS_UPDATE = DeprecatedConstantEnum(
- BinarySensorDeviceClass.UPDATE, "2025.1"
-)
-_DEPRECATED_DEVICE_CLASS_VIBRATION = DeprecatedConstantEnum(
- BinarySensorDeviceClass.VIBRATION, "2025.1"
-)
-_DEPRECATED_DEVICE_CLASS_WINDOW = DeprecatedConstantEnum(
- BinarySensorDeviceClass.WINDOW, "2025.1"
-)
# mypy: disallow-any-generics
@@ -294,11 +200,3 @@ class BinarySensorEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_)
if (is_on := self.is_on) is None:
return None
return STATE_ON if is_on else STATE_OFF
-
-
-# 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/bitcoin/manifest.json b/homeassistant/components/bitcoin/manifest.json
index 6f5fd678009..b208e904cab 100644
--- a/homeassistant/components/bitcoin/manifest.json
+++ b/homeassistant/components/bitcoin/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/bitcoin",
"iot_class": "cloud_polling",
"loggers": ["blockchain"],
+ "quality_scale": "legacy",
"requirements": ["blockchain==1.4.4"]
}
diff --git a/homeassistant/components/bizkaibus/manifest.json b/homeassistant/components/bizkaibus/manifest.json
index b47df75bbe5..5a333546401 100644
--- a/homeassistant/components/bizkaibus/manifest.json
+++ b/homeassistant/components/bizkaibus/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/bizkaibus",
"iot_class": "cloud_polling",
"loggers": ["bizkaibus"],
+ "quality_scale": "legacy",
"requirements": ["bizkaibus==0.1.1"]
}
diff --git a/homeassistant/components/blackbird/manifest.json b/homeassistant/components/blackbird/manifest.json
index d75b69dfaf8..a0f4b0c383c 100644
--- a/homeassistant/components/blackbird/manifest.json
+++ b/homeassistant/components/blackbird/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/blackbird",
"iot_class": "local_polling",
"loggers": ["pyblackbird"],
+ "quality_scale": "legacy",
"requirements": ["pyblackbird==0.6"]
}
diff --git a/homeassistant/components/blebox/climate.py b/homeassistant/components/blebox/climate.py
index e04503974b7..2c528d50e3e 100644
--- a/homeassistant/components/blebox/climate.py
+++ b/homeassistant/components/blebox/climate.py
@@ -57,7 +57,6 @@ class BleBoxClimateEntity(BleBoxEntity[blebox_uniapi.climate.Climate], ClimateEn
| ClimateEntityFeature.TURN_ON
)
_attr_temperature_unit = UnitOfTemperature.CELSIUS
- _enable_turn_on_off_backwards_compatibility = False
@property
def hvac_modes(self):
diff --git a/homeassistant/components/blebox/light.py b/homeassistant/components/blebox/light.py
index 33fff1d71da..c3c9de8be51 100644
--- a/homeassistant/components/blebox/light.py
+++ b/homeassistant/components/blebox/light.py
@@ -11,7 +11,7 @@ from blebox_uniapi.light import BleboxColorMode
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
- ATTR_COLOR_TEMP,
+ ATTR_COLOR_TEMP_KELVIN,
ATTR_EFFECT,
ATTR_RGB_COLOR,
ATTR_RGBW_COLOR,
@@ -22,6 +22,7 @@ from homeassistant.components.light import (
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.util import color as color_util
from . import BleBoxConfigEntry
from .entity import BleBoxEntity
@@ -58,8 +59,8 @@ COLOR_MODE_MAP = {
class BleBoxLightEntity(BleBoxEntity[blebox_uniapi.light.Light], LightEntity):
"""Representation of BleBox lights."""
- _attr_max_mireds = 370 # 1,000,000 divided by 2700 Kelvin = 370 Mireds
- _attr_min_mireds = 154 # 1,000,000 divided by 6500 Kelvin = 154 Mireds
+ _attr_min_color_temp_kelvin = 2700 # 370 Mireds
+ _attr_max_color_temp_kelvin = 6500 # 154 Mireds
def __init__(self, feature: blebox_uniapi.light.Light) -> None:
"""Initialize a BleBox light."""
@@ -78,9 +79,9 @@ class BleBoxLightEntity(BleBoxEntity[blebox_uniapi.light.Light], LightEntity):
return self._feature.brightness
@property
- def color_temp(self):
- """Return color temperature."""
- return self._feature.color_temp
+ def color_temp_kelvin(self) -> int:
+ """Return the color temperature value in Kelvin."""
+ return color_util.color_temperature_mired_to_kelvin(self._feature.color_temp)
@property
def color_mode(self):
@@ -136,7 +137,7 @@ class BleBoxLightEntity(BleBoxEntity[blebox_uniapi.light.Light], LightEntity):
rgbw = kwargs.get(ATTR_RGBW_COLOR)
brightness = kwargs.get(ATTR_BRIGHTNESS)
effect = kwargs.get(ATTR_EFFECT)
- color_temp = kwargs.get(ATTR_COLOR_TEMP)
+ color_temp_kelvin = kwargs.get(ATTR_COLOR_TEMP_KELVIN)
rgbww = kwargs.get(ATTR_RGBWW_COLOR)
feature = self._feature
value = feature.sensible_on_value
@@ -144,9 +145,10 @@ class BleBoxLightEntity(BleBoxEntity[blebox_uniapi.light.Light], LightEntity):
if rgbw is not None:
value = list(rgbw)
- if color_temp is not None:
+ if color_temp_kelvin is not None:
value = feature.return_color_temp_with_brightness(
- int(color_temp), self.brightness
+ int(color_util.color_temperature_kelvin_to_mired(color_temp_kelvin)),
+ self.brightness,
)
if rgbww is not None:
@@ -158,9 +160,12 @@ class BleBoxLightEntity(BleBoxEntity[blebox_uniapi.light.Light], LightEntity):
value = list(rgb)
if brightness is not None:
- if self.color_mode == ATTR_COLOR_TEMP:
+ if self.color_mode == ColorMode.COLOR_TEMP:
value = feature.return_color_temp_with_brightness(
- self.color_temp, brightness
+ color_util.color_temperature_kelvin_to_mired(
+ self.color_temp_kelvin
+ ),
+ brightness,
)
else:
value = feature.apply_brightness(value, brightness)
diff --git a/homeassistant/components/blink/config_flow.py b/homeassistant/components/blink/config_flow.py
index 62f15bd6e10..e37df26aaa8 100644
--- a/homeassistant/components/blink/config_flow.py
+++ b/homeassistant/components/blink/config_flow.py
@@ -10,7 +10,7 @@ from blinkpy.auth import Auth, LoginError, TokenRefreshFailed
from blinkpy.blinkpy import Blink, BlinkSetupError
import voluptuous as vol
-from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
+from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_PASSWORD, CONF_PIN, CONF_USERNAME
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
@@ -61,6 +61,8 @@ class BlinkConfigFlow(ConfigFlow, domain=DOMAIN):
session=async_get_clientsession(self.hass),
)
await self.async_set_unique_id(user_input[CONF_USERNAME])
+ if self.source != SOURCE_REAUTH:
+ self._abort_if_unique_id_configured()
try:
await validate_input(self.auth)
diff --git a/homeassistant/components/blink/sensor.py b/homeassistant/components/blink/sensor.py
index f20f8188b42..e0b5989cc80 100644
--- a/homeassistant/components/blink/sensor.py
+++ b/homeassistant/components/blink/sensor.py
@@ -10,7 +10,11 @@ from homeassistant.components.sensor import (
SensorEntityDescription,
SensorStateClass,
)
-from homeassistant.const import EntityCategory, UnitOfTemperature
+from homeassistant.const import (
+ SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
+ EntityCategory,
+ UnitOfTemperature,
+)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
@@ -32,6 +36,8 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
SensorEntityDescription(
key=TYPE_WIFI_STRENGTH,
translation_key="wifi_strength",
+ native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
+ device_class=SensorDeviceClass.SIGNAL_STRENGTH,
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.MEASUREMENT,
),
diff --git a/homeassistant/components/blink/services.py b/homeassistant/components/blink/services.py
index 5f51598e721..dd5d1e37627 100644
--- a/homeassistant/components/blink/services.py
+++ b/homeassistant/components/blink/services.py
@@ -5,7 +5,7 @@ from __future__ import annotations
import voluptuous as vol
from homeassistant.config_entries import ConfigEntryState
-from homeassistant.const import ATTR_DEVICE_ID, CONF_PIN
+from homeassistant.const import CONF_PIN
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import config_validation as cv
@@ -13,11 +13,6 @@ from homeassistant.helpers import config_validation as cv
from .const import ATTR_CONFIG_ENTRY_ID, DOMAIN, SERVICE_SEND_PIN
from .coordinator import BlinkConfigEntry
-SERVICE_UPDATE_SCHEMA = vol.Schema(
- {
- vol.Required(ATTR_DEVICE_ID): vol.All(cv.ensure_list, [cv.string]),
- }
-)
SERVICE_SEND_PIN_SCHEMA = vol.Schema(
{
vol.Required(ATTR_CONFIG_ENTRY_ID): vol.All(cv.ensure_list, [cv.string]),
diff --git a/homeassistant/components/blink/strings.json b/homeassistant/components/blink/strings.json
index 6e2384e5d5b..74f8ae1cb28 100644
--- a/homeassistant/components/blink/strings.json
+++ b/homeassistant/components/blink/strings.json
@@ -84,16 +84,16 @@
}
},
"send_pin": {
- "name": "Send pin",
- "description": "Sends a new PIN to blink for 2FA.",
+ "name": "Send PIN",
+ "description": "Sends a new PIN to Blink for 2FA.",
"fields": {
"pin": {
- "name": "Pin",
- "description": "PIN received from blink. Leave empty if you only received a verification email."
+ "name": "PIN",
+ "description": "PIN received from Blink. Leave empty if you only received a verification email."
},
"config_entry_id": {
"name": "Integration ID",
- "description": "The Blink Integration id."
+ "description": "The Blink Integration ID."
}
}
}
diff --git a/homeassistant/components/blinksticklight/manifest.json b/homeassistant/components/blinksticklight/manifest.json
index 70fac896ff2..d3592b6af6e 100644
--- a/homeassistant/components/blinksticklight/manifest.json
+++ b/homeassistant/components/blinksticklight/manifest.json
@@ -6,5 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/blinksticklight",
"iot_class": "local_polling",
"loggers": ["blinkstick"],
+ "quality_scale": "legacy",
"requirements": ["BlinkStick==1.2.0"]
}
diff --git a/homeassistant/components/blockchain/manifest.json b/homeassistant/components/blockchain/manifest.json
index 2e58dc5aa03..6c9182ee0c4 100644
--- a/homeassistant/components/blockchain/manifest.json
+++ b/homeassistant/components/blockchain/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/blockchain",
"iot_class": "cloud_polling",
"loggers": ["pyblockchain"],
+ "quality_scale": "legacy",
"requirements": ["python-blockchain-api==0.0.2"]
}
diff --git a/homeassistant/components/bluesound/__init__.py b/homeassistant/components/bluesound/__init__.py
index 82fe9b00d57..b3facc0b8ac 100644
--- a/homeassistant/components/bluesound/__init__.py
+++ b/homeassistant/components/bluesound/__init__.py
@@ -14,7 +14,6 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.typing import ConfigType
from .const import DOMAIN
-from .services import setup_services
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
@@ -36,7 +35,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Bluesound."""
if DOMAIN not in hass.data:
hass.data[DOMAIN] = []
- setup_services(hass)
return True
diff --git a/homeassistant/components/bluesound/config_flow.py b/homeassistant/components/bluesound/config_flow.py
index 050b3ee4eac..b5e31fb2ed7 100644
--- a/homeassistant/components/bluesound/config_flow.py
+++ b/homeassistant/components/bluesound/config_flow.py
@@ -71,27 +71,6 @@ 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
) -> ConfigFlowResult:
diff --git a/homeassistant/components/bluesound/manifest.json b/homeassistant/components/bluesound/manifest.json
index 462112a8b78..151c1512b74 100644
--- a/homeassistant/components/bluesound/manifest.json
+++ b/homeassistant/components/bluesound/manifest.json
@@ -6,7 +6,7 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/bluesound",
"iot_class": "local_polling",
- "requirements": ["pyblu==1.0.4"],
+ "requirements": ["pyblu==2.0.0"],
"zeroconf": [
{
"type": "_musc._tcp.local."
diff --git a/homeassistant/components/bluesound/media_player.py b/homeassistant/components/bluesound/media_player.py
index 97985a74300..e850c059e52 100644
--- a/homeassistant/components/bluesound/media_player.py
+++ b/homeassistant/components/bluesound/media_player.py
@@ -15,7 +15,6 @@ 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,23 +22,24 @@ 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
from homeassistant.exceptions import ServiceValidationError
-from homeassistant.helpers import config_validation as cv, issue_registry as ir
+from homeassistant.helpers import config_validation as cv, entity_platform
from homeassistant.helpers.device_registry import (
CONNECTION_NETWORK_MAC,
DeviceInfo,
format_mac,
)
+from homeassistant.helpers.dispatcher import (
+ async_dispatcher_connect,
+ 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 .const import ATTR_BLUESOUND_GROUP, ATTR_MASTER, DOMAIN, INTEGRATION_TITLE
-from .utils import format_unique_id
+from .const import ATTR_BLUESOUND_GROUP, ATTR_MASTER, DOMAIN
+from .utils import dispatcher_join_signal, dispatcher_unjoin_signal, format_unique_id
if TYPE_CHECKING:
from . import BluesoundConfigEntry
@@ -51,6 +51,11 @@ SCAN_INTERVAL = timedelta(minutes=15)
DATA_BLUESOUND = DOMAIN
DEFAULT_PORT = 11000
+SERVICE_CLEAR_TIMER = "clear_sleep_timer"
+SERVICE_JOIN = "join"
+SERVICE_SET_TIMER = "set_sleep_timer"
+SERVICE_UNJOIN = "unjoin"
+
NODE_OFFLINE_CHECK_TIMEOUT = 180
NODE_RETRY_INITIATION = timedelta(minutes=3)
@@ -58,64 +63,6 @@ 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,
@@ -130,26 +77,22 @@ async def async_setup_entry(
config_entry.runtime_data.sync_status,
)
+ platform = entity_platform.async_get_current_platform()
+ platform.async_register_entity_service(
+ SERVICE_SET_TIMER, None, "async_increase_timer"
+ )
+ platform.async_register_entity_service(
+ SERVICE_CLEAR_TIMER, None, "async_clear_timer"
+ )
+ platform.async_register_entity_service(
+ SERVICE_JOIN, {vol.Required(ATTR_MASTER): cv.entity_id}, "async_join"
+ )
+ 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):
"""Representation of a Bluesound Player."""
@@ -175,13 +118,12 @@ class BluesoundPlayer(MediaPlayerEntity):
self._status: Status | None = None
self._inputs: list[Input] = []
self._presets: list[Preset] = []
- self._muted = False
- self._master: BluesoundPlayer | None = None
- self._is_master = False
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._attr_unique_id = format_unique_id(sync_status.mac, port)
# there should always be one player with the default port per mac
@@ -250,6 +192,22 @@ class BluesoundPlayer(MediaPlayerEntity):
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(
+ self.hass,
+ dispatcher_join_signal(self.entity_id),
+ self.async_add_follower,
+ )
+ )
+ self.async_on_remove(
+ async_dispatcher_connect(
+ self.hass,
+ dispatcher_unjoin_signal(self._sync_status.id),
+ self.async_remove_follower,
+ )
+ )
+
async def async_will_remove_from_hass(self) -> None:
"""Stop the polling task."""
await super().async_will_remove_from_hass()
@@ -292,14 +250,6 @@ class BluesoundPlayer(MediaPlayerEntity):
self._last_status_update = dt_util.utcnow()
self._status = status
- group_name = status.group_name
- if group_name != self._group_name:
- _LOGGER.debug("Group name change detected on device: %s", self.id)
- self._group_name = group_name
-
- # rebuild ordered list of entity_ids that are in the group, master is first
- self._group_list = self.rebuild_bluesound_group()
-
self.async_write_ha_state()
except PlayerUnreachableError:
self._attr_available = False
@@ -323,25 +273,27 @@ class BluesoundPlayer(MediaPlayerEntity):
self._sync_status = sync_status
- if sync_status.master is not None:
- self._is_master = False
- master_id = f"{sync_status.master.ip}:{sync_status.master.port}"
- master_device = [
+ 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 == master_id
+ if device.id == leader_id
]
- if master_device and master_id != self.id:
- self._master = master_device[0]
+ if leader_device and leader_id != self.id:
+ self._leader = leader_device[0]
else:
- self._master = None
- _LOGGER.error("Master not found %s", master_id)
+ self._leader = None
+ _LOGGER.error("Leader not found %s", leader_id)
else:
- if self._master is not None:
- self._master = None
- slaves = self._sync_status.slaves
- self._is_master = slaves is not None
+ 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()
@@ -361,7 +313,7 @@ class BluesoundPlayer(MediaPlayerEntity):
if self._status is None:
return MediaPlayerState.OFF
- if self.is_grouped and not self.is_master:
+ if self.is_grouped and not self.is_leader:
return MediaPlayerState.IDLE
match self._status.state:
@@ -375,7 +327,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_master):
+ if self._status is None or (self.is_grouped and not self.is_leader):
return None
return self._status.name
@@ -386,7 +338,7 @@ class BluesoundPlayer(MediaPlayerEntity):
if self._status is None:
return None
- if self.is_grouped and not self.is_master:
+ if self.is_grouped and not self.is_leader:
return self._group_name
return self._status.artist
@@ -394,7 +346,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_master):
+ if self._status is None or (self.is_grouped and not self.is_leader):
return None
return self._status.album
@@ -402,7 +354,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_master):
+ if self._status is None or (self.is_grouped and not self.is_leader):
return None
url = self._status.image
@@ -417,7 +369,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_master):
+ if self._status is None or (self.is_grouped and not self.is_leader):
return None
mediastate = self.state
@@ -436,7 +388,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_master):
+ if self._status is None or (self.is_grouped and not self.is_leader):
return None
duration = self._status.total_seconds
@@ -495,7 +447,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_master):
+ if self._status is None or (self.is_grouped and not self.is_leader):
return None
sources = [x.text for x in self._inputs]
@@ -506,7 +458,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_master):
+ if self._status is None or (self.is_grouped and not self.is_leader):
return None
if self._status.input_id is not None:
@@ -526,7 +478,7 @@ class BluesoundPlayer(MediaPlayerEntity):
if self._status is None:
return MediaPlayerEntityFeature(0)
- if self.is_grouped and not self.is_master:
+ if self.is_grouped and not self.is_leader:
return (
MediaPlayerEntityFeature.VOLUME_STEP
| MediaPlayerEntityFeature.VOLUME_SET
@@ -566,14 +518,17 @@ class BluesoundPlayer(MediaPlayerEntity):
return supported
@property
- def is_master(self) -> bool:
- """Return true if player is a coordinator."""
- return self._is_master
+ def is_leader(self) -> bool:
+ """Return true if player is leader of a group."""
+ return self._sync_status.followers is not None
@property
def is_grouped(self) -> bool:
- """Return true if player is a coordinator."""
- return self._master is not None or self._is_master
+ """Return true if player is member or leader of a group."""
+ return (
+ self._sync_status.followers is not None
+ or self._sync_status.leader is not None
+ )
@property
def shuffle(self) -> bool:
@@ -586,25 +541,25 @@ class BluesoundPlayer(MediaPlayerEntity):
async def async_join(self, master: str) -> None:
"""Join the player to a group."""
- master_device = [
- device
- for device in self.hass.data[DATA_BLUESOUND]
- if device.entity_id == master
- ]
+ if master == self.entity_id:
+ raise ServiceValidationError("Cannot join player to itself")
- if len(master_device) > 0:
- if self.id == master_device[0].id:
- raise ServiceValidationError("Cannot join player to itself")
+ _LOGGER.debug("Trying to join player: %s", self.id)
+ async_dispatcher_send(
+ self.hass, dispatcher_join_signal(master), self.host, self.port
+ )
- _LOGGER.debug(
- "Trying to join player: %s to master: %s",
- self.id,
- master_device[0].id,
- )
+ async def async_unjoin(self) -> None:
+ """Unjoin the player from a group."""
+ if self._sync_status.leader is None:
+ return
- await master_device[0].async_add_slave(self)
- else:
- _LOGGER.error("Master not found %s", master_device)
+ leader_id = f"{self._sync_status.leader.ip}:{self._sync_status.leader.port}"
+
+ _LOGGER.debug("Trying to unjoin player: %s", self.id)
+ async_dispatcher_send(
+ self.hass, dispatcher_unjoin_signal(leader_id), self.host, self.port
+ )
@property
def extra_state_attributes(self) -> dict[str, Any] | None:
@@ -613,43 +568,46 @@ class BluesoundPlayer(MediaPlayerEntity):
if self._group_list:
attributes = {ATTR_BLUESOUND_GROUP: self._group_list}
- attributes[ATTR_MASTER] = self._is_master
+ attributes[ATTR_MASTER] = self.is_leader
return attributes
def rebuild_bluesound_group(self) -> list[str]:
"""Rebuild the list of entities in speaker group."""
- if self._group_name is None:
+ if self.sync_status.leader is None and self.sync_status.followers is None:
return []
- device_group = self._group_name.split("+")
+ player_entities: list[BluesoundPlayer] = self.hass.data[DATA_BLUESOUND]
- sorted_entities: list[BluesoundPlayer] = sorted(
- self.hass.data[DATA_BLUESOUND],
- key=lambda entity: entity.is_master,
- reverse=True,
- )
- return [
- entity.sync_status.name
- for entity in sorted_entities
- if entity.bluesound_device_name in device_group
+ 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
+ break
+
+ if leader_sync_status is None or leader_sync_status.followers is None:
+ return []
+
+ 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
]
+ follower_names.insert(0, leader_sync_status.name)
+ return follower_names
- async def async_unjoin(self) -> None:
- """Unjoin the player from a group."""
- if self._master is None:
- return
+ async def async_add_follower(self, host: str, port: int) -> None:
+ """Add follower to leader."""
+ await self._player.add_follower(host, port)
- _LOGGER.debug("Trying to unjoin player: %s", self.id)
- await self._master.async_remove_slave(self)
-
- async def async_add_slave(self, slave_device: BluesoundPlayer) -> None:
- """Add slave to master."""
- await self._player.add_slave(slave_device.host, slave_device.port)
-
- async def async_remove_slave(self, slave_device: BluesoundPlayer) -> None:
- """Remove slave to master."""
- await self._player.remove_slave(slave_device.host, slave_device.port)
+ async def async_remove_follower(self, host: str, port: int) -> None:
+ """Remove follower to leader."""
+ await self._player.remove_follower(host, port)
async def async_increase_timer(self) -> int:
"""Increase sleep time on player."""
@@ -667,7 +625,7 @@ class BluesoundPlayer(MediaPlayerEntity):
async def async_select_source(self, source: str) -> None:
"""Select input source."""
- if self.is_grouped and not self.is_master:
+ if self.is_grouped and not self.is_leader:
return
# presets and inputs might have the same name; presets have priority
@@ -686,49 +644,49 @@ class BluesoundPlayer(MediaPlayerEntity):
async def async_clear_playlist(self) -> None:
"""Clear players playlist."""
- if self.is_grouped and not self.is_master:
+ if self.is_grouped and not self.is_leader:
return
await self._player.clear()
async def async_media_next_track(self) -> None:
"""Send media_next command to media player."""
- if self.is_grouped and not self.is_master:
+ if self.is_grouped and not self.is_leader:
return
await self._player.skip()
async def async_media_previous_track(self) -> None:
"""Send media_previous command to media player."""
- if self.is_grouped and not self.is_master:
+ if self.is_grouped and not self.is_leader:
return
await self._player.back()
async def async_media_play(self) -> None:
"""Send media_play command to media player."""
- if self.is_grouped and not self.is_master:
+ if self.is_grouped and not self.is_leader:
return
await self._player.play()
async def async_media_pause(self) -> None:
"""Send media_pause command to media player."""
- if self.is_grouped and not self.is_master:
+ if self.is_grouped and not self.is_leader:
return
await self._player.pause()
async def async_media_stop(self) -> None:
"""Send stop command."""
- if self.is_grouped and not self.is_master:
+ if self.is_grouped and not self.is_leader:
return
await self._player.stop()
async def async_media_seek(self, position: float) -> None:
"""Send media_seek command to media player."""
- if self.is_grouped and not self.is_master:
+ if self.is_grouped and not self.is_leader:
return
await self._player.play(seek=int(position))
@@ -737,7 +695,7 @@ class BluesoundPlayer(MediaPlayerEntity):
self, media_type: MediaType | str, media_id: str, **kwargs: Any
) -> None:
"""Send the play_media command to the media player."""
- if self.is_grouped and not self.is_master:
+ if self.is_grouped and not self.is_leader:
return
if media_source.is_media_source_id(media_id):
diff --git a/homeassistant/components/bluesound/services.py b/homeassistant/components/bluesound/services.py
deleted file mode 100644
index 06a507420f8..00000000000
--- a/homeassistant/components/bluesound/services.py
+++ /dev/null
@@ -1,68 +0,0 @@
-"""Support for Bluesound devices."""
-
-from __future__ import annotations
-
-from typing import NamedTuple
-
-import voluptuous as vol
-
-from homeassistant.const import ATTR_ENTITY_ID
-from homeassistant.core import HomeAssistant, ServiceCall
-from homeassistant.helpers import config_validation as cv
-
-from .const import ATTR_MASTER, DOMAIN
-
-SERVICE_CLEAR_TIMER = "clear_sleep_timer"
-SERVICE_JOIN = "join"
-SERVICE_SET_TIMER = "set_sleep_timer"
-SERVICE_UNJOIN = "unjoin"
-
-BS_SCHEMA = vol.Schema({vol.Optional(ATTR_ENTITY_ID): cv.entity_ids})
-
-BS_JOIN_SCHEMA = BS_SCHEMA.extend({vol.Required(ATTR_MASTER): cv.entity_id})
-
-
-class ServiceMethodDetails(NamedTuple):
- """Details for SERVICE_TO_METHOD mapping."""
-
- method: str
- schema: vol.Schema
-
-
-SERVICE_TO_METHOD = {
- SERVICE_JOIN: ServiceMethodDetails(method="async_join", schema=BS_JOIN_SCHEMA),
- SERVICE_UNJOIN: ServiceMethodDetails(method="async_unjoin", schema=BS_SCHEMA),
- SERVICE_SET_TIMER: ServiceMethodDetails(
- method="async_increase_timer", schema=BS_SCHEMA
- ),
- SERVICE_CLEAR_TIMER: ServiceMethodDetails(
- method="async_clear_timer", schema=BS_SCHEMA
- ),
-}
-
-
-def setup_services(hass: HomeAssistant) -> None:
- """Set up services for Bluesound component."""
-
- async def async_service_handler(service: ServiceCall) -> None:
- """Map services to method of Bluesound devices."""
- if not (method := SERVICE_TO_METHOD.get(service.service)):
- return
-
- params = {
- key: value for key, value in service.data.items() if key != ATTR_ENTITY_ID
- }
- if entity_ids := service.data.get(ATTR_ENTITY_ID):
- target_players = [
- player for player in hass.data[DOMAIN] if player.entity_id in entity_ids
- ]
- else:
- target_players = hass.data[DOMAIN]
-
- for player in target_players:
- await getattr(player, method.method)(**params)
-
- for service, method in SERVICE_TO_METHOD.items():
- hass.services.async_register(
- DOMAIN, service, async_service_handler, schema=method.schema
- )
diff --git a/homeassistant/components/bluesound/utils.py b/homeassistant/components/bluesound/utils.py
index 89a6fd1e787..5df5b32de95 100644
--- a/homeassistant/components/bluesound/utils.py
+++ b/homeassistant/components/bluesound/utils.py
@@ -6,3 +6,16 @@ from homeassistant.helpers.device_registry import format_mac
def format_unique_id(mac: str, port: int) -> str:
"""Generate a unique ID based on the MAC address and port number."""
return f"{format_mac(mac)}-{port}"
+
+
+def dispatcher_join_signal(entity_id: str) -> str:
+ """Join an entity ID with a signal."""
+ return f"bluesound_join_{entity_id}"
+
+
+def dispatcher_unjoin_signal(leader_id: str) -> str:
+ """Unjoin an entity ID with a signal.
+
+ Id is ip_address:port. This can be obtained from sync_status.id.
+ """
+ return f"bluesound_unjoin_{leader_id}"
diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json
index fe16bd73a9e..b0ddac2f7f4 100644
--- a/homeassistant/components/bluetooth/manifest.json
+++ b/homeassistant/components/bluetooth/manifest.json
@@ -16,10 +16,10 @@
"requirements": [
"bleak==0.22.3",
"bleak-retry-connector==3.6.0",
- "bluetooth-adapters==0.20.0",
+ "bluetooth-adapters==0.20.2",
"bluetooth-auto-recovery==1.4.2",
"bluetooth-data-tools==1.20.0",
- "dbus-fast==2.24.3",
- "habluetooth==3.6.0"
+ "dbus-fast==2.28.0",
+ "habluetooth==3.7.0"
]
}
diff --git a/homeassistant/components/bluetooth_le_tracker/manifest.json b/homeassistant/components/bluetooth_le_tracker/manifest.json
index 79f885cad18..4abf5f7607e 100644
--- a/homeassistant/components/bluetooth_le_tracker/manifest.json
+++ b/homeassistant/components/bluetooth_le_tracker/manifest.json
@@ -4,5 +4,6 @@
"codeowners": [],
"dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/bluetooth_le_tracker",
- "iot_class": "local_push"
+ "iot_class": "local_push",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/bluetooth_tracker/manifest.json b/homeassistant/components/bluetooth_tracker/manifest.json
index 0a0356e6669..8fb35b311c9 100644
--- a/homeassistant/components/bluetooth_tracker/manifest.json
+++ b/homeassistant/components/bluetooth_tracker/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/bluetooth_tracker",
"iot_class": "local_polling",
"loggers": ["bluetooth", "bt_proximity"],
+ "quality_scale": "legacy",
"requirements": ["bt-proximity==0.2.1", "PyBluez==0.22"]
}
diff --git a/homeassistant/components/bmw_connected_drive/__init__.py b/homeassistant/components/bmw_connected_drive/__init__.py
index 9e43cfc4187..05fa3e3cab0 100644
--- a/homeassistant/components/bmw_connected_drive/__init__.py
+++ b/homeassistant/components/bmw_connected_drive/__init__.py
@@ -2,12 +2,10 @@
from __future__ import annotations
-from dataclasses import dataclass
import logging
import voluptuous as vol
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_DEVICE_ID, CONF_ENTITY_ID, CONF_NAME, Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import (
@@ -18,7 +16,7 @@ from homeassistant.helpers import (
import homeassistant.helpers.config_validation as cv
from .const import ATTR_VIN, CONF_READ_ONLY, DOMAIN
-from .coordinator import BMWDataUpdateCoordinator
+from .coordinator import BMWConfigEntry, BMWDataUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
@@ -49,19 +47,9 @@ PLATFORMS = [
SERVICE_UPDATE_STATE = "update_state"
-type BMWConfigEntry = ConfigEntry[BMWData]
-
-
-@dataclass
-class BMWData:
- """Class to store BMW runtime data."""
-
- coordinator: BMWDataUpdateCoordinator
-
-
@callback
def _async_migrate_options_from_data_if_missing(
- hass: HomeAssistant, entry: ConfigEntry
+ hass: HomeAssistant, entry: BMWConfigEntry
) -> None:
data = dict(entry.data)
options = dict(entry.options)
@@ -85,23 +73,29 @@ async def _async_migrate_entries(
@callback
def update_unique_id(entry: er.RegistryEntry) -> dict[str, str] | None:
replacements = {
- "charging_level_hv": "fuel_and_battery.remaining_battery_percent",
- "fuel_percent": "fuel_and_battery.remaining_fuel_percent",
- "ac_current_limit": "charging_profile.ac_current_limit",
- "charging_start_time": "fuel_and_battery.charging_start_time",
- "charging_end_time": "fuel_and_battery.charging_end_time",
- "charging_status": "fuel_and_battery.charging_status",
- "charging_target": "fuel_and_battery.charging_target",
- "remaining_battery_percent": "fuel_and_battery.remaining_battery_percent",
- "remaining_range_total": "fuel_and_battery.remaining_range_total",
- "remaining_range_electric": "fuel_and_battery.remaining_range_electric",
- "remaining_range_fuel": "fuel_and_battery.remaining_range_fuel",
- "remaining_fuel": "fuel_and_battery.remaining_fuel",
- "remaining_fuel_percent": "fuel_and_battery.remaining_fuel_percent",
- "activity": "climate.activity",
+ Platform.SENSOR.value: {
+ "charging_level_hv": "fuel_and_battery.remaining_battery_percent",
+ "fuel_percent": "fuel_and_battery.remaining_fuel_percent",
+ "ac_current_limit": "charging_profile.ac_current_limit",
+ "charging_start_time": "fuel_and_battery.charging_start_time",
+ "charging_end_time": "fuel_and_battery.charging_end_time",
+ "charging_status": "fuel_and_battery.charging_status",
+ "charging_target": "fuel_and_battery.charging_target",
+ "remaining_battery_percent": "fuel_and_battery.remaining_battery_percent",
+ "remaining_range_total": "fuel_and_battery.remaining_range_total",
+ "remaining_range_electric": "fuel_and_battery.remaining_range_electric",
+ "remaining_range_fuel": "fuel_and_battery.remaining_range_fuel",
+ "remaining_fuel": "fuel_and_battery.remaining_fuel",
+ "remaining_fuel_percent": "fuel_and_battery.remaining_fuel_percent",
+ "activity": "climate.activity",
+ }
}
- if (key := entry.unique_id.split("-")[-1]) in replacements:
- new_unique_id = entry.unique_id.replace(key, replacements[key])
+ if (key := entry.unique_id.split("-")[-1]) in replacements.get(
+ entry.domain, []
+ ):
+ new_unique_id = entry.unique_id.replace(
+ key, replacements[entry.domain][key]
+ )
_LOGGER.debug(
"Migrating entity '%s' unique_id from '%s' to '%s'",
entry.entity_id,
@@ -127,7 +121,7 @@ async def _async_migrate_entries(
return True
-async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_setup_entry(hass: HomeAssistant, entry: BMWConfigEntry) -> bool:
"""Set up BMW Connected Drive from a config entry."""
_async_migrate_options_from_data_if_missing(hass, entry)
@@ -137,11 +131,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
# Set up one data coordinator per account/config entry
coordinator = BMWDataUpdateCoordinator(
hass,
- entry=entry,
+ config_entry=entry,
)
await coordinator.async_config_entry_first_refresh()
- entry.runtime_data = BMWData(coordinator)
+ entry.runtime_data = coordinator
# Set up all platforms except notify
await hass.config_entries.async_forward_entry_setups(
@@ -175,7 +169,7 @@ 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: BMWConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(
diff --git a/homeassistant/components/bmw_connected_drive/binary_sensor.py b/homeassistant/components/bmw_connected_drive/binary_sensor.py
index 65bdfca997b..5a58c707d6a 100644
--- a/homeassistant/components/bmw_connected_drive/binary_sensor.py
+++ b/homeassistant/components/bmw_connected_drive/binary_sensor.py
@@ -26,6 +26,8 @@ from .const import UNIT_MAP
from .coordinator import BMWDataUpdateCoordinator
from .entity import BMWBaseEntity
+PARALLEL_UPDATES = 0
+
_LOGGER = logging.getLogger(__name__)
@@ -201,7 +203,7 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the BMW binary sensors from config entry."""
- coordinator = config_entry.runtime_data.coordinator
+ coordinator = config_entry.runtime_data
entities = [
BMWBinarySensor(coordinator, vehicle, description, hass.config.units)
diff --git a/homeassistant/components/bmw_connected_drive/button.py b/homeassistant/components/bmw_connected_drive/button.py
index e6bd92b92d7..a7c31d0ef79 100644
--- a/homeassistant/components/bmw_connected_drive/button.py
+++ b/homeassistant/components/bmw_connected_drive/button.py
@@ -16,12 +16,14 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from . import BMWConfigEntry
+from . import DOMAIN as BMW_DOMAIN, BMWConfigEntry
from .entity import BMWBaseEntity
if TYPE_CHECKING:
from .coordinator import BMWDataUpdateCoordinator
+PARALLEL_UPDATES = 1
+
_LOGGER = logging.getLogger(__name__)
@@ -53,7 +55,6 @@ BUTTON_TYPES: tuple[BMWButtonEntityDescription, ...] = (
BMWButtonEntityDescription(
key="deactivate_air_conditioning",
translation_key="deactivate_air_conditioning",
- name="Deactivate air conditioning",
remote_function=lambda vehicle: vehicle.remote_services.trigger_remote_air_conditioning_stop(),
is_available=lambda vehicle: vehicle.is_remote_climate_stop_enabled,
),
@@ -71,7 +72,7 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the BMW buttons from config entry."""
- coordinator = config_entry.runtime_data.coordinator
+ coordinator = config_entry.runtime_data
entities: list[BMWButton] = []
@@ -109,6 +110,10 @@ class BMWButton(BMWBaseEntity, ButtonEntity):
try:
await self.entity_description.remote_function(self.vehicle)
except MyBMWAPIError as ex:
- raise HomeAssistantError(ex) from ex
+ raise HomeAssistantError(
+ translation_domain=BMW_DOMAIN,
+ translation_key="remote_service_error",
+ translation_placeholders={"exception": str(ex)},
+ ) from ex
self.coordinator.async_update_listeners()
diff --git a/homeassistant/components/bmw_connected_drive/config_flow.py b/homeassistant/components/bmw_connected_drive/config_flow.py
index 409bfdca6f1..5a067d23474 100644
--- a/homeassistant/components/bmw_connected_drive/config_flow.py
+++ b/homeassistant/components/bmw_connected_drive/config_flow.py
@@ -18,7 +18,6 @@ import voluptuous as vol
from homeassistant.config_entries import (
SOURCE_REAUTH,
SOURCE_RECONFIGURE,
- ConfigEntry,
ConfigFlow,
ConfigFlowResult,
OptionsFlow,
@@ -27,9 +26,19 @@ from homeassistant.const import CONF_PASSWORD, CONF_REGION, CONF_SOURCE, CONF_US
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.selector import SelectSelector, SelectSelectorConfig
+from homeassistant.util.ssl import get_default_context
from . import DOMAIN
-from .const import CONF_ALLOWED_REGIONS, CONF_GCID, CONF_READ_ONLY, CONF_REFRESH_TOKEN
+from .const import (
+ CONF_ALLOWED_REGIONS,
+ CONF_CAPTCHA_REGIONS,
+ CONF_CAPTCHA_TOKEN,
+ CONF_CAPTCHA_URL,
+ CONF_GCID,
+ CONF_READ_ONLY,
+ CONF_REFRESH_TOKEN,
+)
+from .coordinator import BMWConfigEntry
DATA_SCHEMA = vol.Schema(
{
@@ -41,7 +50,20 @@ DATA_SCHEMA = vol.Schema(
translation_key="regions",
)
),
- }
+ },
+ extra=vol.REMOVE_EXTRA,
+)
+RECONFIGURE_SCHEMA = vol.Schema(
+ {
+ vol.Required(CONF_PASSWORD): str,
+ },
+ extra=vol.REMOVE_EXTRA,
+)
+CAPTCHA_SCHEMA = vol.Schema(
+ {
+ vol.Required(CONF_CAPTCHA_TOKEN): str,
+ },
+ extra=vol.REMOVE_EXTRA,
)
@@ -54,6 +76,8 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str,
data[CONF_USERNAME],
data[CONF_PASSWORD],
get_region_from_name(data[CONF_REGION]),
+ hcaptcha_token=data.get(CONF_CAPTCHA_TOKEN),
+ verify=get_default_context(),
)
try:
@@ -79,39 +103,54 @@ class BMWConfigFlow(ConfigFlow, domain=DOMAIN):
VERSION = 1
- _existing_entry_data: Mapping[str, Any] | None = None
+ def __init__(self) -> None:
+ """Initialize the config flow."""
+ self.data: dict[str, Any] = {}
+ self._existing_entry_data: dict[str, Any] = {}
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
- errors: dict[str, str] = {}
+ errors: dict[str, str] = self.data.pop("errors", {})
- if user_input is not None:
+ if user_input is not None and not errors:
unique_id = f"{user_input[CONF_REGION]}-{user_input[CONF_USERNAME]}"
await self.async_set_unique_id(unique_id)
- if self.source in {SOURCE_REAUTH, SOURCE_RECONFIGURE}:
- self._abort_if_unique_id_mismatch(reason="account_mismatch")
- else:
+ # Unique ID cannot change for reauth/reconfigure
+ if self.source not in {SOURCE_REAUTH, SOURCE_RECONFIGURE}:
self._abort_if_unique_id_configured()
+ # Store user input for later use
+ self.data.update(user_input)
+
+ # North America and Rest of World require captcha token
+ if (
+ self.data.get(CONF_REGION) in CONF_CAPTCHA_REGIONS
+ and CONF_CAPTCHA_TOKEN not in self.data
+ ):
+ return await self.async_step_captcha()
+
info = None
try:
- info = await validate_input(self.hass, user_input)
- entry_data = {
- **user_input,
- CONF_REFRESH_TOKEN: info.get(CONF_REFRESH_TOKEN),
- CONF_GCID: info.get(CONF_GCID),
- }
+ info = await validate_input(self.hass, self.data)
except MissingCaptcha:
errors["base"] = "missing_captcha"
except CannotConnect:
errors["base"] = "cannot_connect"
except InvalidAuth:
errors["base"] = "invalid_auth"
+ finally:
+ self.data.pop(CONF_CAPTCHA_TOKEN, None)
if info:
+ entry_data = {
+ **self.data,
+ CONF_REFRESH_TOKEN: info.get(CONF_REFRESH_TOKEN),
+ CONF_GCID: info.get(CONF_GCID),
+ }
+
if self.source == SOURCE_REAUTH:
return self.async_update_reload_and_abort(
self._get_reauth_entry(), data=entry_data
@@ -128,29 +167,61 @@ class BMWConfigFlow(ConfigFlow, domain=DOMAIN):
schema = self.add_suggested_values_to_schema(
DATA_SCHEMA,
- self._existing_entry_data,
+ self._existing_entry_data or self.data,
)
return self.async_show_form(step_id="user", data_schema=schema, errors=errors)
+ async def async_step_change_password(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Show the change password step."""
+ if user_input is not None:
+ return await self.async_step_user(self._existing_entry_data | user_input)
+
+ return self.async_show_form(
+ step_id="change_password",
+ data_schema=RECONFIGURE_SCHEMA,
+ description_placeholders={
+ CONF_USERNAME: self._existing_entry_data[CONF_USERNAME],
+ CONF_REGION: self._existing_entry_data[CONF_REGION],
+ },
+ )
+
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Handle configuration by re-auth."""
- self._existing_entry_data = entry_data
- return await self.async_step_user()
+ self._existing_entry_data = dict(entry_data)
+ return await self.async_step_change_password()
async def async_step_reconfigure(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle a reconfiguration flow initialized by the user."""
- self._existing_entry_data = self._get_reconfigure_entry().data
- return await self.async_step_user()
+ self._existing_entry_data = dict(self._get_reconfigure_entry().data)
+ return await self.async_step_change_password()
+
+ async def async_step_captcha(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Show captcha form."""
+ if user_input and user_input.get(CONF_CAPTCHA_TOKEN):
+ self.data[CONF_CAPTCHA_TOKEN] = user_input[CONF_CAPTCHA_TOKEN].strip()
+ return await self.async_step_user(self.data)
+
+ return self.async_show_form(
+ step_id="captcha",
+ data_schema=CAPTCHA_SCHEMA,
+ description_placeholders={
+ "captcha_url": CONF_CAPTCHA_URL.format(region=self.data[CONF_REGION])
+ },
+ )
@staticmethod
@callback
def async_get_options_flow(
- config_entry: ConfigEntry,
+ config_entry: BMWConfigEntry,
) -> BMWOptionsFlow:
"""Return a MyBMW option flow."""
return BMWOptionsFlow()
diff --git a/homeassistant/components/bmw_connected_drive/const.py b/homeassistant/components/bmw_connected_drive/const.py
index 98d4acbfc91..750289e9d0a 100644
--- a/homeassistant/components/bmw_connected_drive/const.py
+++ b/homeassistant/components/bmw_connected_drive/const.py
@@ -8,10 +8,15 @@ ATTR_DIRECTION = "direction"
ATTR_VIN = "vin"
CONF_ALLOWED_REGIONS = ["china", "north_america", "rest_of_world"]
+CONF_CAPTCHA_REGIONS = ["north_america", "rest_of_world"]
CONF_READ_ONLY = "read_only"
CONF_ACCOUNT = "account"
CONF_REFRESH_TOKEN = "refresh_token"
CONF_GCID = "gcid"
+CONF_CAPTCHA_TOKEN = "captcha_token"
+CONF_CAPTCHA_URL = (
+ "https://bimmer-connected.readthedocs.io/en/stable/captcha/{region}.html"
+)
DATA_HASS_CONFIG = "hass_config"
diff --git a/homeassistant/components/bmw_connected_drive/coordinator.py b/homeassistant/components/bmw_connected_drive/coordinator.py
index d38b7ffacc2..b54d9245bbd 100644
--- a/homeassistant/components/bmw_connected_drive/coordinator.py
+++ b/homeassistant/components/bmw_connected_drive/coordinator.py
@@ -22,39 +22,51 @@ from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util.ssl import get_default_context
-from .const import CONF_GCID, CONF_READ_ONLY, CONF_REFRESH_TOKEN, DOMAIN, SCAN_INTERVALS
+from .const import (
+ CONF_GCID,
+ CONF_READ_ONLY,
+ CONF_REFRESH_TOKEN,
+ DOMAIN as BMW_DOMAIN,
+ SCAN_INTERVALS,
+)
_LOGGER = logging.getLogger(__name__)
+type BMWConfigEntry = ConfigEntry[BMWDataUpdateCoordinator]
+
+
class BMWDataUpdateCoordinator(DataUpdateCoordinator[None]):
"""Class to manage fetching BMW data."""
account: MyBMWAccount
+ config_entry: BMWConfigEntry
- def __init__(self, hass: HomeAssistant, *, entry: ConfigEntry) -> None:
+ def __init__(self, hass: HomeAssistant, *, config_entry: BMWConfigEntry) -> None:
"""Initialize account-wide BMW data updater."""
self.account = MyBMWAccount(
- entry.data[CONF_USERNAME],
- entry.data[CONF_PASSWORD],
- get_region_from_name(entry.data[CONF_REGION]),
+ config_entry.data[CONF_USERNAME],
+ config_entry.data[CONF_PASSWORD],
+ get_region_from_name(config_entry.data[CONF_REGION]),
observer_position=GPSPosition(hass.config.latitude, hass.config.longitude),
verify=get_default_context(),
)
- self.read_only = entry.options[CONF_READ_ONLY]
- self._entry = entry
+ self.read_only: bool = config_entry.options[CONF_READ_ONLY]
- if CONF_REFRESH_TOKEN in entry.data:
+ if CONF_REFRESH_TOKEN in config_entry.data:
self.account.set_refresh_token(
- refresh_token=entry.data[CONF_REFRESH_TOKEN],
- gcid=entry.data.get(CONF_GCID),
+ refresh_token=config_entry.data[CONF_REFRESH_TOKEN],
+ gcid=config_entry.data.get(CONF_GCID),
)
super().__init__(
hass,
_LOGGER,
- name=f"{DOMAIN}-{entry.data['username']}",
- update_interval=timedelta(seconds=SCAN_INTERVALS[entry.data[CONF_REGION]]),
+ config_entry=config_entry,
+ name=f"{BMW_DOMAIN}-{config_entry.data[CONF_USERNAME]}",
+ update_interval=timedelta(
+ seconds=SCAN_INTERVALS[config_entry.data[CONF_REGION]]
+ ),
)
# Default to false on init so _async_update_data logic works
@@ -69,33 +81,39 @@ class BMWDataUpdateCoordinator(DataUpdateCoordinator[None]):
except MyBMWCaptchaMissingError as err:
# If a captcha is required (user/password login flow), always trigger the reauth flow
raise ConfigEntryAuthFailed(
- translation_domain=DOMAIN,
+ translation_domain=BMW_DOMAIN,
translation_key="missing_captcha",
) from err
except MyBMWAuthError as err:
# Allow one retry interval before raising AuthFailed to avoid flaky API issues
if self.last_update_success:
- raise UpdateFailed(err) from err
+ raise UpdateFailed(
+ translation_domain=BMW_DOMAIN,
+ translation_key="update_failed",
+ translation_placeholders={"exception": str(err)},
+ ) from err
# Clear refresh token and trigger reauth if previous update failed as well
self._update_config_entry_refresh_token(None)
- raise ConfigEntryAuthFailed(err) from err
+ raise ConfigEntryAuthFailed(
+ translation_domain=BMW_DOMAIN,
+ translation_key="invalid_auth",
+ ) from err
except (MyBMWAPIError, RequestError) as err:
- raise UpdateFailed(err) from err
+ raise UpdateFailed(
+ translation_domain=BMW_DOMAIN,
+ translation_key="update_failed",
+ translation_placeholders={"exception": str(err)},
+ ) from err
if self.account.refresh_token != old_refresh_token:
self._update_config_entry_refresh_token(self.account.refresh_token)
- _LOGGER.debug(
- "bimmer_connected: refresh token %s > %s",
- old_refresh_token,
- self.account.refresh_token,
- )
def _update_config_entry_refresh_token(self, refresh_token: str | None) -> None:
"""Update or delete the refresh_token in the Config Entry."""
data = {
- **self._entry.data,
+ **self.config_entry.data,
CONF_REFRESH_TOKEN: refresh_token,
}
if not refresh_token:
data.pop(CONF_REFRESH_TOKEN)
- self.hass.config_entries.async_update_entry(self._entry, data=data)
+ self.hass.config_entries.async_update_entry(self.config_entry, data=data)
diff --git a/homeassistant/components/bmw_connected_drive/device_tracker.py b/homeassistant/components/bmw_connected_drive/device_tracker.py
index 977fd531e2c..74df8693f7a 100644
--- a/homeassistant/components/bmw_connected_drive/device_tracker.py
+++ b/homeassistant/components/bmw_connected_drive/device_tracker.py
@@ -16,6 +16,8 @@ from .const import ATTR_DIRECTION
from .coordinator import BMWDataUpdateCoordinator
from .entity import BMWBaseEntity
+PARALLEL_UPDATES = 0
+
_LOGGER = logging.getLogger(__name__)
@@ -25,7 +27,7 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the MyBMW tracker from config entry."""
- coordinator = config_entry.runtime_data.coordinator
+ coordinator = config_entry.runtime_data
entities: list[BMWDeviceTracker] = []
for vehicle in coordinator.account.vehicles:
@@ -47,7 +49,7 @@ class BMWDeviceTracker(BMWBaseEntity, TrackerEntity):
_attr_force_update = False
_attr_translation_key = "car"
- _attr_icon = "mdi:car"
+ _attr_name = None
def __init__(
self,
@@ -56,9 +58,7 @@ class BMWDeviceTracker(BMWBaseEntity, TrackerEntity):
) -> None:
"""Initialize the Tracker."""
super().__init__(coordinator, vehicle)
-
self._attr_unique_id = vehicle.vin
- self._attr_name = None
@property
def extra_state_attributes(self) -> dict[str, Any]:
diff --git a/homeassistant/components/bmw_connected_drive/diagnostics.py b/homeassistant/components/bmw_connected_drive/diagnostics.py
index ff3c6f29559..3f357c3ae79 100644
--- a/homeassistant/components/bmw_connected_drive/diagnostics.py
+++ b/homeassistant/components/bmw_connected_drive/diagnostics.py
@@ -16,6 +16,8 @@ from homeassistant.helpers.device_registry import DeviceEntry
from . import BMWConfigEntry
from .const import CONF_REFRESH_TOKEN
+PARALLEL_UPDATES = 1
+
if TYPE_CHECKING:
from bimmer_connected.vehicle import MyBMWVehicle
@@ -49,7 +51,7 @@ async def async_get_config_entry_diagnostics(
hass: HomeAssistant, config_entry: BMWConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
- coordinator = config_entry.runtime_data.coordinator
+ coordinator = config_entry.runtime_data
coordinator.account.config.log_responses = True
await coordinator.account.get_vehicles(force_init=True)
@@ -75,7 +77,7 @@ async def async_get_device_diagnostics(
hass: HomeAssistant, config_entry: BMWConfigEntry, device: DeviceEntry
) -> dict[str, Any]:
"""Return diagnostics for a device."""
- coordinator = config_entry.runtime_data.coordinator
+ coordinator = config_entry.runtime_data
coordinator.account.config.log_responses = True
await coordinator.account.get_vehicles(force_init=True)
diff --git a/homeassistant/components/bmw_connected_drive/lock.py b/homeassistant/components/bmw_connected_drive/lock.py
index 3dfc0b1c4d4..4bec12e796b 100644
--- a/homeassistant/components/bmw_connected_drive/lock.py
+++ b/homeassistant/components/bmw_connected_drive/lock.py
@@ -14,11 +14,14 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from . import BMWConfigEntry
+from . import DOMAIN as BMW_DOMAIN, BMWConfigEntry
from .coordinator import BMWDataUpdateCoordinator
from .entity import BMWBaseEntity
+PARALLEL_UPDATES = 1
+
DOOR_LOCK_STATE = "door_lock_state"
+
_LOGGER = logging.getLogger(__name__)
@@ -28,7 +31,7 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the MyBMW lock from config entry."""
- coordinator = config_entry.runtime_data.coordinator
+ coordinator = config_entry.runtime_data
if not coordinator.read_only:
async_add_entities(
@@ -67,7 +70,11 @@ class BMWLock(BMWBaseEntity, LockEntity):
# Set the state to unknown if the command fails
self._attr_is_locked = None
self.async_write_ha_state()
- raise HomeAssistantError(ex) from ex
+ raise HomeAssistantError(
+ translation_domain=BMW_DOMAIN,
+ translation_key="remote_service_error",
+ translation_placeholders={"exception": str(ex)},
+ ) from ex
finally:
# Always update the listeners to get the latest state
self.coordinator.async_update_listeners()
@@ -87,7 +94,11 @@ class BMWLock(BMWBaseEntity, LockEntity):
# Set the state to unknown if the command fails
self._attr_is_locked = None
self.async_write_ha_state()
- raise HomeAssistantError(ex) from ex
+ raise HomeAssistantError(
+ translation_domain=BMW_DOMAIN,
+ translation_key="remote_service_error",
+ translation_placeholders={"exception": str(ex)},
+ ) from ex
finally:
# Always update the listeners to get the latest state
self.coordinator.async_update_listeners()
diff --git a/homeassistant/components/bmw_connected_drive/manifest.json b/homeassistant/components/bmw_connected_drive/manifest.json
index 584eb1eebb5..81928a59a52 100644
--- a/homeassistant/components/bmw_connected_drive/manifest.json
+++ b/homeassistant/components/bmw_connected_drive/manifest.json
@@ -6,6 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/bmw_connected_drive",
"iot_class": "cloud_polling",
"loggers": ["bimmer_connected"],
- "quality_scale": "platinum",
- "requirements": ["bimmer-connected[china]==0.16.4"]
+ "requirements": ["bimmer-connected[china]==0.17.2"]
}
diff --git a/homeassistant/components/bmw_connected_drive/notify.py b/homeassistant/components/bmw_connected_drive/notify.py
index 56523351e66..dfa0939e81f 100644
--- a/homeassistant/components/bmw_connected_drive/notify.py
+++ b/homeassistant/components/bmw_connected_drive/notify.py
@@ -20,7 +20,9 @@ from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
-from . import DOMAIN, BMWConfigEntry
+from . import DOMAIN as BMW_DOMAIN, BMWConfigEntry
+
+PARALLEL_UPDATES = 1
ATTR_LOCATION_ATTRIBUTES = ["street", "city", "postal_code", "country"]
@@ -51,7 +53,7 @@ def get_service(
targets = {}
if (
config_entry
- and (coordinator := config_entry.runtime_data.coordinator)
+ and (coordinator := config_entry.runtime_data)
and not coordinator.read_only
):
targets.update({v.name: v for v in coordinator.account.vehicles})
@@ -90,7 +92,7 @@ class BMWNotificationService(BaseNotificationService):
except (vol.Invalid, TypeError, ValueError) as ex:
raise ServiceValidationError(
- translation_domain=DOMAIN,
+ translation_domain=BMW_DOMAIN,
translation_key="invalid_poi",
translation_placeholders={
"poi_exception": str(ex),
@@ -104,4 +106,8 @@ class BMWNotificationService(BaseNotificationService):
try:
await vehicle.remote_services.trigger_send_poi(poi)
except MyBMWAPIError as ex:
- raise HomeAssistantError(ex) from ex
+ raise HomeAssistantError(
+ translation_domain=BMW_DOMAIN,
+ translation_key="remote_service_error",
+ translation_placeholders={"exception": str(ex)},
+ ) from ex
diff --git a/homeassistant/components/bmw_connected_drive/number.py b/homeassistant/components/bmw_connected_drive/number.py
index 54519ff9e6b..c6a328ecc20 100644
--- a/homeassistant/components/bmw_connected_drive/number.py
+++ b/homeassistant/components/bmw_connected_drive/number.py
@@ -18,10 +18,12 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from . import BMWConfigEntry
+from . import DOMAIN as BMW_DOMAIN, BMWConfigEntry
from .coordinator import BMWDataUpdateCoordinator
from .entity import BMWBaseEntity
+PARALLEL_UPDATES = 1
+
_LOGGER = logging.getLogger(__name__)
@@ -59,7 +61,7 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the MyBMW number from config entry."""
- coordinator = config_entry.runtime_data.coordinator
+ coordinator = config_entry.runtime_data
entities: list[BMWNumber] = []
@@ -107,6 +109,10 @@ class BMWNumber(BMWBaseEntity, NumberEntity):
try:
await self.entity_description.remote_service(self.vehicle, value)
except MyBMWAPIError as ex:
- raise HomeAssistantError(ex) from ex
+ raise HomeAssistantError(
+ translation_domain=BMW_DOMAIN,
+ translation_key="remote_service_error",
+ translation_placeholders={"exception": str(ex)},
+ ) from ex
self.coordinator.async_update_listeners()
diff --git a/homeassistant/components/bmw_connected_drive/select.py b/homeassistant/components/bmw_connected_drive/select.py
index 323768ad9eb..385b45fd9fa 100644
--- a/homeassistant/components/bmw_connected_drive/select.py
+++ b/homeassistant/components/bmw_connected_drive/select.py
@@ -15,10 +15,12 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from . import BMWConfigEntry
+from . import DOMAIN as BMW_DOMAIN, BMWConfigEntry
from .coordinator import BMWDataUpdateCoordinator
from .entity import BMWBaseEntity
+PARALLEL_UPDATES = 1
+
_LOGGER = logging.getLogger(__name__)
@@ -66,7 +68,7 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the MyBMW lock from config entry."""
- coordinator = config_entry.runtime_data.coordinator
+ coordinator = config_entry.runtime_data
entities: list[BMWSelect] = []
@@ -121,6 +123,10 @@ class BMWSelect(BMWBaseEntity, SelectEntity):
try:
await self.entity_description.remote_service(self.vehicle, option)
except MyBMWAPIError as ex:
- raise HomeAssistantError(ex) from ex
+ raise HomeAssistantError(
+ translation_domain=BMW_DOMAIN,
+ translation_key="remote_service_error",
+ translation_placeholders={"exception": str(ex)},
+ ) from ex
self.coordinator.async_update_listeners()
diff --git a/homeassistant/components/bmw_connected_drive/sensor.py b/homeassistant/components/bmw_connected_drive/sensor.py
index e24e2dd75f6..b7be367d57d 100644
--- a/homeassistant/components/bmw_connected_drive/sensor.py
+++ b/homeassistant/components/bmw_connected_drive/sensor.py
@@ -34,6 +34,8 @@ from . import BMWConfigEntry
from .coordinator import BMWDataUpdateCoordinator
from .entity import BMWBaseEntity
+PARALLEL_UPDATES = 0
+
_LOGGER = logging.getLogger(__name__)
@@ -191,7 +193,7 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the MyBMW sensors from config entry."""
- coordinator = config_entry.runtime_data.coordinator
+ coordinator = config_entry.runtime_data
entities = [
BMWSensor(coordinator, vehicle, description)
diff --git a/homeassistant/components/bmw_connected_drive/strings.json b/homeassistant/components/bmw_connected_drive/strings.json
index 0e7a4a32ef4..edb0d5cfb12 100644
--- a/homeassistant/components/bmw_connected_drive/strings.json
+++ b/homeassistant/components/bmw_connected_drive/strings.json
@@ -2,10 +2,35 @@
"config": {
"step": {
"user": {
+ "description": "Connect to your MyBMW/MINI Connected account to retrieve vehicle data.",
"data": {
"username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]",
"region": "ConnectedDrive Region"
+ },
+ "data_description": {
+ "username": "The email address of your MyBMW/MINI Connected account.",
+ "password": "The password of your MyBMW/MINI Connected account.",
+ "region": "The region of your MyBMW/MINI Connected account."
+ }
+ },
+ "captcha": {
+ "title": "Are you a robot?",
+ "description": "A captcha is required for BMW login. Visit the external website to complete the challenge and submit the form. Copy the resulting token into the field below.\n\n{captcha_url}\n\nNo data will be exposed outside of your Home Assistant instance.",
+ "data": {
+ "captcha_token": "Captcha token"
+ },
+ "data_description": {
+ "captcha_token": "One-time token retrieved from the captcha challenge."
+ }
+ },
+ "change_password": {
+ "description": "Update your MyBMW/MINI Connected password for account `{username}` in region `{region}`.",
+ "data": {
+ "password": "[%key:common::config_flow::data::password%]"
+ },
+ "data_description": {
+ "password": "[%key:component::bmw_connected_drive::config::step::user::data_description::password%]"
}
}
},
@@ -17,15 +42,17 @@
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
- "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
- "account_mismatch": "Username and region are not allowed to change"
+ "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
}
},
"options": {
"step": {
"account_options": {
"data": {
- "read_only": "Read-only (only sensors and notify, no execution of services, no lock)"
+ "read_only": "Read-only mode"
+ },
+ "data_description": {
+ "read_only": "Only retrieve values and send POI data, but don't offer any services that can change the vehicle state."
}
}
}
@@ -67,6 +94,9 @@
"activate_air_conditioning": {
"name": "Activate air conditioning"
},
+ "deactivate_air_conditioning": {
+ "name": "Deactivate air conditioning"
+ },
"find_vehicle": {
"name": "Find vehicle"
}
@@ -204,6 +234,15 @@
},
"missing_captcha": {
"message": "Login requires captcha validation"
+ },
+ "invalid_auth": {
+ "message": "[%key:common::config_flow::error::invalid_auth%]"
+ },
+ "remote_service_error": {
+ "message": "Error executing remote service on vehicle. {exception}"
+ },
+ "update_failed": {
+ "message": "Error updating vehicle data. {exception}"
}
}
}
diff --git a/homeassistant/components/bmw_connected_drive/switch.py b/homeassistant/components/bmw_connected_drive/switch.py
index e8a02efdcfc..600ad41165a 100644
--- a/homeassistant/components/bmw_connected_drive/switch.py
+++ b/homeassistant/components/bmw_connected_drive/switch.py
@@ -14,10 +14,12 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from . import BMWConfigEntry
+from . import DOMAIN as BMW_DOMAIN, BMWConfigEntry
from .coordinator import BMWDataUpdateCoordinator
from .entity import BMWBaseEntity
+PARALLEL_UPDATES = 1
+
_LOGGER = logging.getLogger(__name__)
@@ -67,7 +69,7 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the MyBMW switch from config entry."""
- coordinator = config_entry.runtime_data.coordinator
+ coordinator = config_entry.runtime_data
entities: list[BMWSwitch] = []
@@ -109,8 +111,11 @@ class BMWSwitch(BMWBaseEntity, SwitchEntity):
try:
await self.entity_description.remote_service_on(self.vehicle)
except MyBMWAPIError as ex:
- raise HomeAssistantError(ex) from ex
-
+ raise HomeAssistantError(
+ translation_domain=BMW_DOMAIN,
+ translation_key="remote_service_error",
+ translation_placeholders={"exception": str(ex)},
+ ) from ex
self.coordinator.async_update_listeners()
async def async_turn_off(self, **kwargs: Any) -> None:
@@ -118,6 +123,9 @@ class BMWSwitch(BMWBaseEntity, SwitchEntity):
try:
await self.entity_description.remote_service_off(self.vehicle)
except MyBMWAPIError as ex:
- raise HomeAssistantError(ex) from ex
-
+ raise HomeAssistantError(
+ translation_domain=BMW_DOMAIN,
+ translation_key="remote_service_error",
+ translation_placeholders={"exception": str(ex)},
+ ) from ex
self.coordinator.async_update_listeners()
diff --git a/homeassistant/components/bond/manifest.json b/homeassistant/components/bond/manifest.json
index 08e4fb007b7..1d4c110f4fd 100644
--- a/homeassistant/components/bond/manifest.json
+++ b/homeassistant/components/bond/manifest.json
@@ -6,7 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/bond",
"iot_class": "local_push",
"loggers": ["bond_async"],
- "quality_scale": "platinum",
"requirements": ["bond-async==0.2.1"],
"zeroconf": ["_bond._tcp.local."]
}
diff --git a/homeassistant/components/bring/config_flow.py b/homeassistant/components/bring/config_flow.py
index 606c280cf8d..b8ee9d1e6ae 100644
--- a/homeassistant/components/bring/config_flow.py
+++ b/homeassistant/components/bring/config_flow.py
@@ -85,6 +85,7 @@ class BringConfigFlow(ConfigFlow, domain=DOMAIN):
if user_input is not None:
if not (errors := await self.validate_input(user_input)):
+ self._abort_if_unique_id_mismatch()
return self.async_update_reload_and_abort(
self.reauth_entry, data=user_input
)
diff --git a/homeassistant/components/bring/const.py b/homeassistant/components/bring/const.py
index d44b7eb9423..911c08a835d 100644
--- a/homeassistant/components/bring/const.py
+++ b/homeassistant/components/bring/const.py
@@ -9,4 +9,3 @@ ATTR_ITEM_NAME: Final = "item"
ATTR_NOTIFICATION_TYPE: Final = "message"
SERVICE_PUSH_NOTIFICATION = "send_message"
-UNIT_ITEMS = "items"
diff --git a/homeassistant/components/bring/manifest.json b/homeassistant/components/bring/manifest.json
index ff24a991350..71fe733ccf5 100644
--- a/homeassistant/components/bring/manifest.json
+++ b/homeassistant/components/bring/manifest.json
@@ -6,5 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/bring",
"integration_type": "service",
"iot_class": "cloud_polling",
+ "loggers": ["bring_api"],
"requirements": ["bring-api==0.9.1"]
}
diff --git a/homeassistant/components/bring/quality_scale.yaml b/homeassistant/components/bring/quality_scale.yaml
new file mode 100644
index 00000000000..1fdb3f13f1b
--- /dev/null
+++ b/homeassistant/components/bring/quality_scale.yaml
@@ -0,0 +1,72 @@
+rules:
+ # Bronze
+ action-setup:
+ status: exempt
+ comment: Only entity services
+ appropriate-polling: done
+ brands: done
+ common-modules: done
+ config-flow-test-coverage: done
+ config-flow: todo
+ dependency-transparency: done
+ docs-actions: done
+ docs-high-level-description: todo
+ docs-installation-instructions: todo
+ docs-removal-instructions: todo
+ entity-event-setup:
+ status: exempt
+ comment: The integration registers no 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: todo
+ docs-installation-parameters: todo
+ entity-unavailable: done
+ integration-owner: done
+ log-when-unavailable:
+ status: done
+ comment: handled by coordinator
+ parallel-updates: done
+ reauthentication-flow: done
+ test-coverage: done
+
+ # Gold
+ devices: done
+ diagnostics: done
+ discovery-update-info:
+ status: exempt
+ comment: Integration is a service and has no devices.
+ discovery:
+ status: exempt
+ comment: Integration is a service and has no devices.
+ docs-data-update: todo
+ docs-examples: todo
+ docs-known-limitations: todo
+ docs-supported-devices: todo
+ docs-supported-functions: todo
+ docs-troubleshooting: todo
+ docs-use-cases: todo
+ dynamic-devices: todo
+ entity-category: done
+ entity-device-class: done
+ entity-disabled-by-default: done
+ entity-translations: done
+ exception-translations: todo
+ icon-translations: done
+ reconfiguration-flow: todo
+ repair-issues:
+ status: exempt
+ comment: |
+ no repairs
+ stale-devices: todo
+ # Platinum
+ async-dependency: done
+ inject-websession: done
+ strict-typing: todo
diff --git a/homeassistant/components/bring/sensor.py b/homeassistant/components/bring/sensor.py
index 746ed397e1b..bd33ce9bf88 100644
--- a/homeassistant/components/bring/sensor.py
+++ b/homeassistant/components/bring/sensor.py
@@ -20,11 +20,12 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import StateType
from . import BringConfigEntry
-from .const import UNIT_ITEMS
from .coordinator import BringData, BringDataUpdateCoordinator
from .entity import BringBaseEntity
from .util import list_language, sum_attributes
+PARALLEL_UPDATES = 0
+
@dataclass(kw_only=True, frozen=True)
class BringSensorEntityDescription(SensorEntityDescription):
@@ -48,19 +49,16 @@ SENSOR_DESCRIPTIONS: tuple[BringSensorEntityDescription, ...] = (
key=BringSensor.URGENT,
translation_key=BringSensor.URGENT,
value_fn=lambda lst, _: sum_attributes(lst, "urgent"),
- native_unit_of_measurement=UNIT_ITEMS,
),
BringSensorEntityDescription(
key=BringSensor.CONVENIENT,
translation_key=BringSensor.CONVENIENT,
value_fn=lambda lst, _: sum_attributes(lst, "convenient"),
- native_unit_of_measurement=UNIT_ITEMS,
),
BringSensorEntityDescription(
key=BringSensor.DISCOUNTED,
translation_key=BringSensor.DISCOUNTED,
value_fn=lambda lst, _: sum_attributes(lst, "discounted"),
- native_unit_of_measurement=UNIT_ITEMS,
),
BringSensorEntityDescription(
key=BringSensor.LIST_LANGUAGE,
diff --git a/homeassistant/components/bring/strings.json b/homeassistant/components/bring/strings.json
index 9a93881b5d2..7331f68a161 100644
--- a/homeassistant/components/bring/strings.json
+++ b/homeassistant/components/bring/strings.json
@@ -1,4 +1,7 @@
{
+ "common": {
+ "shopping_list_items": "items"
+ },
"config": {
"step": {
"user": {
@@ -23,19 +26,23 @@
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
- "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
+ "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
+ "unique_id_mismatch": "The login details correspond to a different account. Please re-authenticate to the previously configured account."
}
},
"entity": {
"sensor": {
"urgent": {
- "name": "Urgent"
+ "name": "Urgent",
+ "unit_of_measurement": "[%key:component::bring::common::shopping_list_items%]"
},
"convenient": {
- "name": "On occasion"
+ "name": "On occasion",
+ "unit_of_measurement": "[%key:component::bring::common::shopping_list_items%]"
},
"discounted": {
- "name": "Discount only"
+ "name": "Discount only",
+ "unit_of_measurement": "[%key:component::bring::common::shopping_list_items%]"
},
"list_language": {
"name": "Region & language",
diff --git a/homeassistant/components/bring/todo.py b/homeassistant/components/bring/todo.py
index 319aedc6b80..c53b5788b68 100644
--- a/homeassistant/components/bring/todo.py
+++ b/homeassistant/components/bring/todo.py
@@ -34,6 +34,8 @@ from .const import (
from .coordinator import BringData, BringDataUpdateCoordinator
from .entity import BringBaseEntity
+PARALLEL_UPDATES = 0
+
async def async_setup_entry(
hass: HomeAssistant,
diff --git a/homeassistant/components/broadlink/climate.py b/homeassistant/components/broadlink/climate.py
index dbfd982795c..25a6bbd60a5 100644
--- a/homeassistant/components/broadlink/climate.py
+++ b/homeassistant/components/broadlink/climate.py
@@ -52,7 +52,6 @@ class BroadlinkThermostat(BroadlinkEntity, ClimateEntity):
)
_attr_target_temperature_step = PRECISION_HALVES
_attr_temperature_unit = UnitOfTemperature.CELSIUS
- _enable_turn_on_off_backwards_compatibility = False
def __init__(self, device: BroadlinkDevice) -> None:
"""Initialize the climate entity."""
diff --git a/homeassistant/components/brother/manifest.json b/homeassistant/components/brother/manifest.json
index 4e773a6cff2..fa70f3a5dc5 100644
--- a/homeassistant/components/brother/manifest.json
+++ b/homeassistant/components/brother/manifest.json
@@ -8,7 +8,6 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["brother", "pyasn1", "pysmi", "pysnmp"],
- "quality_scale": "platinum",
"requirements": ["brother==4.3.1"],
"zeroconf": [
{
diff --git a/homeassistant/components/brother/sensor.py b/homeassistant/components/brother/sensor.py
index e86eb59d6bc..d49ebdf07ca 100644
--- a/homeassistant/components/brother/sensor.py
+++ b/homeassistant/components/brother/sensor.py
@@ -30,8 +30,6 @@ from .const import DOMAIN
ATTR_COUNTER = "counter"
ATTR_REMAINING_PAGES = "remaining_pages"
-UNIT_PAGES = "p"
-
_LOGGER = logging.getLogger(__name__)
@@ -52,7 +50,6 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = (
BrotherSensorEntityDescription(
key="page_counter",
translation_key="page_counter",
- native_unit_of_measurement=UNIT_PAGES,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
value=lambda data: data.page_counter,
@@ -60,7 +57,6 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = (
BrotherSensorEntityDescription(
key="bw_counter",
translation_key="bw_pages",
- native_unit_of_measurement=UNIT_PAGES,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
value=lambda data: data.bw_counter,
@@ -68,7 +64,6 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = (
BrotherSensorEntityDescription(
key="color_counter",
translation_key="color_pages",
- native_unit_of_measurement=UNIT_PAGES,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
value=lambda data: data.color_counter,
@@ -76,7 +71,6 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = (
BrotherSensorEntityDescription(
key="duplex_unit_pages_counter",
translation_key="duplex_unit_page_counter",
- native_unit_of_measurement=UNIT_PAGES,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
value=lambda data: data.duplex_unit_pages_counter,
@@ -92,7 +86,6 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = (
BrotherSensorEntityDescription(
key="drum_remaining_pages",
translation_key="drum_remaining_pages",
- native_unit_of_measurement=UNIT_PAGES,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
value=lambda data: data.drum_remaining_pages,
@@ -100,7 +93,6 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = (
BrotherSensorEntityDescription(
key="drum_counter",
translation_key="drum_page_counter",
- native_unit_of_measurement=UNIT_PAGES,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
value=lambda data: data.drum_counter,
@@ -116,7 +108,6 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = (
BrotherSensorEntityDescription(
key="black_drum_remaining_pages",
translation_key="black_drum_remaining_pages",
- native_unit_of_measurement=UNIT_PAGES,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
value=lambda data: data.black_drum_remaining_pages,
@@ -124,7 +115,6 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = (
BrotherSensorEntityDescription(
key="black_drum_counter",
translation_key="black_drum_page_counter",
- native_unit_of_measurement=UNIT_PAGES,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
value=lambda data: data.black_drum_counter,
@@ -140,7 +130,6 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = (
BrotherSensorEntityDescription(
key="cyan_drum_remaining_pages",
translation_key="cyan_drum_remaining_pages",
- native_unit_of_measurement=UNIT_PAGES,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
value=lambda data: data.cyan_drum_remaining_pages,
@@ -148,7 +137,6 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = (
BrotherSensorEntityDescription(
key="cyan_drum_counter",
translation_key="cyan_drum_page_counter",
- native_unit_of_measurement=UNIT_PAGES,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
value=lambda data: data.cyan_drum_counter,
@@ -164,7 +152,6 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = (
BrotherSensorEntityDescription(
key="magenta_drum_remaining_pages",
translation_key="magenta_drum_remaining_pages",
- native_unit_of_measurement=UNIT_PAGES,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
value=lambda data: data.magenta_drum_remaining_pages,
@@ -172,7 +159,6 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = (
BrotherSensorEntityDescription(
key="magenta_drum_counter",
translation_key="magenta_drum_page_counter",
- native_unit_of_measurement=UNIT_PAGES,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
value=lambda data: data.magenta_drum_counter,
@@ -188,7 +174,6 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = (
BrotherSensorEntityDescription(
key="yellow_drum_remaining_pages",
translation_key="yellow_drum_remaining_pages",
- native_unit_of_measurement=UNIT_PAGES,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
value=lambda data: data.yellow_drum_remaining_pages,
@@ -196,7 +181,6 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = (
BrotherSensorEntityDescription(
key="yellow_drum_counter",
translation_key="yellow_drum_page_counter",
- native_unit_of_measurement=UNIT_PAGES,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
value=lambda data: data.yellow_drum_counter,
diff --git a/homeassistant/components/brother/strings.json b/homeassistant/components/brother/strings.json
index 3b5b38ce9a0..b502ed7e3b9 100644
--- a/homeassistant/components/brother/strings.json
+++ b/homeassistant/components/brother/strings.json
@@ -46,61 +46,75 @@
"name": "Status"
},
"page_counter": {
- "name": "Page counter"
+ "name": "Page counter",
+ "unit_of_measurement": "pages"
},
"bw_pages": {
- "name": "B/W pages"
+ "name": "B/W pages",
+ "unit_of_measurement": "[%key:component::brother::entity::sensor::page_counter::unit_of_measurement%]"
},
"color_pages": {
- "name": "Color pages"
+ "name": "Color pages",
+ "unit_of_measurement": "[%key:component::brother::entity::sensor::page_counter::unit_of_measurement%]"
},
"duplex_unit_page_counter": {
- "name": "Duplex unit page counter"
+ "name": "Duplex unit page counter",
+ "unit_of_measurement": "[%key:component::brother::entity::sensor::page_counter::unit_of_measurement%]"
},
"drum_remaining_life": {
"name": "Drum remaining lifetime"
},
"drum_remaining_pages": {
- "name": "Drum remaining pages"
+ "name": "Drum remaining pages",
+ "unit_of_measurement": "[%key:component::brother::entity::sensor::page_counter::unit_of_measurement%]"
},
"drum_page_counter": {
- "name": "Drum page counter"
+ "name": "Drum page counter",
+ "unit_of_measurement": "[%key:component::brother::entity::sensor::page_counter::unit_of_measurement%]"
},
"black_drum_remaining_life": {
"name": "Black drum remaining lifetime"
},
"black_drum_remaining_pages": {
- "name": "Black drum remaining pages"
+ "name": "Black drum remaining pages",
+ "unit_of_measurement": "[%key:component::brother::entity::sensor::page_counter::unit_of_measurement%]"
},
"black_drum_page_counter": {
- "name": "Black drum page counter"
+ "name": "Black drum page counter",
+ "unit_of_measurement": "[%key:component::brother::entity::sensor::page_counter::unit_of_measurement%]"
},
"cyan_drum_remaining_life": {
"name": "Cyan drum remaining lifetime"
},
"cyan_drum_remaining_pages": {
- "name": "Cyan drum remaining pages"
+ "name": "Cyan drum remaining pages",
+ "unit_of_measurement": "[%key:component::brother::entity::sensor::page_counter::unit_of_measurement%]"
},
"cyan_drum_page_counter": {
- "name": "Cyan drum page counter"
+ "name": "Cyan drum page counter",
+ "unit_of_measurement": "[%key:component::brother::entity::sensor::page_counter::unit_of_measurement%]"
},
"magenta_drum_remaining_life": {
"name": "Magenta drum remaining lifetime"
},
"magenta_drum_remaining_pages": {
- "name": "Magenta drum remaining pages"
+ "name": "Magenta drum remaining pages",
+ "unit_of_measurement": "[%key:component::brother::entity::sensor::page_counter::unit_of_measurement%]"
},
"magenta_drum_page_counter": {
- "name": "Magenta drum page counter"
+ "name": "Magenta drum page counter",
+ "unit_of_measurement": "[%key:component::brother::entity::sensor::page_counter::unit_of_measurement%]"
},
"yellow_drum_remaining_life": {
"name": "Yellow drum remaining lifetime"
},
"yellow_drum_remaining_pages": {
- "name": "Yellow drum remaining pages"
+ "name": "Yellow drum remaining pages",
+ "unit_of_measurement": "[%key:component::brother::entity::sensor::page_counter::unit_of_measurement%]"
},
"yellow_drum_page_counter": {
- "name": "Yellow drum page counter"
+ "name": "Yellow drum page counter",
+ "unit_of_measurement": "[%key:component::brother::entity::sensor::page_counter::unit_of_measurement%]"
},
"belt_unit_remaining_life": {
"name": "Belt unit remaining lifetime"
diff --git a/homeassistant/components/bryant_evolution/climate.py b/homeassistant/components/bryant_evolution/climate.py
index dd31097a1ee..2d54ced8217 100644
--- a/homeassistant/components/bryant_evolution/climate.py
+++ b/homeassistant/components/bryant_evolution/climate.py
@@ -77,7 +77,6 @@ class BryantEvolutionClimate(ClimateEntity):
HVACMode.OFF,
]
_attr_fan_modes = ["auto", "low", "med", "high"]
- _enable_turn_on_off_backwards_compatibility = False
def __init__(
self,
diff --git a/homeassistant/components/bsblan/__init__.py b/homeassistant/components/bsblan/__init__.py
index 4d3c6ee2073..623bfbfef56 100644
--- a/homeassistant/components/bsblan/__init__.py
+++ b/homeassistant/components/bsblan/__init__.py
@@ -18,7 +18,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import CONF_PASSKEY
from .coordinator import BSBLanUpdateCoordinator
-PLATFORMS = [Platform.CLIMATE, Platform.SENSOR]
+PLATFORMS = [Platform.CLIMATE, Platform.SENSOR, Platform.WATER_HEATER]
type BSBLanConfigEntry = ConfigEntry[BSBLanData]
diff --git a/homeassistant/components/bsblan/climate.py b/homeassistant/components/bsblan/climate.py
index fcbe88f2fac..2833d6549b4 100644
--- a/homeassistant/components/bsblan/climate.py
+++ b/homeassistant/components/bsblan/climate.py
@@ -15,7 +15,7 @@ from homeassistant.components.climate import (
ClimateEntityFeature,
HVACMode,
)
-from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
+from homeassistant.const import ATTR_TEMPERATURE
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers.device_registry import format_mac
@@ -65,7 +65,6 @@ class BSBLANClimate(BSBLanEntity, ClimateEntity):
_attr_preset_modes = PRESET_MODES
_attr_hvac_modes = HVAC_MODES
- _enable_turn_on_off_backwards_compatibility = False
def __init__(
self,
@@ -75,26 +74,19 @@ class BSBLANClimate(BSBLanEntity, ClimateEntity):
super().__init__(data.coordinator, data)
self._attr_unique_id = f"{format_mac(data.device.MAC)}-climate"
- self._attr_min_temp = float(data.static.min_temp.value)
- self._attr_max_temp = float(data.static.max_temp.value)
- if data.static.min_temp.unit in ("°C", "°C"):
- self._attr_temperature_unit = UnitOfTemperature.CELSIUS
- else:
- self._attr_temperature_unit = UnitOfTemperature.FAHRENHEIT
+ self._attr_min_temp = data.static.min_temp.value
+ self._attr_max_temp = data.static.max_temp.value
+ self._attr_temperature_unit = data.coordinator.client.get_temperature_unit
@property
def current_temperature(self) -> float | None:
"""Return the current temperature."""
- if self.coordinator.data.state.current_temperature.value == "---":
- # device returns no current temperature
- return None
-
- return float(self.coordinator.data.state.current_temperature.value)
+ return self.coordinator.data.state.current_temperature.value
@property
def target_temperature(self) -> float | None:
"""Return the temperature we try to reach."""
- return float(self.coordinator.data.state.target_temperature.value)
+ return self.coordinator.data.state.target_temperature.value
@property
def hvac_mode(self) -> HVACMode | None:
diff --git a/homeassistant/components/bsblan/coordinator.py b/homeassistant/components/bsblan/coordinator.py
index 1a4299fe72f..be9030d95b0 100644
--- a/homeassistant/components/bsblan/coordinator.py
+++ b/homeassistant/components/bsblan/coordinator.py
@@ -4,7 +4,7 @@ from dataclasses import dataclass
from datetime import timedelta
from random import randint
-from bsblan import BSBLAN, BSBLANConnectionError, Sensor, State
+from bsblan import BSBLAN, BSBLANConnectionError, HotWaterState, Sensor, State
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST
@@ -20,6 +20,7 @@ class BSBLanCoordinatorData:
state: State
sensor: Sensor
+ dhw: HotWaterState
class BSBLanUpdateCoordinator(DataUpdateCoordinator[BSBLanCoordinatorData]):
@@ -59,6 +60,7 @@ class BSBLanUpdateCoordinator(DataUpdateCoordinator[BSBLanCoordinatorData]):
state = await self.client.state()
sensor = await self.client.sensor()
+ dhw = await self.client.hot_water_state()
except BSBLANConnectionError as err:
host = self.config_entry.data[CONF_HOST] if self.config_entry else "unknown"
raise UpdateFailed(
@@ -66,4 +68,4 @@ class BSBLanUpdateCoordinator(DataUpdateCoordinator[BSBLanCoordinatorData]):
) from err
self.update_interval = self._get_update_interval()
- return BSBLanCoordinatorData(state=state, sensor=sensor)
+ return BSBLanCoordinatorData(state=state, sensor=sensor, dhw=dhw)
diff --git a/homeassistant/components/bsblan/sensor.py b/homeassistant/components/bsblan/sensor.py
index eab03d7a50c..c13b4ad7650 100644
--- a/homeassistant/components/bsblan/sensor.py
+++ b/homeassistant/components/bsblan/sensor.py
@@ -72,11 +72,9 @@ class BSBLanSensor(BSBLanEntity, SensorEntity):
super().__init__(data.coordinator, data)
self.entity_description = description
self._attr_unique_id = f"{data.device.MAC}-{description.key}"
+ self._attr_temperature_unit = data.coordinator.client.get_temperature_unit
@property
def native_value(self) -> StateType:
"""Return the state of the sensor."""
- value = self.entity_description.value_fn(self.coordinator.data)
- if value == "---":
- return None
- return value
+ return self.entity_description.value_fn(self.coordinator.data)
diff --git a/homeassistant/components/bsblan/strings.json b/homeassistant/components/bsblan/strings.json
index 4fb374fee75..a73a89ca1cc 100644
--- a/homeassistant/components/bsblan/strings.json
+++ b/homeassistant/components/bsblan/strings.json
@@ -31,6 +31,12 @@
},
"set_data_error": {
"message": "An error occurred while sending the data to the BSBLAN device"
+ },
+ "set_temperature_error": {
+ "message": "An error occurred while setting the temperature"
+ },
+ "set_operation_mode_error": {
+ "message": "An error occurred while setting the operation mode"
}
},
"entity": {
diff --git a/homeassistant/components/bsblan/water_heater.py b/homeassistant/components/bsblan/water_heater.py
new file mode 100644
index 00000000000..318408a9124
--- /dev/null
+++ b/homeassistant/components/bsblan/water_heater.py
@@ -0,0 +1,107 @@
+"""BSBLAN platform to control a compatible Water Heater Device."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from bsblan import BSBLANError
+
+from homeassistant.components.water_heater import (
+ STATE_ECO,
+ STATE_OFF,
+ WaterHeaterEntity,
+ WaterHeaterEntityFeature,
+)
+from homeassistant.const import ATTR_TEMPERATURE, STATE_ON
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers.device_registry import format_mac
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+
+from . import BSBLanConfigEntry, BSBLanData
+from .const import DOMAIN
+from .entity import BSBLanEntity
+
+PARALLEL_UPDATES = 1
+
+# Mapping between BSBLan and HA operation modes
+OPERATION_MODES = {
+ "Eco": STATE_ECO, # Energy saving mode
+ "Off": STATE_OFF, # Protection mode
+ "On": STATE_ON, # Continuous comfort mode
+}
+
+OPERATION_MODES_REVERSE = {v: k for k, v in OPERATION_MODES.items()}
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: BSBLanConfigEntry,
+ async_add_entities: AddEntitiesCallback,
+) -> None:
+ """Set up BSBLAN water heater based on a config entry."""
+ data = entry.runtime_data
+ async_add_entities([BSBLANWaterHeater(data)])
+
+
+class BSBLANWaterHeater(BSBLanEntity, WaterHeaterEntity):
+ """Defines a BSBLAN water heater entity."""
+
+ _attr_name = None
+ _attr_supported_features = (
+ WaterHeaterEntityFeature.TARGET_TEMPERATURE
+ | WaterHeaterEntityFeature.OPERATION_MODE
+ )
+
+ def __init__(self, data: BSBLanData) -> None:
+ """Initialize BSBLAN water heater."""
+ super().__init__(data.coordinator, data)
+ self._attr_unique_id = format_mac(data.device.MAC)
+ self._attr_operation_list = list(OPERATION_MODES_REVERSE.keys())
+
+ # Set temperature limits based on device capabilities
+ self._attr_temperature_unit = data.coordinator.client.get_temperature_unit
+ self._attr_min_temp = data.coordinator.data.dhw.reduced_setpoint.value
+ self._attr_max_temp = data.coordinator.data.dhw.nominal_setpoint_max.value
+
+ @property
+ def current_operation(self) -> str | None:
+ """Return current operation."""
+ current_mode = self.coordinator.data.dhw.operating_mode.desc
+ return OPERATION_MODES.get(current_mode)
+
+ @property
+ def current_temperature(self) -> float | None:
+ """Return the current temperature."""
+ return self.coordinator.data.dhw.dhw_actual_value_top_temperature.value
+
+ @property
+ def target_temperature(self) -> float | None:
+ """Return the temperature we try to reach."""
+ return self.coordinator.data.dhw.nominal_setpoint.value
+
+ async def async_set_temperature(self, **kwargs: Any) -> None:
+ """Set new target temperature."""
+ temperature = kwargs.get(ATTR_TEMPERATURE)
+ try:
+ await self.coordinator.client.set_hot_water(nominal_setpoint=temperature)
+ except BSBLANError as err:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="set_temperature_error",
+ ) from err
+
+ await self.coordinator.async_request_refresh()
+
+ async def async_set_operation_mode(self, operation_mode: str) -> None:
+ """Set new operation mode."""
+ bsblan_mode = OPERATION_MODES_REVERSE.get(operation_mode)
+ try:
+ await self.coordinator.client.set_hot_water(operating_mode=bsblan_mode)
+ except BSBLANError as err:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="set_operation_mode_error",
+ ) from err
+
+ await self.coordinator.async_request_refresh()
diff --git a/homeassistant/components/bt_home_hub_5/manifest.json b/homeassistant/components/bt_home_hub_5/manifest.json
index c2d708d9a02..e260d443dc7 100644
--- a/homeassistant/components/bt_home_hub_5/manifest.json
+++ b/homeassistant/components/bt_home_hub_5/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/bt_home_hub_5",
"iot_class": "local_polling",
"loggers": ["bthomehub5_devicelist"],
+ "quality_scale": "legacy",
"requirements": ["bthomehub5-devicelist==0.1.1"]
}
diff --git a/homeassistant/components/bt_smarthub/manifest.json b/homeassistant/components/bt_smarthub/manifest.json
index 8f2dc631e80..31dd99a493f 100644
--- a/homeassistant/components/bt_smarthub/manifest.json
+++ b/homeassistant/components/bt_smarthub/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/bt_smarthub",
"iot_class": "local_polling",
"loggers": ["btsmarthub_devicelist"],
+ "quality_scale": "legacy",
"requirements": ["btsmarthub-devicelist==0.2.3"]
}
diff --git a/homeassistant/components/buienradar/sensor.py b/homeassistant/components/buienradar/sensor.py
index afce293402e..712f765237e 100644
--- a/homeassistant/components/buienradar/sensor.py
+++ b/homeassistant/components/buienradar/sensor.py
@@ -742,6 +742,7 @@ class BrSensor(SensorEntity):
) -> None:
"""Initialize the sensor."""
self.entity_description = description
+ self._data: BrData | None = None
self._measured = None
self._attr_unique_id = (
f"{coordinates[CONF_LATITUDE]:2.6f}{coordinates[CONF_LONGITUDE]:2.6f}"
@@ -756,17 +757,29 @@ class BrSensor(SensorEntity):
if description.key.startswith(PRECIPITATION_FORECAST):
self._timeframe = None
+ async def async_added_to_hass(self) -> None:
+ """Handle entity being added to hass."""
+ if self._data is None:
+ return
+ self._update()
+
@callback
def data_updated(self, data: BrData):
- """Update data."""
- if self._load_data(data.data) and self.hass:
+ """Handle data update."""
+ self._data = data
+ if not self.hass:
+ return
+ self._update()
+
+ def _update(self):
+ """Update sensor data."""
+ _LOGGER.debug("Updating sensor %s", self.entity_id)
+ if self._load_data(self._data.data):
self.async_write_ha_state()
@callback
def _load_data(self, data): # noqa: C901
"""Load the sensor with relevant data."""
- # Find sensor
-
# Check if we have a new measurement,
# otherwise we do not have to update the sensor
if self._measured == data.get(MEASURED):
diff --git a/homeassistant/components/caldav/manifest.json b/homeassistant/components/caldav/manifest.json
index e0d598e6493..5c1334c8029 100644
--- a/homeassistant/components/caldav/manifest.json
+++ b/homeassistant/components/caldav/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/caldav",
"iot_class": "cloud_polling",
"loggers": ["caldav", "vobject"],
- "requirements": ["caldav==1.3.9"]
+ "requirements": ["caldav==1.3.9", "icalendar==6.1.0"]
}
diff --git a/homeassistant/components/calendar/strings.json b/homeassistant/components/calendar/strings.json
index 76e6c42b666..c0127c20d05 100644
--- a/homeassistant/components/calendar/strings.json
+++ b/homeassistant/components/calendar/strings.json
@@ -82,11 +82,11 @@
},
"end_date_time": {
"name": "End time",
- "description": "Returns active events before this time (exclusive). Cannot be used with 'duration'."
+ "description": "Returns active events before this time (exclusive). Cannot be used with Duration."
},
"duration": {
"name": "Duration",
- "description": "Returns active events from start_date_time until the specified duration."
+ "description": "Returns active events from Start time for the specified duration."
}
}
}
diff --git a/homeassistant/components/cambridge_audio/__init__.py b/homeassistant/components/cambridge_audio/__init__.py
index a584f0db6c1..8b910bb81bb 100644
--- a/homeassistant/components/cambridge_audio/__init__.py
+++ b/homeassistant/components/cambridge_audio/__init__.py
@@ -12,6 +12,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.aiohttp_client import async_get_clientsession
from .const import CONNECT_TIMEOUT, DOMAIN, STREAM_MAGIC_EXCEPTIONS
@@ -27,7 +28,7 @@ async def async_setup_entry(
) -> bool:
"""Set up Cambridge Audio integration from a config entry."""
- client = StreamMagicClient(entry.data[CONF_HOST])
+ client = StreamMagicClient(entry.data[CONF_HOST], async_get_clientsession(hass))
async def _connection_update_callback(
_client: StreamMagicClient, _callback_type: CallbackType
diff --git a/homeassistant/components/cambridge_audio/config_flow.py b/homeassistant/components/cambridge_audio/config_flow.py
index 201e531608d..6f5a92feac0 100644
--- a/homeassistant/components/cambridge_audio/config_flow.py
+++ b/homeassistant/components/cambridge_audio/config_flow.py
@@ -7,11 +7,18 @@ from aiostreammagic import StreamMagicClient
import voluptuous as vol
from homeassistant.components import zeroconf
-from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
+from homeassistant.config_entries import (
+ SOURCE_RECONFIGURE,
+ ConfigFlow,
+ ConfigFlowResult,
+)
from homeassistant.const import CONF_HOST, CONF_NAME
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import CONNECT_TIMEOUT, DOMAIN, STREAM_MAGIC_EXCEPTIONS
+DATA_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str})
+
class CambridgeAudioConfigFlow(ConfigFlow, domain=DOMAIN):
"""Cambridge Audio configuration flow."""
@@ -30,7 +37,7 @@ class CambridgeAudioConfigFlow(ConfigFlow, domain=DOMAIN):
await self.async_set_unique_id(discovery_info.properties["serial"])
self._abort_if_unique_id_configured(updates={CONF_HOST: host})
- client = StreamMagicClient(host)
+ client = StreamMagicClient(host, async_get_clientsession(self.hass))
try:
async with asyncio.timeout(CONNECT_TIMEOUT):
await client.connect()
@@ -63,13 +70,26 @@ class CambridgeAudioConfigFlow(ConfigFlow, domain=DOMAIN):
},
)
+ async def async_step_reconfigure(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Handle reconfiguration of the integration."""
+ if not user_input:
+ return self.async_show_form(
+ step_id="reconfigure",
+ data_schema=DATA_SCHEMA,
+ )
+ return await self.async_step_user(user_input)
+
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:
- client = StreamMagicClient(user_input[CONF_HOST])
+ client = StreamMagicClient(
+ user_input[CONF_HOST], async_get_clientsession(self.hass)
+ )
try:
async with asyncio.timeout(CONNECT_TIMEOUT):
await client.connect()
@@ -79,6 +99,12 @@ class CambridgeAudioConfigFlow(ConfigFlow, domain=DOMAIN):
await self.async_set_unique_id(
client.info.unit_id, 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(
+ self._get_reconfigure_entry(),
+ data_updates={CONF_HOST: user_input[CONF_HOST]},
+ )
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=client.info.name,
@@ -88,6 +114,6 @@ class CambridgeAudioConfigFlow(ConfigFlow, domain=DOMAIN):
await client.disconnect()
return self.async_show_form(
step_id="user",
- data_schema=vol.Schema({vol.Required(CONF_HOST): str}),
+ data_schema=DATA_SCHEMA,
errors=errors,
)
diff --git a/homeassistant/components/cambridge_audio/manifest.json b/homeassistant/components/cambridge_audio/manifest.json
index edacd17f54d..14a389587d2 100644
--- a/homeassistant/components/cambridge_audio/manifest.json
+++ b/homeassistant/components/cambridge_audio/manifest.json
@@ -7,6 +7,7 @@
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["aiostreammagic"],
- "requirements": ["aiostreammagic==2.8.4"],
+ "quality_scale": "platinum",
+ "requirements": ["aiostreammagic==2.10.0"],
"zeroconf": ["_stream-magic._tcp.local.", "_smoip._tcp.local."]
}
diff --git a/homeassistant/components/cambridge_audio/media_browser.py b/homeassistant/components/cambridge_audio/media_browser.py
new file mode 100644
index 00000000000..efe55ee792e
--- /dev/null
+++ b/homeassistant/components/cambridge_audio/media_browser.py
@@ -0,0 +1,85 @@
+"""Support for media browsing."""
+
+from aiostreammagic import StreamMagicClient
+from aiostreammagic.models import Preset
+
+from homeassistant.components.media_player import BrowseMedia, MediaClass
+from homeassistant.core import HomeAssistant
+
+
+async def async_browse_media(
+ hass: HomeAssistant,
+ client: StreamMagicClient,
+ media_content_id: str | None,
+ media_content_type: str | None,
+) -> BrowseMedia:
+ """Browse media."""
+
+ if media_content_type == "presets":
+ return await _presets_payload(client.preset_list.presets)
+
+ return await _root_payload(
+ hass,
+ client,
+ )
+
+
+async def _root_payload(
+ hass: HomeAssistant,
+ client: StreamMagicClient,
+) -> BrowseMedia:
+ """Return root payload for Cambridge Audio."""
+ children: list[BrowseMedia] = []
+
+ if client.preset_list.presets:
+ children.append(
+ BrowseMedia(
+ title="Presets",
+ media_class=MediaClass.DIRECTORY,
+ media_content_id="",
+ media_content_type="presets",
+ thumbnail="https://brands.home-assistant.io/_/cambridge_audio/logo.png",
+ can_play=False,
+ can_expand=True,
+ )
+ )
+
+ return BrowseMedia(
+ title="Cambridge Audio",
+ media_class=MediaClass.DIRECTORY,
+ media_content_id="",
+ media_content_type="root",
+ can_play=False,
+ can_expand=True,
+ children=children,
+ )
+
+
+async def _presets_payload(presets: list[Preset]) -> BrowseMedia:
+ """Create payload to list presets."""
+
+ children: list[BrowseMedia] = []
+ for preset in presets:
+ if preset.state != "OK":
+ continue
+ children.append(
+ BrowseMedia(
+ title=preset.name,
+ media_class=MediaClass.MUSIC,
+ media_content_id=str(preset.preset_id),
+ media_content_type="preset",
+ can_play=True,
+ can_expand=False,
+ thumbnail=preset.art_url,
+ )
+ )
+
+ return BrowseMedia(
+ title="Presets",
+ media_class=MediaClass.DIRECTORY,
+ media_content_id="",
+ media_content_type="presets",
+ can_play=False,
+ can_expand=True,
+ children=children,
+ )
diff --git a/homeassistant/components/cambridge_audio/media_player.py b/homeassistant/components/cambridge_audio/media_player.py
index 5e340cdd21e..042178d5781 100644
--- a/homeassistant/components/cambridge_audio/media_player.py
+++ b/homeassistant/components/cambridge_audio/media_player.py
@@ -13,6 +13,7 @@ from aiostreammagic import (
)
from homeassistant.components.media_player import (
+ BrowseMedia,
MediaPlayerDeviceClass,
MediaPlayerEntity,
MediaPlayerEntityFeature,
@@ -20,11 +21,11 @@ from homeassistant.components.media_player import (
MediaType,
RepeatMode,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from . import CambridgeAudioConfigEntry, media_browser
from .const import (
CAMBRIDGE_MEDIA_TYPE_AIRABLE,
CAMBRIDGE_MEDIA_TYPE_INTERNET_RADIO,
@@ -34,7 +35,8 @@ from .const import (
from .entity import CambridgeAudioEntity, command
BASE_FEATURES = (
- MediaPlayerEntityFeature.SELECT_SOURCE
+ MediaPlayerEntityFeature.BROWSE_MEDIA
+ | MediaPlayerEntityFeature.SELECT_SOURCE
| MediaPlayerEntityFeature.TURN_OFF
| MediaPlayerEntityFeature.TURN_ON
| MediaPlayerEntityFeature.PLAY_MEDIA
@@ -57,10 +59,12 @@ TRANSPORT_FEATURES: dict[TransportControl, MediaPlayerEntityFeature] = {
TransportControl.STOP: MediaPlayerEntityFeature.STOP,
}
+PARALLEL_UPDATES = 0
+
async def async_setup_entry(
hass: HomeAssistant,
- entry: ConfigEntry,
+ entry: CambridgeAudioConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Cambridge Audio device based on a config entry."""
@@ -336,3 +340,13 @@ class CambridgeAudioDevice(CambridgeAudioEntity, MediaPlayerEntity):
if media_type == CAMBRIDGE_MEDIA_TYPE_INTERNET_RADIO:
await self.client.play_radio_url("Radio", media_id)
+
+ async def async_browse_media(
+ self,
+ media_content_type: MediaType | str | None = None,
+ media_content_id: str | None = None,
+ ) -> BrowseMedia:
+ """Implement the media browsing helper."""
+ return await media_browser.async_browse_media(
+ self.hass, self.client, media_content_id, media_content_type
+ )
diff --git a/homeassistant/components/cambridge_audio/quality_scale.yaml b/homeassistant/components/cambridge_audio/quality_scale.yaml
new file mode 100644
index 00000000000..e5cafdd6368
--- /dev/null
+++ b/homeassistant/components/cambridge_audio/quality_scale.yaml
@@ -0,0 +1,80 @@
+rules:
+ # Bronze
+ action-setup:
+ status: exempt
+ comment: |
+ This integration does not provide additional actions beyond play media which is setup by the media player entity.
+ appropriate-polling:
+ status: exempt
+ comment: |
+ This integration uses a push API. No polling required.
+ 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
+ config-entry-unloading: done
+ log-when-unavailable: done
+ entity-unavailable: done
+ action-exceptions: done
+ reauthentication-flow:
+ status: exempt
+ comment: |
+ This integration does not require authentication.
+ parallel-updates: done
+ test-coverage: done
+ integration-owner: done
+ docs-installation-parameters: done
+ docs-configuration-parameters:
+ status: exempt
+ comment: |
+ This integration does not have an options flow.
+ # Gold
+ entity-translations: done
+ entity-device-class: done
+ devices: done
+ entity-category: done
+ entity-disabled-by-default: done
+ discovery: done
+ stale-devices:
+ status: exempt
+ comment: |
+ This integration is not a hub and as such only represents a single device.
+ diagnostics: done
+ exception-translations: done
+ icon-translations: done
+ reconfiguration-flow: done
+ dynamic-devices:
+ status: exempt
+ comment: |
+ This integration is not a hub and only represents a single device.
+ 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: 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/cambridge_audio/select.py b/homeassistant/components/cambridge_audio/select.py
index ca6eebdec6b..6bfe83c2539 100644
--- a/homeassistant/components/cambridge_audio/select.py
+++ b/homeassistant/components/cambridge_audio/select.py
@@ -7,12 +7,14 @@ from aiostreammagic import StreamMagicClient
from aiostreammagic.models import DisplayBrightness
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 .entity import CambridgeAudioEntity
+from . import CambridgeAudioConfigEntry
+from .entity import CambridgeAudioEntity, command
+
+PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
@@ -51,8 +53,13 @@ CONTROL_ENTITIES: tuple[CambridgeAudioSelectEntityDescription, ...] = (
CambridgeAudioSelectEntityDescription(
key="display_brightness",
translation_key="display_brightness",
- options=[x.value for x in DisplayBrightness],
+ options=[
+ DisplayBrightness.BRIGHT.value,
+ DisplayBrightness.DIM.value,
+ DisplayBrightness.OFF.value,
+ ],
entity_category=EntityCategory.CONFIG,
+ load_fn=lambda client: client.display.brightness != DisplayBrightness.NONE,
value_fn=lambda client: client.display.brightness,
set_value_fn=lambda client, value: client.set_display_brightness(
DisplayBrightness(value)
@@ -74,7 +81,7 @@ CONTROL_ENTITIES: tuple[CambridgeAudioSelectEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
- entry: ConfigEntry,
+ entry: CambridgeAudioConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Cambridge Audio select entities based on a config entry."""
@@ -111,6 +118,7 @@ class CambridgeAudioSelect(CambridgeAudioEntity, SelectEntity):
"""Return the state of the select."""
return self.entity_description.value_fn(self.client)
+ @command
async def async_select_option(self, option: str) -> None:
"""Change the selected option."""
await self.entity_description.set_value_fn(self.client, option)
diff --git a/homeassistant/components/cambridge_audio/strings.json b/homeassistant/components/cambridge_audio/strings.json
index c368ba060a7..6041232fe65 100644
--- a/homeassistant/components/cambridge_audio/strings.json
+++ b/homeassistant/components/cambridge_audio/strings.json
@@ -12,13 +12,24 @@
}
},
"discovery_confirm": {
- "description": "Do you want to setup {name}?"
+ "description": "Do you want to set up {name}?"
+ },
+ "reconfigure": {
+ "description": "Reconfigure your Cambridge Audio Streamer.",
+ "data": {
+ "host": "[%key:common::config_flow::data::host%]"
+ },
+ "data_description": {
+ "host": "[%key:component::cambridge_audio::config::step::user::data_description::host%]"
+ }
}
},
"error": {
"cannot_connect": "Failed to connect to Cambridge Audio device. Please make sure the device is powered up and connected to the network. Try power-cycling the device if it does not connect."
},
"abort": {
+ "wrong_device": "This Cambridge Audio device does not match the existing device ID. Please make sure you entered the correct IP address.",
+ "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
}
diff --git a/homeassistant/components/cambridge_audio/switch.py b/homeassistant/components/cambridge_audio/switch.py
index 3209b275d46..065a1da4f94 100644
--- a/homeassistant/components/cambridge_audio/switch.py
+++ b/homeassistant/components/cambridge_audio/switch.py
@@ -7,12 +7,14 @@ from typing import Any
from aiostreammagic import StreamMagicClient
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 .entity import CambridgeAudioEntity
+from . import CambridgeAudioConfigEntry
+from .entity import CambridgeAudioEntity, command
+
+PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
@@ -43,7 +45,7 @@ CONTROL_ENTITIES: tuple[CambridgeAudioSwitchEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
- entry: ConfigEntry,
+ entry: CambridgeAudioConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Cambridge Audio switch entities based on a config entry."""
@@ -73,10 +75,12 @@ class CambridgeAudioSwitch(CambridgeAudioEntity, SwitchEntity):
"""Return the state of the switch."""
return self.entity_description.value_fn(self.client)
+ @command
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the switch on."""
await self.entity_description.set_value_fn(self.client, True)
+ @command
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the switch off."""
await self.entity_description.set_value_fn(self.client, False)
diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py
index d31d21d424c..4d718433fca 100644
--- a/homeassistant/components/camera/__init__.py
+++ b/homeassistant/components/camera/__init__.py
@@ -20,7 +20,7 @@ from aiohttp import hdrs, web
import attr
from propcache import cached_property, under_cached_property
import voluptuous as vol
-from webrtc_models import RTCIceCandidate, RTCIceServer
+from webrtc_models import RTCIceCandidateInit, RTCIceServer
from homeassistant.components import websocket_api
from homeassistant.components.http import KEY_AUTHENTICATED, HomeAssistantView
@@ -55,19 +55,19 @@ from homeassistant.helpers.deprecation import (
DeprecatedConstantEnum,
all_with_deprecated_constants,
check_if_deprecated_constant,
+ deprecated_function,
dir_with_deprecated_constants,
)
from homeassistant.helpers.entity import Entity, EntityDescription
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.event import async_track_time_interval
+from homeassistant.helpers.frame import ReportBehavior, report_usage
from homeassistant.helpers.network import get_url
from homeassistant.helpers.template import Template
from homeassistant.helpers.typing import ConfigType, VolDictType
from homeassistant.loader import bind_hass
-from .const import ( # noqa: F401
- _DEPRECATED_STREAM_TYPE_HLS,
- _DEPRECATED_STREAM_TYPE_WEB_RTC,
+from .const import (
CAMERA_IMAGE_TIMEOUT,
CAMERA_STREAM_SOURCE_TIMEOUT,
CONF_DURATION,
@@ -133,16 +133,6 @@ class CameraEntityFeature(IntFlag):
STREAM = 2
-# These SUPPORT_* constants are deprecated as of Home Assistant 2022.5.
-# Pleease use the CameraEntityFeature enum instead.
-_DEPRECATED_SUPPORT_ON_OFF: Final = DeprecatedConstantEnum(
- CameraEntityFeature.ON_OFF, "2025.1"
-)
-_DEPRECATED_SUPPORT_STREAM: Final = DeprecatedConstantEnum(
- CameraEntityFeature.STREAM, "2025.1"
-)
-
-
DEFAULT_CONTENT_TYPE: Final = "image/jpeg"
ENTITY_IMAGE_URL: Final = "/api/camera_proxy/{0}?token={1}"
@@ -466,6 +456,7 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
# Entity Properties
_attr_brand: str | None = None
_attr_frame_interval: float = MIN_STREAM_INTERVAL
+ # Deprecated in 2024.12. Remove in 2025.6
_attr_frontend_stream_type: StreamType | None
_attr_is_on: bool = True
_attr_is_recording: bool = False
@@ -497,6 +488,16 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
type(self).async_handle_async_webrtc_offer
!= Camera.async_handle_async_webrtc_offer
)
+ self._deprecate_attr_frontend_stream_type_logged = False
+ if type(self).frontend_stream_type != Camera.frontend_stream_type:
+ report_usage(
+ (
+ f"is overwriting the 'frontend_stream_type' property in the {type(self).__name__} class,"
+ " which is deprecated and will be removed in Home Assistant 2025.6, "
+ ),
+ core_integration_behavior=ReportBehavior.ERROR,
+ exclude_integrations={DOMAIN},
+ )
@cached_property
def entity_picture(self) -> str:
@@ -566,11 +567,29 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
frontend which camera attributes and player to use. The default type
is to use HLS, and components can override to change the type.
"""
+ # Deprecated in 2024.12. Remove in 2025.6
+ # Use the camera_capabilities instead
if hasattr(self, "_attr_frontend_stream_type"):
+ if not self._deprecate_attr_frontend_stream_type_logged:
+ report_usage(
+ (
+ f"is setting the '_attr_frontend_stream_type' attribute in the {type(self).__name__} class,"
+ " which is deprecated and will be removed in Home Assistant 2025.6, "
+ ),
+ core_integration_behavior=ReportBehavior.ERROR,
+ exclude_integrations={DOMAIN},
+ )
+
+ self._deprecate_attr_frontend_stream_type_logged = True
return self._attr_frontend_stream_type
if CameraEntityFeature.STREAM not in self.supported_features_compat:
return None
- if self._webrtc_provider or self._legacy_webrtc_provider:
+ if (
+ self._webrtc_provider
+ or self._legacy_webrtc_provider
+ or self._supports_native_sync_webrtc
+ or self._supports_native_async_webrtc
+ ):
return StreamType.WEB_RTC
return StreamType.HLS
@@ -628,14 +647,17 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
Async means that it could take some time to process the offer and responses/message
will be sent with the send_message callback.
- This method is used by cameras with CameraEntityFeature.STREAM and StreamType.WEB_RTC.
+ This method is used by cameras with CameraEntityFeature.STREAM.
An integration overriding this method must also implement async_on_webrtc_candidate.
Integrations can override with a native WebRTC implementation.
"""
if self._supports_native_sync_webrtc:
try:
- answer = await self.async_handle_web_rtc_offer(offer_sdp)
+ answer = await deprecated_function(
+ "async_handle_async_webrtc_offer",
+ breaks_in_ha_version="2025.6",
+ )(self.async_handle_web_rtc_offer)(offer_sdp)
except ValueError as ex:
_LOGGER.error("Error handling WebRTC offer: %s", ex)
send_message(
@@ -865,7 +887,7 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
return config
async def async_on_webrtc_candidate(
- self, session_id: str, candidate: RTCIceCandidate
+ self, session_id: str, candidate: RTCIceCandidateInit
) -> None:
"""Handle a WebRTC candidate."""
if self._webrtc_provider:
@@ -896,7 +918,7 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
else:
frontend_stream_types.add(StreamType.HLS)
- if self._webrtc_provider:
+ if self._webrtc_provider or self._legacy_webrtc_provider:
frontend_stream_types.add(StreamType.WEB_RTC)
return CameraCapabilities(frontend_stream_types)
diff --git a/homeassistant/components/camera/const.py b/homeassistant/components/camera/const.py
index 7e4633d410a..65862e66dab 100644
--- a/homeassistant/components/camera/const.py
+++ b/homeassistant/components/camera/const.py
@@ -3,15 +3,8 @@
from __future__ import annotations
from enum import StrEnum
-from functools import partial
from typing import TYPE_CHECKING, Final
-from homeassistant.helpers.deprecation import (
- DeprecatedConstantEnum,
- all_with_deprecated_constants,
- check_if_deprecated_constant,
- dir_with_deprecated_constants,
-)
from homeassistant.util.hass_dict import HassKey
if TYPE_CHECKING:
@@ -58,17 +51,3 @@ class StreamType(StrEnum):
HLS = "hls"
WEB_RTC = "web_rtc"
-
-
-# These constants are deprecated as of Home Assistant 2022.5
-# Please use the StreamType enum instead.
-_DEPRECATED_STREAM_TYPE_HLS = DeprecatedConstantEnum(StreamType.HLS, "2025.1")
-_DEPRECATED_STREAM_TYPE_WEB_RTC = DeprecatedConstantEnum(StreamType.WEB_RTC, "2025.1")
-
-
-# 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/camera/media_source.py b/homeassistant/components/camera/media_source.py
index ea30dafb09e..701457afc3e 100644
--- a/homeassistant/components/camera/media_source.py
+++ b/homeassistant/components/camera/media_source.py
@@ -64,7 +64,7 @@ class CameraMediaSource(MediaSource):
if not camera:
raise Unresolvable(f"Could not resolve media item: {item.identifier}")
- if (stream_type := camera.frontend_stream_type) is None:
+ if not (stream_types := camera.camera_capabilities.frontend_stream_types):
return PlayMedia(
f"/api/camera_proxy_stream/{camera.entity_id}", camera.content_type
)
@@ -76,7 +76,7 @@ class CameraMediaSource(MediaSource):
url = await _async_stream_endpoint_url(self.hass, camera, HLS_PROVIDER)
except HomeAssistantError as err:
# Handle known error
- if stream_type != StreamType.HLS:
+ if StreamType.HLS not in stream_types:
raise Unresolvable(
"Camera does not support MJPEG or HLS streaming."
) from err
@@ -95,14 +95,16 @@ class CameraMediaSource(MediaSource):
can_stream_hls = "stream" in self.hass.config.components
async def _filter_browsable_camera(camera: Camera) -> BrowseMediaSource | None:
- stream_type = camera.frontend_stream_type
- if stream_type is None:
+ stream_types = camera.camera_capabilities.frontend_stream_types
+ if not stream_types:
return _media_source_for_camera(self.hass, camera, camera.content_type)
if not can_stream_hls:
return None
content_type = FORMAT_CONTENT_TYPE[HLS_PROVIDER]
- if stream_type != StreamType.HLS and not (await camera.stream_source()):
+ if StreamType.HLS not in stream_types and not (
+ await camera.stream_source()
+ ):
return None
return _media_source_for_camera(self.hass, camera, content_type)
diff --git a/homeassistant/components/camera/webrtc.py b/homeassistant/components/camera/webrtc.py
index 0612c96e40c..3630acf1cfe 100644
--- a/homeassistant/components/camera/webrtc.py
+++ b/homeassistant/components/camera/webrtc.py
@@ -6,17 +6,24 @@ from abc import ABC, abstractmethod
import asyncio
from collections.abc import Awaitable, Callable, Iterable
from dataclasses import asdict, dataclass, field
-from functools import cache, partial
+from functools import cache, partial, wraps
import logging
from typing import TYPE_CHECKING, Any, Protocol
+from mashumaro import MissingField
import voluptuous as vol
-from webrtc_models import RTCConfiguration, RTCIceCandidate, RTCIceServer
+from webrtc_models import (
+ RTCConfiguration,
+ RTCIceCandidate,
+ RTCIceCandidateInit,
+ RTCIceServer,
+)
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, issue_registry as ir
+from homeassistant.helpers.deprecation import deprecated_function
from homeassistant.util.hass_dict import HassKey
from homeassistant.util.ulid import ulid
@@ -78,13 +85,13 @@ class WebRTCAnswer(WebRTCMessage):
class WebRTCCandidate(WebRTCMessage):
"""WebRTC candidate."""
- candidate: RTCIceCandidate
+ candidate: RTCIceCandidate | RTCIceCandidateInit
def as_dict(self) -> dict[str, Any]:
"""Return a dict representation of the message."""
return {
"type": self._get_type(),
- "candidate": self.candidate.candidate,
+ "candidate": self.candidate.to_dict(),
}
@@ -146,7 +153,7 @@ class CameraWebRTCProvider(ABC):
@abstractmethod
async def async_on_webrtc_candidate(
- self, session_id: str, candidate: RTCIceCandidate
+ self, session_id: str, candidate: RTCIceCandidateInit
) -> None:
"""Handle the WebRTC candidate."""
@@ -205,6 +212,51 @@ async def _async_refresh_providers(hass: HomeAssistant) -> None:
)
+type WsCommandWithCamera = Callable[
+ [websocket_api.ActiveConnection, dict[str, Any], Camera],
+ Awaitable[None],
+]
+
+
+def require_webrtc_support(
+ error_code: str,
+) -> Callable[[WsCommandWithCamera], websocket_api.AsyncWebSocketCommandHandler]:
+ """Validate that the camera supports WebRTC."""
+
+ def decorate(
+ func: WsCommandWithCamera,
+ ) -> websocket_api.AsyncWebSocketCommandHandler:
+ """Decorate func."""
+
+ @wraps(func)
+ async def validate(
+ hass: HomeAssistant,
+ connection: websocket_api.ActiveConnection,
+ msg: dict[str, Any],
+ ) -> None:
+ """Validate that the camera supports WebRTC."""
+ entity_id = msg["entity_id"]
+ camera = get_camera_from_entity_id(hass, entity_id)
+ if StreamType.WEB_RTC not in (
+ stream_types := camera.camera_capabilities.frontend_stream_types
+ ):
+ connection.send_error(
+ msg["id"],
+ error_code,
+ (
+ "Camera does not support WebRTC,"
+ f" frontend_stream_types={stream_types}"
+ ),
+ )
+ return
+
+ await func(connection, msg, camera)
+
+ return validate
+
+ return decorate
+
+
@websocket_api.websocket_command(
{
vol.Required("type"): "camera/webrtc/offer",
@@ -213,8 +265,9 @@ async def _async_refresh_providers(hass: HomeAssistant) -> None:
}
)
@websocket_api.async_response
+@require_webrtc_support("webrtc_offer_failed")
async def ws_webrtc_offer(
- hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
+ connection: websocket_api.ActiveConnection, msg: dict[str, Any], camera: Camera
) -> None:
"""Handle the signal path for a WebRTC stream.
@@ -226,20 +279,7 @@ async def ws_webrtc_offer(
Async friendly.
"""
- entity_id = msg["entity_id"]
offer = msg["offer"]
- camera = get_camera_from_entity_id(hass, entity_id)
- if camera.frontend_stream_type != StreamType.WEB_RTC:
- connection.send_error(
- msg["id"],
- "webrtc_offer_failed",
- (
- "Camera does not support WebRTC,"
- f" frontend_stream_type={camera.frontend_stream_type}"
- ),
- )
- return
-
session_id = ulid()
connection.subscriptions[msg["id"]] = partial(
camera.close_webrtc_session, session_id
@@ -278,23 +318,11 @@ async def ws_webrtc_offer(
}
)
@websocket_api.async_response
+@require_webrtc_support("webrtc_get_client_config_failed")
async def ws_get_client_config(
- hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
+ connection: websocket_api.ActiveConnection, msg: dict[str, Any], camera: Camera
) -> None:
"""Handle get WebRTC client config websocket command."""
- entity_id = msg["entity_id"]
- camera = get_camera_from_entity_id(hass, entity_id)
- if camera.frontend_stream_type != StreamType.WEB_RTC:
- connection.send_error(
- msg["id"],
- "webrtc_get_client_config_failed",
- (
- "Camera does not support WebRTC,"
- f" frontend_stream_type={camera.frontend_stream_type}"
- ),
- )
- return
-
config = camera.async_get_webrtc_client_configuration().to_frontend_dict()
connection.send_result(
msg["id"],
@@ -302,35 +330,29 @@ async def ws_get_client_config(
)
+def _parse_webrtc_candidate_init(value: Any) -> RTCIceCandidateInit:
+ """Validate and parse a WebRTCCandidateInit dict."""
+ try:
+ return RTCIceCandidateInit.from_dict(value)
+ except (MissingField, ValueError) as ex:
+ raise vol.Invalid(str(ex)) from ex
+
+
@websocket_api.websocket_command(
{
vol.Required("type"): "camera/webrtc/candidate",
vol.Required("entity_id"): cv.entity_id,
vol.Required("session_id"): str,
- vol.Required("candidate"): str,
+ vol.Required("candidate"): _parse_webrtc_candidate_init,
}
)
@websocket_api.async_response
+@require_webrtc_support("webrtc_candidate_failed")
async def ws_candidate(
- hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
+ connection: websocket_api.ActiveConnection, msg: dict[str, Any], camera: Camera
) -> None:
"""Handle WebRTC candidate websocket command."""
- entity_id = msg["entity_id"]
- camera = get_camera_from_entity_id(hass, entity_id)
- if camera.frontend_stream_type != StreamType.WEB_RTC:
- connection.send_error(
- msg["id"],
- "webrtc_candidate_failed",
- (
- "Camera does not support WebRTC,"
- f" frontend_stream_type={camera.frontend_stream_type}"
- ),
- )
- return
-
- await camera.async_on_webrtc_candidate(
- msg["session_id"], RTCIceCandidate(msg["candidate"])
- )
+ await camera.async_on_webrtc_candidate(msg["session_id"], msg["candidate"])
connection.send_message(websocket_api.result_message(msg["id"]))
@@ -424,6 +446,7 @@ class _CameraRtspToWebRTCProvider(CameraWebRTCLegacyProvider):
return await self._fn(stream_source, offer_sdp, camera.entity_id)
+@deprecated_function("async_register_webrtc_provider", breaks_in_ha_version="2025.6")
def async_register_rtsp_to_web_rtc_provider(
hass: HomeAssistant,
domain: str,
diff --git a/homeassistant/components/canary/config_flow.py b/homeassistant/components/canary/config_flow.py
index 2dd3a678b5d..17e660e96ac 100644
--- a/homeassistant/components/canary/config_flow.py
+++ b/homeassistant/components/canary/config_flow.py
@@ -62,9 +62,6 @@ class CanaryConfigFlow(ConfigFlow, domain=DOMAIN):
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle a flow initiated by the user."""
- if self._async_current_entries():
- return self.async_abort(reason="single_instance_allowed")
-
errors = {}
default_username = ""
diff --git a/homeassistant/components/canary/manifest.json b/homeassistant/components/canary/manifest.json
index 4d5adf4a32b..9383bc91556 100644
--- a/homeassistant/components/canary/manifest.json
+++ b/homeassistant/components/canary/manifest.json
@@ -7,5 +7,6 @@
"documentation": "https://www.home-assistant.io/integrations/canary",
"iot_class": "cloud_polling",
"loggers": ["canary"],
- "requirements": ["py-canary==0.5.4"]
+ "requirements": ["py-canary==0.5.4"],
+ "single_config_entry": true
}
diff --git a/homeassistant/components/canary/strings.json b/homeassistant/components/canary/strings.json
index 9555756deff..699e8b25e11 100644
--- a/homeassistant/components/canary/strings.json
+++ b/homeassistant/components/canary/strings.json
@@ -14,7 +14,6 @@
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
},
"abort": {
- "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
}
},
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/strings.json b/homeassistant/components/cast/strings.json
index 12f2edeee9a..9c49813bd83 100644
--- a/homeassistant/components/cast/strings.json
+++ b/homeassistant/components/cast/strings.json
@@ -53,7 +53,7 @@
},
"view_path": {
"name": "View path",
- "description": "The path of the dashboard view to show."
+ "description": "The URL path of the dashboard view to show."
}
}
}
diff --git a/homeassistant/components/ccm15/climate.py b/homeassistant/components/ccm15/climate.py
index a6e5d2cab61..3db8c3e1016 100644
--- a/homeassistant/components/ccm15/climate.py
+++ b/homeassistant/components/ccm15/climate.py
@@ -70,7 +70,6 @@ class CCM15Climate(CoordinatorEntity[CCM15Coordinator], ClimateEntity):
| ClimateEntityFeature.TURN_ON
)
_attr_name = None
- _enable_turn_on_off_backwards_compatibility = False
def __init__(
self, ac_host: str, ac_index: int, coordinator: CCM15Coordinator
diff --git a/homeassistant/components/cert_expiry/config_flow.py b/homeassistant/components/cert_expiry/config_flow.py
index 22d443c700d..c351435a73e 100644
--- a/homeassistant/components/cert_expiry/config_flow.py
+++ b/homeassistant/components/cert_expiry/config_flow.py
@@ -74,7 +74,7 @@ class CertexpiryConfigFlow(ConfigFlow, domain=DOMAIN):
title=title,
data={CONF_HOST: host, CONF_PORT: port},
)
- if self.context["source"] == SOURCE_IMPORT:
+ if self.source == SOURCE_IMPORT:
_LOGGER.error("Config import failed for %s", user_input[CONF_HOST])
return self.async_abort(reason="import_failed")
else:
@@ -94,10 +94,3 @@ class CertexpiryConfigFlow(ConfigFlow, domain=DOMAIN):
),
errors=self._errors,
)
-
- async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult:
- """Import a config entry.
-
- Only host was required in the yaml file all other fields are optional
- """
- return await self.async_step_user(import_data)
diff --git a/homeassistant/components/cert_expiry/sensor.py b/homeassistant/components/cert_expiry/sensor.py
index a6f163b51be..4fd0846f0f3 100644
--- a/homeassistant/components/cert_expiry/sensor.py
+++ b/homeassistant/components/cert_expiry/sensor.py
@@ -2,63 +2,18 @@
from __future__ import annotations
-from datetime import datetime, timedelta
+from datetime import datetime
-import voluptuous as vol
-
-from homeassistant.components.sensor import (
- PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA,
- SensorDeviceClass,
- SensorEntity,
-)
-from homeassistant.config_entries import SOURCE_IMPORT
-from homeassistant.const import CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_START
-from homeassistant.core import Event, HomeAssistant, callback
-import homeassistant.helpers.config_validation as cv
+from homeassistant.components.sensor import SensorDeviceClass, SensorEntity
+from homeassistant.core import HomeAssistant
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.helpers.typing import ConfigType, DiscoveryInfoType
from . import CertExpiryConfigEntry
-from .const import DEFAULT_PORT, DOMAIN
+from .const import DOMAIN
from .coordinator import CertExpiryDataUpdateCoordinator
from .entity import CertExpiryEntity
-SCAN_INTERVAL = timedelta(hours=12)
-
-PLATFORM_SCHEMA = SENSOR_PLATFORM_SCHEMA.extend(
- {
- vol.Required(CONF_HOST): cv.string,
- vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
- }
-)
-
-
-async def async_setup_platform(
- hass: HomeAssistant,
- config: ConfigType,
- async_add_entities: AddEntitiesCallback,
- discovery_info: DiscoveryInfoType | None = None,
-) -> None:
- """Set up certificate expiry sensor."""
-
- @callback
- def schedule_import(_: Event) -> None:
- """Schedule delayed import after HA is fully started."""
- async_call_later(hass, 10, do_import)
-
- @callback
- def do_import(_: datetime) -> None:
- """Process YAML import."""
- hass.async_create_task(
- hass.config_entries.flow.async_init(
- DOMAIN, context={"source": SOURCE_IMPORT}, data=dict(config)
- )
- )
-
- hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, schedule_import)
-
async def async_setup_entry(
hass: HomeAssistant,
diff --git a/homeassistant/components/channels/manifest.json b/homeassistant/components/channels/manifest.json
index 0455ca2e8ad..9476e006eda 100644
--- a/homeassistant/components/channels/manifest.json
+++ b/homeassistant/components/channels/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/channels",
"iot_class": "local_polling",
"loggers": ["pychannels"],
+ "quality_scale": "legacy",
"requirements": ["pychannels==1.2.3"]
}
diff --git a/homeassistant/components/cisco_ios/device_tracker.py b/homeassistant/components/cisco_ios/device_tracker.py
index 1f78f95c259..b882f046a8e 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
@@ -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 dd0d4213973..ddfb96a70ae 100644
--- a/homeassistant/components/cisco_ios/manifest.json
+++ b/homeassistant/components/cisco_ios/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/cisco_ios",
"iot_class": "local_polling",
"loggers": ["pexpect", "ptyprocess"],
- "requirements": ["pexpect==4.6.0"]
+ "quality_scale": "legacy",
+ "requirements": ["pexpect==4.9.0"]
}
diff --git a/homeassistant/components/cisco_mobility_express/manifest.json b/homeassistant/components/cisco_mobility_express/manifest.json
index 02786e80cd8..f9ee1c92ed1 100644
--- a/homeassistant/components/cisco_mobility_express/manifest.json
+++ b/homeassistant/components/cisco_mobility_express/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/cisco_mobility_express",
"iot_class": "local_polling",
"loggers": ["ciscomobilityexpress"],
+ "quality_scale": "legacy",
"requirements": ["ciscomobilityexpress==0.3.9"]
}
diff --git a/homeassistant/components/cisco_webex_teams/manifest.json b/homeassistant/components/cisco_webex_teams/manifest.json
index 3da31a0b453..85cfeb7eddf 100644
--- a/homeassistant/components/cisco_webex_teams/manifest.json
+++ b/homeassistant/components/cisco_webex_teams/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/cisco_webex_teams",
"iot_class": "cloud_push",
"loggers": ["webexpythonsdk"],
+ "quality_scale": "legacy",
"requirements": ["webexpythonsdk==2.0.1"]
}
diff --git a/homeassistant/components/citybikes/manifest.json b/homeassistant/components/citybikes/manifest.json
index e163b85ec08..8dac7def832 100644
--- a/homeassistant/components/citybikes/manifest.json
+++ b/homeassistant/components/citybikes/manifest.json
@@ -3,5 +3,6 @@
"name": "CityBikes",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/citybikes",
- "iot_class": "cloud_polling"
+ "iot_class": "cloud_polling",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/clementine/manifest.json b/homeassistant/components/clementine/manifest.json
index 88e7f35f49a..42fe81d0e9b 100644
--- a/homeassistant/components/clementine/manifest.json
+++ b/homeassistant/components/clementine/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/clementine",
"iot_class": "local_polling",
"loggers": ["clementineremote"],
+ "quality_scale": "legacy",
"requirements": ["python-clementine-remote==1.0.1"]
}
diff --git a/homeassistant/components/clickatell/manifest.json b/homeassistant/components/clickatell/manifest.json
index 31456b25c64..3c5ee8b0053 100644
--- a/homeassistant/components/clickatell/manifest.json
+++ b/homeassistant/components/clickatell/manifest.json
@@ -3,5 +3,6 @@
"name": "Clickatell",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/clickatell",
- "iot_class": "cloud_push"
+ "iot_class": "cloud_push",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/clicksend/manifest.json b/homeassistant/components/clicksend/manifest.json
index 41bd10108f4..8a43428026b 100644
--- a/homeassistant/components/clicksend/manifest.json
+++ b/homeassistant/components/clicksend/manifest.json
@@ -3,5 +3,6 @@
"name": "ClickSend SMS",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/clicksend",
- "iot_class": "cloud_push"
+ "iot_class": "cloud_push",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/clicksend_tts/manifest.json b/homeassistant/components/clicksend_tts/manifest.json
index ffa35fd070f..eb884e41203 100644
--- a/homeassistant/components/clicksend_tts/manifest.json
+++ b/homeassistant/components/clicksend_tts/manifest.json
@@ -3,5 +3,6 @@
"name": "ClickSend TTS",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/clicksend_tts",
- "iot_class": "cloud_push"
+ "iot_class": "cloud_push",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/climate/__init__.py b/homeassistant/components/climate/__init__.py
index 94db8008aa1..ca85979f19a 100644
--- a/homeassistant/components/climate/__init__.py
+++ b/homeassistant/components/climate/__init__.py
@@ -2,7 +2,6 @@
from __future__ import annotations
-import asyncio
from datetime import timedelta
import functools as ft
import logging
@@ -26,14 +25,8 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import config_validation as cv, issue_registry as ir
-from homeassistant.helpers.deprecation import (
- 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.entity_platform import EntityPlatform
from homeassistant.helpers.temperature import display_temp as show_temp
from homeassistant.helpers.typing import ConfigType
from homeassistant.loader import async_get_issue_tracker, async_suggest_report_issue
@@ -41,20 +34,6 @@ from homeassistant.util.hass_dict import HassKey
from homeassistant.util.unit_conversion import TemperatureConverter
from .const import ( # noqa: F401
- _DEPRECATED_HVAC_MODE_AUTO,
- _DEPRECATED_HVAC_MODE_COOL,
- _DEPRECATED_HVAC_MODE_DRY,
- _DEPRECATED_HVAC_MODE_FAN_ONLY,
- _DEPRECATED_HVAC_MODE_HEAT,
- _DEPRECATED_HVAC_MODE_HEAT_COOL,
- _DEPRECATED_HVAC_MODE_OFF,
- _DEPRECATED_SUPPORT_AUX_HEAT,
- _DEPRECATED_SUPPORT_FAN_MODE,
- _DEPRECATED_SUPPORT_PRESET_MODE,
- _DEPRECATED_SUPPORT_SWING_MODE,
- _DEPRECATED_SUPPORT_TARGET_HUMIDITY,
- _DEPRECATED_SUPPORT_TARGET_TEMPERATURE,
- _DEPRECATED_SUPPORT_TARGET_TEMPERATURE_RANGE,
ATTR_AUX_HEAT,
ATTR_CURRENT_HUMIDITY,
ATTR_CURRENT_TEMPERATURE,
@@ -70,6 +49,8 @@ from .const import ( # noqa: F401
ATTR_MIN_TEMP,
ATTR_PRESET_MODE,
ATTR_PRESET_MODES,
+ ATTR_SWING_HORIZONTAL_MODE,
+ ATTR_SWING_HORIZONTAL_MODES,
ATTR_SWING_MODE,
ATTR_SWING_MODES,
ATTR_TARGET_TEMP_HIGH,
@@ -101,6 +82,7 @@ from .const import ( # noqa: F401
SERVICE_SET_HUMIDITY,
SERVICE_SET_HVAC_MODE,
SERVICE_SET_PRESET_MODE,
+ SERVICE_SET_SWING_HORIZONTAL_MODE,
SERVICE_SET_SWING_MODE,
SERVICE_SET_TEMPERATURE,
SWING_BOTH,
@@ -219,6 +201,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"async_handle_set_swing_mode_service",
[ClimateEntityFeature.SWING_MODE],
)
+ component.async_register_entity_service(
+ SERVICE_SET_SWING_HORIZONTAL_MODE,
+ {vol.Required(ATTR_SWING_HORIZONTAL_MODE): cv.string},
+ "async_handle_set_swing_horizontal_mode_service",
+ [ClimateEntityFeature.SWING_HORIZONTAL_MODE],
+ )
return True
@@ -256,6 +244,8 @@ CACHED_PROPERTIES_WITH_ATTR_ = {
"fan_modes",
"swing_mode",
"swing_modes",
+ "swing_horizontal_mode",
+ "swing_horizontal_modes",
"supported_features",
"min_temp",
"max_temp",
@@ -300,6 +290,8 @@ class ClimateEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
_attr_supported_features: ClimateEntityFeature = ClimateEntityFeature(0)
_attr_swing_mode: str | None
_attr_swing_modes: list[str] | None
+ _attr_swing_horizontal_mode: str | None
+ _attr_swing_horizontal_modes: list[str] | None
_attr_target_humidity: float | None = None
_attr_target_temperature_high: float | None
_attr_target_temperature_low: float | None
@@ -309,115 +301,6 @@ class ClimateEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
__climate_reported_legacy_aux = False
- __mod_supported_features: ClimateEntityFeature = ClimateEntityFeature(0)
- # Integrations should set `_enable_turn_on_off_backwards_compatibility` to False
- # once migrated and set the feature flags TURN_ON/TURN_OFF as needed.
- _enable_turn_on_off_backwards_compatibility: bool = True
-
- def __getattribute__(self, __name: str) -> Any:
- """Get attribute.
-
- Modify return of `supported_features` to
- include `_mod_supported_features` if attribute is set.
- """
- if __name != "supported_features":
- return super().__getattribute__(__name)
-
- # Convert the supported features to ClimateEntityFeature.
- # Remove this compatibility shim in 2025.1 or later.
- _supported_features: ClimateEntityFeature = super().__getattribute__(
- "supported_features"
- )
- _mod_supported_features: ClimateEntityFeature = super().__getattribute__(
- "_ClimateEntity__mod_supported_features"
- )
- if type(_supported_features) is int: # noqa: E721
- _features = ClimateEntityFeature(_supported_features)
- self._report_deprecated_supported_features_values(_features)
- else:
- _features = _supported_features
-
- if not _mod_supported_features:
- return _features
-
- # Add automatically calculated ClimateEntityFeature.TURN_OFF/TURN_ON to
- # supported features and return it
- return _features | _mod_supported_features
-
- @callback
- def add_to_platform_start(
- self,
- hass: HomeAssistant,
- platform: EntityPlatform,
- parallel_updates: asyncio.Semaphore | None,
- ) -> None:
- """Start adding an entity to a platform."""
- super().add_to_platform_start(hass, platform, parallel_updates)
-
- def _report_turn_on_off(feature: str, method: str) -> None:
- """Log warning not implemented turn on/off feature."""
- report_issue = self._suggest_report_issue()
- if feature.startswith("TURN"):
- message = (
- "Entity %s (%s) does not set ClimateEntityFeature.%s"
- " but implements the %s method. Please %s"
- )
- else:
- message = (
- "Entity %s (%s) implements HVACMode(s): %s and therefore implicitly"
- " supports the %s methods without setting the proper"
- " ClimateEntityFeature. Please %s"
- )
- _LOGGER.warning(
- message,
- self.entity_id,
- type(self),
- feature,
- method,
- report_issue,
- )
-
- # Adds ClimateEntityFeature.TURN_OFF/TURN_ON depending on service calls implemented
- # This should be removed in 2025.1.
- if self._enable_turn_on_off_backwards_compatibility is False:
- # Return if integration has migrated already
- return
-
- supported_features = self.supported_features
- if supported_features & CHECK_TURN_ON_OFF_FEATURE_FLAG:
- # The entity supports both turn_on and turn_off, the backwards compatibility
- # checks are not needed
- return
-
- if not supported_features & ClimateEntityFeature.TURN_OFF and (
- type(self).async_turn_off is not ClimateEntity.async_turn_off
- or type(self).turn_off is not ClimateEntity.turn_off
- ):
- # turn_off implicitly supported by implementing turn_off method
- _report_turn_on_off("TURN_OFF", "turn_off")
- self.__mod_supported_features |= ( # pylint: disable=unused-private-member
- ClimateEntityFeature.TURN_OFF
- )
-
- if not supported_features & ClimateEntityFeature.TURN_ON and (
- type(self).async_turn_on is not ClimateEntity.async_turn_on
- or type(self).turn_on is not ClimateEntity.turn_on
- ):
- # turn_on implicitly supported by implementing turn_on method
- _report_turn_on_off("TURN_ON", "turn_on")
- self.__mod_supported_features |= ( # pylint: disable=unused-private-member
- ClimateEntityFeature.TURN_ON
- )
-
- if (modes := self.hvac_modes) and len(modes) >= 2 and HVACMode.OFF in modes:
- # turn_on/off implicitly supported by including more modes than 1 and one of these
- # are HVACMode.OFF
- _modes = [_mode for _mode in modes if _mode is not None]
- _report_turn_on_off(", ".join(_modes or []), "turn_on/turn_off")
- self.__mod_supported_features |= ( # pylint: disable=unused-private-member
- ClimateEntityFeature.TURN_ON | ClimateEntityFeature.TURN_OFF
- )
-
def _report_legacy_aux(self) -> None:
"""Log warning and create an issue if the entity implements legacy auxiliary heater."""
@@ -513,6 +396,9 @@ class ClimateEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
if ClimateEntityFeature.SWING_MODE in supported_features:
data[ATTR_SWING_MODES] = self.swing_modes
+ if ClimateEntityFeature.SWING_HORIZONTAL_MODE in supported_features:
+ data[ATTR_SWING_HORIZONTAL_MODES] = self.swing_horizontal_modes
+
return data
@final
@@ -564,6 +450,9 @@ class ClimateEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
if ClimateEntityFeature.SWING_MODE in supported_features:
data[ATTR_SWING_MODE] = self.swing_mode
+ if ClimateEntityFeature.SWING_HORIZONTAL_MODE in supported_features:
+ data[ATTR_SWING_HORIZONTAL_MODE] = self.swing_horizontal_mode
+
if ClimateEntityFeature.AUX_HEAT in supported_features:
data[ATTR_AUX_HEAT] = STATE_ON if self.is_aux_heat else STATE_OFF
if (
@@ -691,11 +580,27 @@ class ClimateEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
"""
return self._attr_swing_modes
+ @cached_property
+ def swing_horizontal_mode(self) -> str | None:
+ """Return the horizontal swing setting.
+
+ Requires ClimateEntityFeature.SWING_HORIZONTAL_MODE.
+ """
+ return self._attr_swing_horizontal_mode
+
+ @cached_property
+ def swing_horizontal_modes(self) -> list[str] | None:
+ """Return the list of available horizontal swing modes.
+
+ Requires ClimateEntityFeature.SWING_HORIZONTAL_MODE.
+ """
+ return self._attr_swing_horizontal_modes
+
@final
@callback
def _valid_mode_or_raise(
self,
- mode_type: Literal["preset", "swing", "fan", "hvac"],
+ mode_type: Literal["preset", "horizontal_swing", "swing", "fan", "hvac"],
mode: str | HVACMode,
modes: list[str] | list[HVACMode] | None,
) -> None:
@@ -793,6 +698,26 @@ class ClimateEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
"""Set new target swing operation."""
await self.hass.async_add_executor_job(self.set_swing_mode, swing_mode)
+ @final
+ async def async_handle_set_swing_horizontal_mode_service(
+ self, swing_horizontal_mode: str
+ ) -> None:
+ """Validate and set new horizontal swing mode."""
+ self._valid_mode_or_raise(
+ "horizontal_swing", swing_horizontal_mode, self.swing_horizontal_modes
+ )
+ await self.async_set_swing_horizontal_mode(swing_horizontal_mode)
+
+ def set_swing_horizontal_mode(self, swing_horizontal_mode: str) -> None:
+ """Set new target horizontal swing operation."""
+ raise NotImplementedError
+
+ async def async_set_swing_horizontal_mode(self, swing_horizontal_mode: str) -> None:
+ """Set new target horizontal swing operation."""
+ await self.hass.async_add_executor_job(
+ self.set_swing_horizontal_mode, swing_horizontal_mode
+ )
+
@final
async def async_handle_set_preset_mode_service(self, preset_mode: str) -> None:
"""Validate and set new preset mode."""
@@ -1027,13 +952,3 @@ async def async_service_temperature_set(
kwargs[value] = temp
await entity.async_set_temperature(**kwargs)
-
-
-# As we import deprecated constants from the const module, we need to add these two functions
-# otherwise this module will be logged for using deprecated constants and not the custom component
-# 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/climate/const.py b/homeassistant/components/climate/const.py
index a84a2f3c628..111401a2251 100644
--- a/homeassistant/components/climate/const.py
+++ b/homeassistant/components/climate/const.py
@@ -1,14 +1,6 @@
"""Provides the constants needed for component."""
from enum import IntFlag, StrEnum
-from functools import partial
-
-from homeassistant.helpers.deprecation import (
- DeprecatedConstantEnum,
- all_with_deprecated_constants,
- check_if_deprecated_constant,
- dir_with_deprecated_constants,
-)
class HVACMode(StrEnum):
@@ -37,15 +29,6 @@ class HVACMode(StrEnum):
FAN_ONLY = "fan_only"
-# These HVAC_MODE_* constants are deprecated as of Home Assistant 2022.5.
-# Please use the HVACMode enum instead.
-_DEPRECATED_HVAC_MODE_OFF = DeprecatedConstantEnum(HVACMode.OFF, "2025.1")
-_DEPRECATED_HVAC_MODE_HEAT = DeprecatedConstantEnum(HVACMode.HEAT, "2025.1")
-_DEPRECATED_HVAC_MODE_COOL = DeprecatedConstantEnum(HVACMode.COOL, "2025.1")
-_DEPRECATED_HVAC_MODE_HEAT_COOL = DeprecatedConstantEnum(HVACMode.HEAT_COOL, "2025.1")
-_DEPRECATED_HVAC_MODE_AUTO = DeprecatedConstantEnum(HVACMode.AUTO, "2025.1")
-_DEPRECATED_HVAC_MODE_DRY = DeprecatedConstantEnum(HVACMode.DRY, "2025.1")
-_DEPRECATED_HVAC_MODE_FAN_ONLY = DeprecatedConstantEnum(HVACMode.FAN_ONLY, "2025.1")
HVAC_MODES = [cls.value for cls in HVACMode]
# No preset is active
@@ -92,6 +75,10 @@ SWING_BOTH = "both"
SWING_VERTICAL = "vertical"
SWING_HORIZONTAL = "horizontal"
+# Possible horizontal swing state
+SWING_HORIZONTAL_ON = "on"
+SWING_HORIZONTAL_OFF = "off"
+
class HVACAction(StrEnum):
"""HVAC action for climate devices."""
@@ -106,14 +93,6 @@ class HVACAction(StrEnum):
PREHEATING = "preheating"
-# These CURRENT_HVAC_* constants are deprecated as of Home Assistant 2022.5.
-# Please use the HVACAction enum instead.
-_DEPRECATED_CURRENT_HVAC_OFF = DeprecatedConstantEnum(HVACAction.OFF, "2025.1")
-_DEPRECATED_CURRENT_HVAC_HEAT = DeprecatedConstantEnum(HVACAction.HEATING, "2025.1")
-_DEPRECATED_CURRENT_HVAC_COOL = DeprecatedConstantEnum(HVACAction.COOLING, "2025.1")
-_DEPRECATED_CURRENT_HVAC_DRY = DeprecatedConstantEnum(HVACAction.DRYING, "2025.1")
-_DEPRECATED_CURRENT_HVAC_IDLE = DeprecatedConstantEnum(HVACAction.IDLE, "2025.1")
-_DEPRECATED_CURRENT_HVAC_FAN = DeprecatedConstantEnum(HVACAction.FAN, "2025.1")
CURRENT_HVAC_ACTIONS = [cls.value for cls in HVACAction]
@@ -134,6 +113,8 @@ ATTR_HVAC_MODES = "hvac_modes"
ATTR_HVAC_MODE = "hvac_mode"
ATTR_SWING_MODES = "swing_modes"
ATTR_SWING_MODE = "swing_mode"
+ATTR_SWING_HORIZONTAL_MODE = "swing_horizontal_mode"
+ATTR_SWING_HORIZONTAL_MODES = "swing_horizontal_modes"
ATTR_TARGET_TEMP_HIGH = "target_temp_high"
ATTR_TARGET_TEMP_LOW = "target_temp_low"
ATTR_TARGET_TEMP_STEP = "target_temp_step"
@@ -153,6 +134,7 @@ SERVICE_SET_PRESET_MODE = "set_preset_mode"
SERVICE_SET_HUMIDITY = "set_humidity"
SERVICE_SET_HVAC_MODE = "set_hvac_mode"
SERVICE_SET_SWING_MODE = "set_swing_mode"
+SERVICE_SET_SWING_HORIZONTAL_MODE = "set_swing_horizontal_mode"
SERVICE_SET_TEMPERATURE = "set_temperature"
@@ -168,35 +150,4 @@ class ClimateEntityFeature(IntFlag):
AUX_HEAT = 64
TURN_OFF = 128
TURN_ON = 256
-
-
-# These SUPPORT_* constants are deprecated as of Home Assistant 2022.5.
-# Please use the ClimateEntityFeature enum instead.
-_DEPRECATED_SUPPORT_TARGET_TEMPERATURE = DeprecatedConstantEnum(
- ClimateEntityFeature.TARGET_TEMPERATURE, "2025.1"
-)
-_DEPRECATED_SUPPORT_TARGET_TEMPERATURE_RANGE = DeprecatedConstantEnum(
- ClimateEntityFeature.TARGET_TEMPERATURE_RANGE, "2025.1"
-)
-_DEPRECATED_SUPPORT_TARGET_HUMIDITY = DeprecatedConstantEnum(
- ClimateEntityFeature.TARGET_HUMIDITY, "2025.1"
-)
-_DEPRECATED_SUPPORT_FAN_MODE = DeprecatedConstantEnum(
- ClimateEntityFeature.FAN_MODE, "2025.1"
-)
-_DEPRECATED_SUPPORT_PRESET_MODE = DeprecatedConstantEnum(
- ClimateEntityFeature.PRESET_MODE, "2025.1"
-)
-_DEPRECATED_SUPPORT_SWING_MODE = DeprecatedConstantEnum(
- ClimateEntityFeature.SWING_MODE, "2025.1"
-)
-_DEPRECATED_SUPPORT_AUX_HEAT = DeprecatedConstantEnum(
- ClimateEntityFeature.AUX_HEAT, "2025.1"
-)
-
-# 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())
+ SWING_HORIZONTAL_MODE = 512
diff --git a/homeassistant/components/climate/icons.json b/homeassistant/components/climate/icons.json
index c9a8d12d01b..8f4ffa6b19f 100644
--- a/homeassistant/components/climate/icons.json
+++ b/homeassistant/components/climate/icons.json
@@ -51,6 +51,13 @@
"on": "mdi:arrow-oscillating",
"vertical": "mdi:arrow-up-down"
}
+ },
+ "swing_horizontal_mode": {
+ "default": "mdi:circle-medium",
+ "state": {
+ "off": "mdi:arrow-oscillating-off",
+ "on": "mdi:arrow-expand-horizontal"
+ }
}
}
}
@@ -65,6 +72,9 @@
"set_swing_mode": {
"service": "mdi:arrow-oscillating"
},
+ "set_swing_horizontal_mode": {
+ "service": "mdi:arrow-expand-horizontal"
+ },
"set_temperature": {
"service": "mdi:thermometer"
},
diff --git a/homeassistant/components/climate/reproduce_state.py b/homeassistant/components/climate/reproduce_state.py
index 99357777fba..d38e243cb62 100644
--- a/homeassistant/components/climate/reproduce_state.py
+++ b/homeassistant/components/climate/reproduce_state.py
@@ -14,6 +14,7 @@ from .const import (
ATTR_HUMIDITY,
ATTR_HVAC_MODE,
ATTR_PRESET_MODE,
+ ATTR_SWING_HORIZONTAL_MODE,
ATTR_SWING_MODE,
ATTR_TARGET_TEMP_HIGH,
ATTR_TARGET_TEMP_LOW,
@@ -23,6 +24,7 @@ from .const import (
SERVICE_SET_HUMIDITY,
SERVICE_SET_HVAC_MODE,
SERVICE_SET_PRESET_MODE,
+ SERVICE_SET_SWING_HORIZONTAL_MODE,
SERVICE_SET_SWING_MODE,
SERVICE_SET_TEMPERATURE,
)
@@ -76,6 +78,14 @@ async def _async_reproduce_states(
):
await call_service(SERVICE_SET_SWING_MODE, [ATTR_SWING_MODE])
+ if (
+ ATTR_SWING_HORIZONTAL_MODE in state.attributes
+ and state.attributes[ATTR_SWING_HORIZONTAL_MODE] is not None
+ ):
+ await call_service(
+ SERVICE_SET_SWING_HORIZONTAL_MODE, [ATTR_SWING_HORIZONTAL_MODE]
+ )
+
if (
ATTR_FAN_MODE in state.attributes
and state.attributes[ATTR_FAN_MODE] is not None
diff --git a/homeassistant/components/climate/services.yaml b/homeassistant/components/climate/services.yaml
index 12a8e6f001f..68421bf2386 100644
--- a/homeassistant/components/climate/services.yaml
+++ b/homeassistant/components/climate/services.yaml
@@ -131,7 +131,20 @@ set_swing_mode:
fields:
swing_mode:
required: true
- example: "horizontal"
+ example: "on"
+ selector:
+ text:
+
+set_swing_horizontal_mode:
+ target:
+ entity:
+ domain: climate
+ supported_features:
+ - climate.ClimateEntityFeature.SWING_HORIZONTAL_MODE
+ fields:
+ swing_horizontal_mode:
+ required: true
+ example: "on"
selector:
text:
diff --git a/homeassistant/components/climate/significant_change.py b/homeassistant/components/climate/significant_change.py
index 0c4cdd4ac6a..2b7e2c5d8b1 100644
--- a/homeassistant/components/climate/significant_change.py
+++ b/homeassistant/components/climate/significant_change.py
@@ -19,6 +19,7 @@ from . import (
ATTR_HUMIDITY,
ATTR_HVAC_ACTION,
ATTR_PRESET_MODE,
+ ATTR_SWING_HORIZONTAL_MODE,
ATTR_SWING_MODE,
ATTR_TARGET_TEMP_HIGH,
ATTR_TARGET_TEMP_LOW,
@@ -34,6 +35,7 @@ SIGNIFICANT_ATTRIBUTES: set[str] = {
ATTR_HVAC_ACTION,
ATTR_PRESET_MODE,
ATTR_SWING_MODE,
+ ATTR_SWING_HORIZONTAL_MODE,
ATTR_TARGET_TEMP_HIGH,
ATTR_TARGET_TEMP_LOW,
ATTR_TEMPERATURE,
@@ -70,6 +72,7 @@ def async_check_significant_change(
ATTR_HVAC_ACTION,
ATTR_PRESET_MODE,
ATTR_SWING_MODE,
+ ATTR_SWING_HORIZONTAL_MODE,
]:
return True
diff --git a/homeassistant/components/climate/strings.json b/homeassistant/components/climate/strings.json
index 26a06821d84..6d8b2c5449d 100644
--- a/homeassistant/components/climate/strings.json
+++ b/homeassistant/components/climate/strings.json
@@ -123,6 +123,16 @@
"swing_modes": {
"name": "Swing modes"
},
+ "swing_horizontal_mode": {
+ "name": "Horizontal swing mode",
+ "state": {
+ "off": "[%key:common::state::off%]",
+ "on": "[%key:common::state::on%]"
+ }
+ },
+ "swing_horizontal_modes": {
+ "name": "Horizontal swing modes"
+ },
"target_temp_high": {
"name": "Upper target temperature"
},
@@ -161,19 +171,19 @@
},
"set_temperature": {
"name": "Set target temperature",
- "description": "Sets target temperature.",
+ "description": "Sets the temperature setpoint.",
"fields": {
"temperature": {
- "name": "Temperature",
- "description": "Target temperature."
+ "name": "Target temperature",
+ "description": "The temperature setpoint."
},
"target_temp_high": {
- "name": "Target temperature high",
- "description": "High target temperature."
+ "name": "Upper target temperature",
+ "description": "The max temperature setpoint."
},
"target_temp_low": {
- "name": "Target temperature low",
- "description": "Low target temperature."
+ "name": "Lower target temperature",
+ "description": "The min temperature setpoint."
},
"hvac_mode": {
"name": "HVAC mode",
@@ -221,6 +231,16 @@
}
}
},
+ "set_swing_horizontal_mode": {
+ "name": "Set horizontal swing mode",
+ "description": "Sets horizontal swing operation mode.",
+ "fields": {
+ "swing_horizontal_mode": {
+ "name": "Horizontal swing mode",
+ "description": "Horizontal swing operation mode."
+ }
+ }
+ },
"turn_on": {
"name": "[%key:common::action::turn_on%]",
"description": "Turns climate device on."
@@ -264,6 +284,9 @@
"not_valid_swing_mode": {
"message": "Swing mode {mode} is not valid. Valid swing modes are: {modes}."
},
+ "not_valid_horizontal_swing_mode": {
+ "message": "Horizontal swing mode {mode} is not valid. Valid horizontal swing modes are: {modes}."
+ },
"not_valid_fan_mode": {
"message": "Fan mode {mode} is not valid. Valid fan modes are: {modes}."
},
diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py
index 80c02571d24..80b00237fd3 100644
--- a/homeassistant/components/cloud/__init__.py
+++ b/homeassistant/components/cloud/__init__.py
@@ -36,7 +36,14 @@ from homeassistant.helpers.typing import ConfigType
from homeassistant.loader import bind_hass
from homeassistant.util.signal_type import SignalType
-from . import account_link, http_api
+# Pre-import backup to avoid it being imported
+# later when the import executor is busy and delaying
+# startup
+from . import (
+ account_link,
+ backup, # noqa: F401
+ http_api,
+)
from .client import CloudClient
from .const import (
CONF_ACCOUNT_LINK_SERVER,
diff --git a/homeassistant/components/cloud/assist_pipeline.py b/homeassistant/components/cloud/assist_pipeline.py
index f3a591d6eda..c97e5bdc0a2 100644
--- a/homeassistant/components/cloud/assist_pipeline.py
+++ b/homeassistant/components/cloud/assist_pipeline.py
@@ -1,6 +1,7 @@
"""Handle Cloud assist pipelines."""
import asyncio
+from typing import Any
from homeassistant.components.assist_pipeline import (
async_create_default_pipeline,
@@ -98,7 +99,7 @@ async def async_migrate_cloud_pipeline_engine(
# is an after dependency of cloud
await async_setup_pipeline_store(hass)
- kwargs: dict[str, str] = {pipeline_attribute: engine_id}
+ kwargs: dict[str, Any] = {pipeline_attribute: engine_id}
pipelines = async_get_pipelines(hass)
for pipeline in pipelines:
if getattr(pipeline, pipeline_attribute) == DOMAIN:
diff --git a/homeassistant/components/cloud/backup.py b/homeassistant/components/cloud/backup.py
new file mode 100644
index 00000000000..153d0741770
--- /dev/null
+++ b/homeassistant/components/cloud/backup.py
@@ -0,0 +1,259 @@
+"""Backup platform for the cloud integration."""
+
+from __future__ import annotations
+
+import asyncio
+import base64
+from collections.abc import AsyncIterator, Callable, Coroutine, Mapping
+import hashlib
+import logging
+import random
+from typing import Any
+
+from aiohttp import ClientError, ClientTimeout
+from hass_nabucasa import Cloud, CloudError
+from hass_nabucasa.cloud_api import (
+ async_files_delete_file,
+ async_files_download_details,
+ async_files_list,
+ async_files_upload_details,
+)
+
+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
+from .const import DATA_CLOUD, DOMAIN, EVENT_CLOUD_EVENT
+
+_LOGGER = logging.getLogger(__name__)
+_STORAGE_BACKUP = "backup"
+_RETRY_LIMIT = 5
+_RETRY_SECONDS_MIN = 60
+_RETRY_SECONDS_MAX = 600
+
+
+async def _b64md5(stream: AsyncIterator[bytes]) -> str:
+ """Calculate the MD5 hash of a file."""
+ file_hash = hashlib.md5()
+ async for chunk in stream:
+ file_hash.update(chunk)
+ return base64.b64encode(file_hash.digest()).decode()
+
+
+async def async_get_backup_agents(
+ hass: HomeAssistant,
+ **kwargs: Any,
+) -> list[BackupAgent]:
+ """Return the cloud backup agent."""
+ cloud = hass.data[DATA_CLOUD]
+ if not cloud.is_logged_in:
+ return []
+
+ return [CloudBackupAgent(hass=hass, cloud=cloud)]
+
+
+@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."""
+
+ @callback
+ def unsub() -> None:
+ """Unsubscribe from events."""
+ unsub_signal()
+
+ @callback
+ def handle_event(data: Mapping[str, Any]) -> None:
+ """Handle event."""
+ if data["type"] not in ("login", "logout"):
+ return
+ listener()
+
+ unsub_signal = async_dispatcher_connect(hass, EVENT_CLOUD_EVENT, handle_event)
+ return unsub
+
+
+class CloudBackupAgent(BackupAgent):
+ """Cloud backup agent."""
+
+ domain = DOMAIN
+ name = DOMAIN
+
+ def __init__(self, hass: HomeAssistant, cloud: Cloud[CloudClient]) -> None:
+ """Initialize the cloud backup sync agent."""
+ super().__init__()
+ self._cloud = cloud
+ self._hass = hass
+
+ @callback
+ def _get_backup_filename(self) -> str:
+ """Return the backup filename."""
+ return f"{self._cloud.client.prefs.instance_id}.tar"
+
+ 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.
+ """
+ if not await self.async_get_backup(backup_id):
+ raise BackupAgentError("Backup not found")
+
+ try:
+ details = await async_files_download_details(
+ self._cloud,
+ storage_type=_STORAGE_BACKUP,
+ filename=self._get_backup_filename(),
+ )
+ except (ClientError, CloudError) as err:
+ raise BackupAgentError("Failed to get download details") from err
+
+ try:
+ resp = await self._cloud.websession.get(
+ details["url"],
+ timeout=ClientTimeout(connect=10.0, total=43200.0), # 43200s == 12h
+ )
+
+ resp.raise_for_status()
+ except ClientError as err:
+ raise BackupAgentError("Failed to download backup") from err
+
+ return ChunkAsyncStreamIterator(resp.content)
+
+ async def _async_do_upload_backup(
+ self,
+ *,
+ open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]],
+ filename: str,
+ base64md5hash: str,
+ metadata: dict[str, Any],
+ size: int,
+ ) -> None:
+ """Upload a backup."""
+ try:
+ details = await async_files_upload_details(
+ self._cloud,
+ storage_type=_STORAGE_BACKUP,
+ filename=filename,
+ metadata=metadata,
+ size=size,
+ base64md5hash=base64md5hash,
+ )
+ except (ClientError, CloudError) as err:
+ raise BackupAgentError("Failed to get upload details") from err
+
+ try:
+ upload_status = await self._cloud.websession.put(
+ details["url"],
+ data=await open_stream(),
+ headers=details["headers"] | {"content-length": str(size)},
+ timeout=ClientTimeout(connect=10.0, total=43200.0), # 43200s == 12h
+ )
+ _LOGGER.log(
+ logging.DEBUG if upload_status.status < 400 else logging.WARNING,
+ "Backup upload status: %s",
+ upload_status.status,
+ )
+ upload_status.raise_for_status()
+ except (TimeoutError, ClientError) as err:
+ raise BackupAgentError("Failed to upload backup") from err
+
+ 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.
+ """
+ if not backup.protected:
+ raise BackupAgentError("Cloud backups must be protected")
+
+ base64md5hash = await _b64md5(await open_stream())
+ filename = self._get_backup_filename()
+ metadata = backup.as_dict()
+ size = backup.size
+
+ tries = 1
+ while tries <= _RETRY_LIMIT:
+ try:
+ await self._async_do_upload_backup(
+ open_stream=open_stream,
+ filename=filename,
+ base64md5hash=base64md5hash,
+ metadata=metadata,
+ size=size,
+ )
+ break
+ except BackupAgentError as err:
+ if tries == _RETRY_LIMIT:
+ raise
+ tries += 1
+ retry_timer = random.randint(_RETRY_SECONDS_MIN, _RETRY_SECONDS_MAX)
+ _LOGGER.info(
+ "Failed to upload backup, retrying (%s/%s) in %ss: %s",
+ tries,
+ _RETRY_LIMIT,
+ retry_timer,
+ err,
+ )
+ await asyncio.sleep(retry_timer)
+
+ 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.
+ """
+ if not await self.async_get_backup(backup_id):
+ return
+
+ try:
+ await async_files_delete_file(
+ self._cloud,
+ storage_type=_STORAGE_BACKUP,
+ filename=self._get_backup_filename(),
+ )
+ except (ClientError, CloudError) as err:
+ raise BackupAgentError("Failed to delete backup") from err
+
+ async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]:
+ """List backups."""
+ try:
+ backups = await async_files_list(self._cloud, storage_type=_STORAGE_BACKUP)
+ _LOGGER.debug("Cloud backups: %s", backups)
+ except (ClientError, CloudError) as err:
+ raise BackupAgentError("Failed to list backups") from err
+
+ return [AgentBackup.from_dict(backup["Metadata"]) for backup in backups]
+
+ 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
diff --git a/homeassistant/components/cloud/client.py b/homeassistant/components/cloud/client.py
index ee46fa42125..ea3d992e8f7 100644
--- a/homeassistant/components/cloud/client.py
+++ b/homeassistant/components/cloud/client.py
@@ -306,6 +306,7 @@ class CloudClient(Interface):
},
"version": HA_VERSION,
"instance_id": self.prefs.instance_id,
+ "name": self._hass.config.location_name,
}
async def async_alexa_message(self, payload: dict[Any, Any]) -> dict[Any, Any]:
diff --git a/homeassistant/components/cloud/const.py b/homeassistant/components/cloud/const.py
index 4392bf94827..cff71bacebc 100644
--- a/homeassistant/components/cloud/const.py
+++ b/homeassistant/components/cloud/const.py
@@ -18,6 +18,8 @@ DATA_CLOUD: HassKey[Cloud[CloudClient]] = HassKey(DOMAIN)
DATA_PLATFORMS_SETUP: HassKey[dict[str, asyncio.Event]] = HassKey(
"cloud_platforms_setup"
)
+EVENT_CLOUD_EVENT = "cloud_event"
+
REQUEST_TIMEOUT = 10
PREF_ENABLE_ALEXA = "alexa_enabled"
@@ -88,3 +90,5 @@ DISPATCHER_REMOTE_UPDATE: SignalType[Any] = SignalType("cloud_remote_update")
STT_ENTITY_UNIQUE_ID = "cloud-speech-to-text"
TTS_ENTITY_UNIQUE_ID = "cloud-text-to-speech"
+
+LOGIN_MFA_TIMEOUT = 60
diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py
index 844f0e9f11d..473f553593a 100644
--- a/homeassistant/components/cloud/http_api.py
+++ b/homeassistant/components/cloud/http_api.py
@@ -9,6 +9,7 @@ import dataclasses
from functools import wraps
from http import HTTPStatus
import logging
+import time
from typing import Any, Concatenate
import aiohttp
@@ -31,7 +32,9 @@ from homeassistant.components.http.data_validator import RequestDataValidator
from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES
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
+from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.util.location import async_detect_location_info
from .alexa_config import entity_supported as entity_supported_by_alexa
@@ -39,6 +42,8 @@ from .assist_pipeline import async_create_cloud_pipeline
from .client import CloudClient
from .const import (
DATA_CLOUD,
+ EVENT_CLOUD_EVENT,
+ LOGIN_MFA_TIMEOUT,
PREF_ALEXA_REPORT_STATE,
PREF_DISABLE_2FA,
PREF_ENABLE_ALEXA,
@@ -69,6 +74,10 @@ _CLOUD_ERRORS: dict[type[Exception], tuple[HTTPStatus, str]] = {
}
+class MFAExpiredOrNotStarted(auth.CloudError):
+ """Multi-factor authentication expired, or not started."""
+
+
@callback
def async_setup(hass: HomeAssistant) -> None:
"""Initialize the HTTP API."""
@@ -101,6 +110,11 @@ def async_setup(hass: HomeAssistant) -> None:
_CLOUD_ERRORS.update(
{
+ auth.InvalidTotpCode: (HTTPStatus.BAD_REQUEST, "Invalid TOTP code."),
+ auth.MFARequired: (
+ HTTPStatus.UNAUTHORIZED,
+ "Multi-factor authentication required.",
+ ),
auth.UserNotFound: (HTTPStatus.BAD_REQUEST, "User does not exist."),
auth.UserNotConfirmed: (HTTPStatus.BAD_REQUEST, "Email not confirmed."),
auth.UserExists: (
@@ -112,6 +126,10 @@ def async_setup(hass: HomeAssistant) -> None:
HTTPStatus.BAD_REQUEST,
"Password change required.",
),
+ MFAExpiredOrNotStarted: (
+ HTTPStatus.BAD_REQUEST,
+ "Multi-factor authentication expired, or not started. Please try again.",
+ ),
}
)
@@ -206,24 +224,64 @@ class GoogleActionsSyncView(HomeAssistantView):
class CloudLoginView(HomeAssistantView):
"""Login to Home Assistant cloud."""
+ _mfa_tokens: dict[str, str] = {}
+ _mfa_tokens_set_time: float = 0
+
url = "/api/cloud/login"
name = "api:cloud:login"
@require_admin
@_handle_cloud_errors
@RequestDataValidator(
- vol.Schema({vol.Required("email"): str, vol.Required("password"): str})
+ vol.Schema(
+ vol.All(
+ {
+ vol.Required("email"): str,
+ vol.Exclusive("password", "login"): str,
+ vol.Exclusive("code", "login"): str,
+ },
+ cv.has_at_least_one_key("password", "code"),
+ )
+ )
)
async def post(self, request: web.Request, data: dict[str, Any]) -> web.Response:
"""Handle login request."""
hass = request.app[KEY_HASS]
cloud = hass.data[DATA_CLOUD]
- await cloud.login(data["email"], data["password"])
+
+ try:
+ email = data["email"]
+ password = data.get("password")
+ code = data.get("code")
+
+ if email and password:
+ await cloud.login(email, password)
+
+ else:
+ if (
+ not self._mfa_tokens
+ or time.time() - self._mfa_tokens_set_time > LOGIN_MFA_TIMEOUT
+ ):
+ raise MFAExpiredOrNotStarted
+
+ # Voluptuous should ensure that code is not None because password is
+ assert code is not None
+
+ await cloud.login_verify_totp(email, code, self._mfa_tokens)
+ self._mfa_tokens = {}
+ self._mfa_tokens_set_time = 0
+
+ except auth.MFARequired as mfa_err:
+ self._mfa_tokens = mfa_err.mfa_tokens
+ self._mfa_tokens_set_time = time.time()
+ raise
if "assist_pipeline" in hass.config.components:
new_cloud_pipeline_id = await async_create_cloud_pipeline(hass)
else:
new_cloud_pipeline_id = None
+
+ async_dispatcher_send(hass, EVENT_CLOUD_EVENT, {"type": "login"})
return self.json({"success": True, "cloud_pipeline": new_cloud_pipeline_id})
@@ -243,6 +301,7 @@ class CloudLogoutView(HomeAssistantView):
async with asyncio.timeout(REQUEST_TIMEOUT):
await cloud.logout()
+ async_dispatcher_send(hass, EVENT_CLOUD_EVENT, {"type": "logout"})
return self.json_message("ok")
@@ -440,16 +499,16 @@ def validate_language_voice(value: tuple[str, str]) -> tuple[str, str]:
@websocket_api.websocket_command(
{
vol.Required("type"): "cloud/update_prefs",
- vol.Optional(PREF_ENABLE_GOOGLE): bool,
- vol.Optional(PREF_ENABLE_ALEXA): bool,
vol.Optional(PREF_ALEXA_REPORT_STATE): bool,
+ vol.Optional(PREF_ENABLE_ALEXA): bool,
+ vol.Optional(PREF_ENABLE_CLOUD_ICE_SERVERS): bool,
+ vol.Optional(PREF_ENABLE_GOOGLE): bool,
vol.Optional(PREF_GOOGLE_REPORT_STATE): bool,
vol.Optional(PREF_GOOGLE_SECURE_DEVICES_PIN): vol.Any(None, str),
+ vol.Optional(PREF_REMOTE_ALLOW_REMOTE_ENABLE): bool,
vol.Optional(PREF_TTS_DEFAULT_VOICE): vol.All(
vol.Coerce(tuple), validate_language_voice
),
- vol.Optional(PREF_REMOTE_ALLOW_REMOTE_ENABLE): bool,
- vol.Optional(PREF_ENABLE_CLOUD_ICE_SERVERS): bool,
}
)
@websocket_api.async_response
diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json
index 4201cb1b2d4..7ee8cf46b86 100644
--- a/homeassistant/components/cloud/manifest.json
+++ b/homeassistant/components/cloud/manifest.json
@@ -1,13 +1,18 @@
{
"domain": "cloud",
"name": "Home Assistant Cloud",
- "after_dependencies": ["assist_pipeline", "google_assistant", "alexa"],
+ "after_dependencies": [
+ "alexa",
+ "assist_pipeline",
+ "backup",
+ "google_assistant"
+ ],
"codeowners": ["@home-assistant/cloud"],
"dependencies": ["auth", "http", "repairs", "webhook"],
"documentation": "https://www.home-assistant.io/integrations/cloud",
"integration_type": "system",
"iot_class": "cloud_push",
"loggers": ["hass_nabucasa"],
- "requirements": ["hass-nabucasa==0.84.0"],
+ "requirements": ["hass-nabucasa==0.87.0"],
"single_config_entry": true
}
diff --git a/homeassistant/components/cloud/prefs.py b/homeassistant/components/cloud/prefs.py
index a0811393097..ae4b2794e1b 100644
--- a/homeassistant/components/cloud/prefs.py
+++ b/homeassistant/components/cloud/prefs.py
@@ -163,21 +163,21 @@ class CloudPreferences:
async def async_update(
self,
*,
- google_enabled: bool | UndefinedType = UNDEFINED,
alexa_enabled: bool | UndefinedType = UNDEFINED,
- remote_enabled: bool | UndefinedType = UNDEFINED,
- google_secure_devices_pin: str | None | UndefinedType = UNDEFINED,
- cloudhooks: dict[str, dict[str, str | bool]] | UndefinedType = UNDEFINED,
- cloud_user: str | UndefinedType = UNDEFINED,
alexa_report_state: bool | UndefinedType = UNDEFINED,
- google_report_state: bool | UndefinedType = UNDEFINED,
- tts_default_voice: tuple[str, str] | UndefinedType = UNDEFINED,
- remote_domain: str | None | UndefinedType = UNDEFINED,
alexa_settings_version: int | UndefinedType = UNDEFINED,
- google_settings_version: int | UndefinedType = UNDEFINED,
- google_connected: bool | UndefinedType = UNDEFINED,
- remote_allow_remote_enable: bool | UndefinedType = UNDEFINED,
cloud_ice_servers_enabled: bool | UndefinedType = UNDEFINED,
+ cloud_user: str | UndefinedType = UNDEFINED,
+ cloudhooks: dict[str, dict[str, str | bool]] | UndefinedType = UNDEFINED,
+ google_connected: bool | UndefinedType = UNDEFINED,
+ google_enabled: bool | UndefinedType = UNDEFINED,
+ google_report_state: bool | UndefinedType = UNDEFINED,
+ google_secure_devices_pin: str | None | UndefinedType = UNDEFINED,
+ google_settings_version: int | UndefinedType = UNDEFINED,
+ remote_allow_remote_enable: bool | UndefinedType = UNDEFINED,
+ remote_domain: str | None | UndefinedType = UNDEFINED,
+ remote_enabled: bool | UndefinedType = UNDEFINED,
+ tts_default_voice: tuple[str, str] | UndefinedType = UNDEFINED,
) -> None:
"""Update user preferences."""
prefs = {**self._prefs}
@@ -186,21 +186,21 @@ class CloudPreferences:
{
key: value
for key, value in (
- (PREF_ENABLE_GOOGLE, google_enabled),
- (PREF_ENABLE_ALEXA, alexa_enabled),
- (PREF_ENABLE_REMOTE, remote_enabled),
- (PREF_GOOGLE_SECURE_DEVICES_PIN, google_secure_devices_pin),
- (PREF_CLOUDHOOKS, cloudhooks),
- (PREF_CLOUD_USER, cloud_user),
(PREF_ALEXA_REPORT_STATE, alexa_report_state),
- (PREF_GOOGLE_REPORT_STATE, google_report_state),
(PREF_ALEXA_SETTINGS_VERSION, alexa_settings_version),
- (PREF_GOOGLE_SETTINGS_VERSION, google_settings_version),
- (PREF_TTS_DEFAULT_VOICE, tts_default_voice),
- (PREF_REMOTE_DOMAIN, remote_domain),
- (PREF_GOOGLE_CONNECTED, google_connected),
- (PREF_REMOTE_ALLOW_REMOTE_ENABLE, remote_allow_remote_enable),
+ (PREF_CLOUD_USER, cloud_user),
+ (PREF_CLOUDHOOKS, cloudhooks),
+ (PREF_ENABLE_ALEXA, alexa_enabled),
(PREF_ENABLE_CLOUD_ICE_SERVERS, cloud_ice_servers_enabled),
+ (PREF_ENABLE_GOOGLE, google_enabled),
+ (PREF_ENABLE_REMOTE, remote_enabled),
+ (PREF_GOOGLE_CONNECTED, google_connected),
+ (PREF_GOOGLE_REPORT_STATE, google_report_state),
+ (PREF_GOOGLE_SECURE_DEVICES_PIN, google_secure_devices_pin),
+ (PREF_GOOGLE_SETTINGS_VERSION, google_settings_version),
+ (PREF_REMOTE_ALLOW_REMOTE_ENABLE, remote_allow_remote_enable),
+ (PREF_REMOTE_DOMAIN, remote_domain),
+ (PREF_TTS_DEFAULT_VOICE, tts_default_voice),
)
if value is not UNDEFINED
}
@@ -242,6 +242,7 @@ class CloudPreferences:
PREF_ALEXA_REPORT_STATE: self.alexa_report_state,
PREF_CLOUDHOOKS: self.cloudhooks,
PREF_ENABLE_ALEXA: self.alexa_enabled,
+ PREF_ENABLE_CLOUD_ICE_SERVERS: self.cloud_ice_servers_enabled,
PREF_ENABLE_GOOGLE: self.google_enabled,
PREF_ENABLE_REMOTE: self.remote_enabled,
PREF_GOOGLE_DEFAULT_EXPOSE: self.google_default_expose,
@@ -249,7 +250,6 @@ class CloudPreferences:
PREF_GOOGLE_SECURE_DEVICES_PIN: self.google_secure_devices_pin,
PREF_REMOTE_ALLOW_REMOTE_ENABLE: self.remote_allow_remote_enable,
PREF_TTS_DEFAULT_VOICE: self.tts_default_voice,
- PREF_ENABLE_CLOUD_ICE_SERVERS: self.cloud_ice_servers_enabled,
}
@property
diff --git a/homeassistant/components/cloud/strings.json b/homeassistant/components/cloud/strings.json
index 9f7e0dbadcd..1da91f67813 100644
--- a/homeassistant/components/cloud/strings.json
+++ b/homeassistant/components/cloud/strings.json
@@ -68,12 +68,12 @@
},
"services": {
"remote_connect": {
- "name": "Remote connect",
- "description": "Makes the instance UI accessible from outside of the local network by using Home Assistant Cloud."
+ "name": "Enable remote access",
+ "description": "Makes the instance UI accessible from outside of the local network by enabling your Home Assistant Cloud connection."
},
"remote_disconnect": {
- "name": "Remote disconnect",
- "description": "Disconnects the Home Assistant UI from the Home Assistant Cloud. You will no longer be able to access your Home Assistant instance from outside your local network."
+ "name": "Disable remote access",
+ "description": "Disconnects the instance UI from Home Assistant Cloud. This disables access to it from outside your local network."
}
}
}
diff --git a/homeassistant/components/cmus/manifest.json b/homeassistant/components/cmus/manifest.json
index f7591599022..9678dc52a68 100644
--- a/homeassistant/components/cmus/manifest.json
+++ b/homeassistant/components/cmus/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/cmus",
"iot_class": "local_polling",
"loggers": ["pbr", "pycmus"],
+ "quality_scale": "legacy",
"requirements": ["pycmus==0.1.1"]
}
diff --git a/homeassistant/components/co2signal/config_flow.py b/homeassistant/components/co2signal/config_flow.py
index 622c09f0d38..0d357cce199 100644
--- a/homeassistant/components/co2signal/config_flow.py
+++ b/homeassistant/components/co2signal/config_flow.py
@@ -168,7 +168,7 @@ class ElectricityMapsConfigFlow(ConfigFlow, domain=DOMAIN):
)
return self.async_create_entry(
- title=get_extra_name(data) or "CO2 Signal",
+ title=get_extra_name(data) or "Electricity Maps",
data=data,
)
diff --git a/homeassistant/components/comed_hourly_pricing/manifest.json b/homeassistant/components/comed_hourly_pricing/manifest.json
index 791a824af8f..a3a29903ac7 100644
--- a/homeassistant/components/comed_hourly_pricing/manifest.json
+++ b/homeassistant/components/comed_hourly_pricing/manifest.json
@@ -3,5 +3,6 @@
"name": "ComEd Hourly Pricing",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/comed_hourly_pricing",
- "iot_class": "cloud_polling"
+ "iot_class": "cloud_polling",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/comelit/climate.py b/homeassistant/components/comelit/climate.py
index 0b88367c0fa..6dc7c7e26d9 100644
--- a/homeassistant/components/comelit/climate.py
+++ b/homeassistant/components/comelit/climate.py
@@ -100,7 +100,6 @@ class ComelitClimateEntity(CoordinatorEntity[ComelitSerialBridge], ClimateEntity
_attr_temperature_unit = UnitOfTemperature.CELSIUS
_attr_has_entity_name = True
_attr_name = None
- _enable_turn_on_off_backwards_compatibility = False
def __init__(
self,
diff --git a/homeassistant/components/comelit/manifest.json b/homeassistant/components/comelit/manifest.json
index d25d5c1d7d5..238dede8546 100644
--- a/homeassistant/components/comelit/manifest.json
+++ b/homeassistant/components/comelit/manifest.json
@@ -7,6 +7,5 @@
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["aiocomelit"],
- "quality_scale": "silver",
- "requirements": ["aiocomelit==0.9.1"]
+ "requirements": ["aiocomelit==0.10.1"]
}
diff --git a/homeassistant/components/comfoconnect/fan.py b/homeassistant/components/comfoconnect/fan.py
index 4e30b3ee3dc..2295fdb4e8e 100644
--- a/homeassistant/components/comfoconnect/fan.py
+++ b/homeassistant/components/comfoconnect/fan.py
@@ -68,7 +68,7 @@ class ComfoConnectFan(FanEntity):
| FanEntityFeature.TURN_OFF
| FanEntityFeature.TURN_ON
)
- _enable_turn_on_off_backwards_compatibility = False
+
_attr_preset_modes = PRESET_MODES
current_speed: float | None = None
diff --git a/homeassistant/components/comfoconnect/manifest.json b/homeassistant/components/comfoconnect/manifest.json
index ae9a092f5d9..4157cb6c311 100644
--- a/homeassistant/components/comfoconnect/manifest.json
+++ b/homeassistant/components/comfoconnect/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/comfoconnect",
"iot_class": "local_push",
"loggers": ["pycomfoconnect"],
+ "quality_scale": "legacy",
"requirements": ["pycomfoconnect==0.5.1"]
}
diff --git a/homeassistant/components/command_line/manifest.json b/homeassistant/components/command_line/manifest.json
index 3e76cf4a6a6..2a54f500504 100644
--- a/homeassistant/components/command_line/manifest.json
+++ b/homeassistant/components/command_line/manifest.json
@@ -4,5 +4,6 @@
"codeowners": ["@gjohansson-ST"],
"documentation": "https://www.home-assistant.io/integrations/command_line",
"iot_class": "local_polling",
+ "quality_scale": "legacy",
"requirements": ["jsonpath==0.82.2"]
}
diff --git a/homeassistant/components/command_line/sensor.py b/homeassistant/components/command_line/sensor.py
index 7c31af165f9..e4c1370d5f7 100644
--- a/homeassistant/components/command_line/sensor.py
+++ b/homeassistant/components/command_line/sensor.py
@@ -187,13 +187,11 @@ class CommandSensor(ManualTriggerSensorEntity):
SensorDeviceClass.TIMESTAMP,
}:
self._attr_native_value = value
- self._process_manual_data(value)
- return
-
- if value is not None:
+ elif value is not None:
self._attr_native_value = async_parse_date_datetime(
value, self.entity_id, self.device_class
)
+
self._process_manual_data(value)
self.async_write_ha_state()
diff --git a/homeassistant/components/compensation/manifest.json b/homeassistant/components/compensation/manifest.json
index 90fa6289b8d..a7e714a80b8 100644
--- a/homeassistant/components/compensation/manifest.json
+++ b/homeassistant/components/compensation/manifest.json
@@ -4,5 +4,6 @@
"codeowners": ["@Petro31"],
"documentation": "https://www.home-assistant.io/integrations/compensation",
"iot_class": "calculated",
- "requirements": ["numpy==2.1.2"]
+ "quality_scale": "legacy",
+ "requirements": ["numpy==2.2.1"]
}
diff --git a/homeassistant/components/concord232/manifest.json b/homeassistant/components/concord232/manifest.json
index e0aea5d64d9..ebd1d68064b 100644
--- a/homeassistant/components/concord232/manifest.json
+++ b/homeassistant/components/concord232/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/concord232",
"iot_class": "local_polling",
"loggers": ["concord232", "stevedore"],
+ "quality_scale": "legacy",
"requirements": ["concord232==0.15.1"]
}
diff --git a/homeassistant/components/conversation/__init__.py b/homeassistant/components/conversation/__init__.py
index 17f3b6f5ccc..898b7b2cf4f 100644
--- a/homeassistant/components/conversation/__init__.py
+++ b/homeassistant/components/conversation/__init__.py
@@ -44,7 +44,7 @@ from .const import (
SERVICE_RELOAD,
ConversationEntityFeature,
)
-from .default_agent import async_setup_default_agent
+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
@@ -207,6 +207,32 @@ async def async_prepare_agent(
await agent.async_prepare(language)
+async def async_handle_sentence_triggers(
+ hass: HomeAssistant, user_input: ConversationInput
+) -> str | None:
+ """Try to match input against sentence triggers and return response text.
+
+ Returns None if no match occurred.
+ """
+ default_agent = async_get_agent(hass)
+ assert isinstance(default_agent, DefaultAgent)
+
+ return await default_agent.async_handle_sentence_triggers(user_input)
+
+
+async def async_handle_intents(
+ hass: HomeAssistant, user_input: ConversationInput
+) -> intent.IntentResponse | None:
+ """Try to match input against registered intents and return response.
+
+ Returns None if no match occurred.
+ """
+ default_agent = async_get_agent(hass)
+ assert isinstance(default_agent, DefaultAgent)
+
+ return await default_agent.async_handle_intents(user_input)
+
+
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Register the process service."""
entity_component = EntityComponent[ConversationEntity](_LOGGER, DOMAIN, hass)
diff --git a/homeassistant/components/conversation/agent_manager.py b/homeassistant/components/conversation/agent_manager.py
index 7516d9d22ef..97dc5e1292e 100644
--- a/homeassistant/components/conversation/agent_manager.py
+++ b/homeassistant/components/conversation/agent_manager.py
@@ -75,6 +75,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 +100,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(
diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py
index 6b5cef89fd6..66ffb25fa1a 100644
--- a/homeassistant/components/conversation/default_agent.py
+++ b/homeassistant/components/conversation/default_agent.py
@@ -3,8 +3,10 @@
from __future__ import annotations
import asyncio
+from collections import OrderedDict
from collections.abc import Awaitable, Callable, Iterable
from dataclasses import dataclass
+from enum import Enum, auto
import functools
import logging
from pathlib import Path
@@ -12,15 +14,22 @@ import re
import time
from typing import IO, Any, cast
-from hassil.expression import Expression, ListReference, Sequence
-from hassil.intents import Intents, SlotList, TextSlotList, WildcardSlotList
+from hassil.expression import Expression, ListReference, Sequence, TextChunk
+from hassil.intents import (
+ Intents,
+ SlotList,
+ TextSlotList,
+ TextSlotValue,
+ WildcardSlotList,
+)
from hassil.recognize import (
MISSING_ENTITY,
- MatchEntity,
RecognizeResult,
- UnmatchedTextEntity,
recognize_all,
+ recognize_best,
)
+from hassil.string_matcher import UnmatchedRangeEntity, UnmatchedTextEntity
+from hassil.trie import Trie
from hassil.util import merge_dict
from home_assistant_intents import ErrorKey, get_intents, get_languages
import yaml
@@ -61,7 +70,7 @@ _ENTITY_REGISTRY_UPDATE_FIELDS = ["aliases", "name", "original_name"]
REGEX_TYPE = type(re.compile(""))
TRIGGER_CALLBACK_TYPE = Callable[
- [str, RecognizeResult, str | None], Awaitable[str | None]
+ [ConversationInput, RecognizeResult], Awaitable[str | None]
]
METADATA_CUSTOM_SENTENCE = "hass_custom_sentence"
METADATA_CUSTOM_FILE = "hass_custom_file"
@@ -102,6 +111,77 @@ class SentenceTriggerResult:
matched_triggers: dict[int, RecognizeResult]
+class IntentMatchingStage(Enum):
+ """Stages of intent matching."""
+
+ EXPOSED_ENTITIES_ONLY = auto()
+ """Match against exposed entities only."""
+
+ UNEXPOSED_ENTITIES = auto()
+ """Match against unexposed entities in Home Assistant."""
+
+ FUZZY = auto()
+ """Capture names that are not known to Home Assistant."""
+
+
+@dataclass(frozen=True)
+class IntentCacheKey:
+ """Key for IntentCache."""
+
+ text: str
+ """User input text."""
+
+ language: str
+ """Language of text."""
+
+ device_id: str | None
+ """Device id from user input."""
+
+
+@dataclass(frozen=True)
+class IntentCacheValue:
+ """Value for IntentCache."""
+
+ result: RecognizeResult | None
+ """Result of intent recognition."""
+
+ stage: IntentMatchingStage
+ """Stage where result was found."""
+
+
+class IntentCache:
+ """LRU cache for intent recognition results."""
+
+ def __init__(self, capacity: int) -> None:
+ """Initialize cache."""
+ self.cache: OrderedDict[IntentCacheKey, IntentCacheValue] = OrderedDict()
+ self.capacity = capacity
+
+ def get(self, key: IntentCacheKey) -> IntentCacheValue | None:
+ """Get value for cache or None."""
+ if key not in self.cache:
+ return None
+
+ # Move the key to the end to show it was recently used
+ self.cache.move_to_end(key)
+ return self.cache[key]
+
+ def put(self, key: IntentCacheKey, value: IntentCacheValue) -> None:
+ """Put a value in the cache, evicting the least recently used item if necessary."""
+ if key in self.cache:
+ # Update value and mark as recently used
+ self.cache.move_to_end(key)
+ elif len(self.cache) >= self.capacity:
+ # Evict the oldest item
+ self.cache.popitem(last=False)
+
+ self.cache[key] = value
+
+ def clear(self) -> None:
+ """Clear the cache."""
+ self.cache.clear()
+
+
def _get_language_variations(language: str) -> Iterable[str]:
"""Generate language codes with and without region."""
yield language
@@ -161,12 +241,19 @@ class DefaultAgent(ConversationEntity):
self._config_intents: dict[str, Any] = config_intents
self._slot_lists: dict[str, SlotList] | None = None
+ # Used to filter slot lists before intent matching
+ self._exposed_names_trie: Trie | None = None
+ self._unexposed_names_trie: Trie | None = None
+
# Sentences that will trigger a callback (skipping intent recognition)
- self._trigger_sentences: list[TriggerData] = []
+ self.trigger_sentences: list[TriggerData] = []
self._trigger_intents: Intents | None = None
self._unsub_clear_slot_list: list[Callable[[], None]] | None = None
self._load_intents_lock = asyncio.Lock()
+ # LRU cache to avoid unnecessary intent matching
+ self._intent_cache = IntentCache(capacity=128)
+
@property
def supported_languages(self) -> list[str]:
"""Return a list of supported languages."""
@@ -213,13 +300,10 @@ class DefaultAgent(ConversationEntity):
async_listen_entity_updates(self.hass, DOMAIN, self._async_clear_slot_list),
]
- async def async_recognize(
- self, user_input: ConversationInput
- ) -> RecognizeResult | SentenceTriggerResult | None:
+ async def async_recognize_intent(
+ self, user_input: ConversationInput, strict_intents_only: bool = False
+ ) -> RecognizeResult | None:
"""Recognize intent from user input."""
- if trigger_result := await self._match_triggers(user_input.text):
- return trigger_result
-
language = user_input.language or self.hass.config.language
lang_intents = await self.async_get_or_load_intents(language)
@@ -231,6 +315,16 @@ class DefaultAgent(ConversationEntity):
slot_lists = self._make_slot_lists()
intent_context = self._make_intent_context(user_input)
+ if self._exposed_names_trie is not None:
+ # Filter by input string
+ text_lower = user_input.text.strip().lower()
+ slot_lists["name"] = TextSlotList(
+ name="name",
+ values=[
+ result[2] for result in self._exposed_names_trie.find(text_lower)
+ ],
+ )
+
start = time.monotonic()
result = await self.hass.async_add_executor_job(
@@ -240,6 +334,7 @@ class DefaultAgent(ConversationEntity):
slot_lists,
intent_context,
language,
+ strict_intents_only,
)
_LOGGER.debug(
@@ -251,56 +346,36 @@ class DefaultAgent(ConversationEntity):
async def async_process(self, user_input: ConversationInput) -> ConversationResult:
"""Process a sentence."""
- language = user_input.language or self.hass.config.language
- conversation_id = None # Not supported
-
- result = await self.async_recognize(user_input)
# Check if a trigger matched
- if isinstance(result, SentenceTriggerResult):
- # Gather callback responses in parallel
- trigger_callbacks = [
- self._trigger_sentences[trigger_id].callback(
- result.sentence, trigger_result, user_input.device_id
- )
- for trigger_id, trigger_result in result.matched_triggers.items()
- ]
-
- # Use first non-empty result as response.
- #
- # There may be multiple copies of a trigger running when editing in
- # the UI, so it's critical that we filter out empty responses here.
- response_text: str | None = None
- response_set_by_trigger = False
- for trigger_future in asyncio.as_completed(trigger_callbacks):
- trigger_response = await trigger_future
- if trigger_response is None:
- continue
-
- response_text = trigger_response
- response_set_by_trigger = True
- break
+ 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=language)
+ response = intent.IntentResponse(
+ language=user_input.language or self.hass.config.language
+ )
response.response_type = intent.IntentResponseType.ACTION_DONE
-
- if response_set_by_trigger:
- # Response was explicitly set to empty
- response_text = response_text or ""
- elif not response_text:
- # Use translated acknowledgment for pipeline language
- translations = await translation.async_get_translations(
- self.hass, language, DOMAIN, [DOMAIN]
- )
- response_text = translations.get(
- f"component.{DOMAIN}.agent.done", "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:
+ """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)
@@ -436,21 +511,235 @@ class DefaultAgent(ConversationEntity):
slot_lists: dict[str, SlotList],
intent_context: dict[str, Any] | None,
language: str,
+ strict_intents_only: bool,
) -> RecognizeResult | None:
"""Search intents for a match to user input."""
- strict_result = self._recognize_strict(
- user_input, lang_intents, slot_lists, intent_context, language
- )
+ skip_exposed_match = False
- if strict_result is not None:
- # Successful strict match
- return strict_result
+ # Try cache first
+ cache_key = IntentCacheKey(
+ text=user_input.text, language=language, device_id=user_input.device_id
+ )
+ cache_value = self._intent_cache.get(cache_key)
+ if cache_value is not None:
+ if (cache_value.result is not None) and (
+ cache_value.stage == IntentMatchingStage.EXPOSED_ENTITIES_ONLY
+ ):
+ _LOGGER.debug("Got cached result for exposed entities")
+ return cache_value.result
+
+ # Continue with matching, but we know we won't succeed for exposed
+ # entities only.
+ skip_exposed_match = True
+
+ if not skip_exposed_match:
+ start_time = time.monotonic()
+ strict_result = self._recognize_strict(
+ user_input, lang_intents, slot_lists, intent_context, language
+ )
+ _LOGGER.debug(
+ "Checked exposed entities in %s second(s)",
+ time.monotonic() - start_time,
+ )
+
+ # Update cache
+ self._intent_cache.put(
+ cache_key,
+ IntentCacheValue(
+ result=strict_result,
+ stage=IntentMatchingStage.EXPOSED_ENTITIES_ONLY,
+ ),
+ )
+
+ if strict_result is not None:
+ # Successful strict match with exposed entities
+ return strict_result
+
+ if strict_intents_only:
+ # Don't try matching against all entities or doing a fuzzy match
+ return None
# Try again with all entities (including unexposed)
+ skip_unexposed_entities_match = False
+ if cache_value is not None:
+ if (cache_value.result is not None) and (
+ cache_value.stage == IntentMatchingStage.UNEXPOSED_ENTITIES
+ ):
+ _LOGGER.debug("Got cached result for all entities")
+ return cache_value.result
+
+ # Continue with matching, but we know we won't succeed for all
+ # entities.
+ skip_unexposed_entities_match = True
+
+ if not skip_unexposed_entities_match:
+ unexposed_entities_slot_lists = {
+ **slot_lists,
+ "name": self._get_unexposed_entity_names(user_input.text),
+ }
+
+ start_time = time.monotonic()
+ strict_result = self._recognize_strict(
+ user_input,
+ lang_intents,
+ unexposed_entities_slot_lists,
+ intent_context,
+ language,
+ )
+
+ _LOGGER.debug(
+ "Checked all entities in %s second(s)", time.monotonic() - start_time
+ )
+
+ # Update cache
+ self._intent_cache.put(
+ cache_key,
+ IntentCacheValue(
+ result=strict_result, stage=IntentMatchingStage.UNEXPOSED_ENTITIES
+ ),
+ )
+
+ if strict_result is not None:
+ # Not a successful match, but useful for an error message.
+ # This should fail the intent handling phase (async_match_targets).
+ return strict_result
+
+ # Try again with missing entities enabled
+ skip_fuzzy_match = False
+ if cache_value is not None:
+ if (cache_value.result is not None) and (
+ cache_value.stage == IntentMatchingStage.FUZZY
+ ):
+ _LOGGER.debug("Got cached result for fuzzy match")
+ return cache_value.result
+
+ # We know we won't succeed for fuzzy matching.
+ skip_fuzzy_match = True
+
+ maybe_result: RecognizeResult | None = None
+ if not skip_fuzzy_match:
+ start_time = time.monotonic()
+ best_num_matched_entities = 0
+ best_num_unmatched_entities = 0
+ best_num_unmatched_ranges = 0
+ for result in recognize_all(
+ user_input.text,
+ lang_intents.intents,
+ slot_lists=slot_lists,
+ intent_context=intent_context,
+ allow_unmatched_entities=True,
+ ):
+ if result.text_chunks_matched < 1:
+ # Skip results that don't match any literal text
+ continue
+
+ # Don't count missing entities that couldn't be filled from context
+ num_matched_entities = 0
+ for matched_entity in result.entities_list:
+ if matched_entity.name not in result.unmatched_entities:
+ num_matched_entities += 1
+
+ num_unmatched_entities = 0
+ num_unmatched_ranges = 0
+ for unmatched_entity in result.unmatched_entities_list:
+ if isinstance(unmatched_entity, UnmatchedTextEntity):
+ if unmatched_entity.text != MISSING_ENTITY:
+ num_unmatched_entities += 1
+ elif isinstance(unmatched_entity, UnmatchedRangeEntity):
+ num_unmatched_ranges += 1
+ num_unmatched_entities += 1
+ else:
+ num_unmatched_entities += 1
+
+ if (
+ (maybe_result is None) # first result
+ or (num_matched_entities > best_num_matched_entities)
+ or (
+ # Fewer unmatched entities
+ (num_matched_entities == best_num_matched_entities)
+ and (num_unmatched_entities < best_num_unmatched_entities)
+ )
+ or (
+ # Prefer unmatched ranges
+ (num_matched_entities == best_num_matched_entities)
+ and (num_unmatched_entities == best_num_unmatched_entities)
+ and (num_unmatched_ranges > best_num_unmatched_ranges)
+ )
+ or (
+ # More literal text matched
+ (num_matched_entities == best_num_matched_entities)
+ and (num_unmatched_entities == best_num_unmatched_entities)
+ and (num_unmatched_ranges == best_num_unmatched_ranges)
+ and (
+ result.text_chunks_matched
+ > maybe_result.text_chunks_matched
+ )
+ )
+ or (
+ # Prefer match failures with entities
+ (result.text_chunks_matched == maybe_result.text_chunks_matched)
+ and (num_unmatched_entities == best_num_unmatched_entities)
+ and (num_unmatched_ranges == best_num_unmatched_ranges)
+ and (
+ ("name" in result.entities)
+ or ("name" in result.unmatched_entities)
+ )
+ )
+ ):
+ maybe_result = result
+ best_num_matched_entities = num_matched_entities
+ best_num_unmatched_entities = num_unmatched_entities
+ best_num_unmatched_ranges = num_unmatched_ranges
+
+ # Update cache
+ self._intent_cache.put(
+ cache_key,
+ IntentCacheValue(result=maybe_result, stage=IntentMatchingStage.FUZZY),
+ )
+
+ _LOGGER.debug(
+ "Did fuzzy match in %s second(s)", time.monotonic() - start_time
+ )
+
+ return maybe_result
+
+ def _get_unexposed_entity_names(self, text: str) -> TextSlotList:
+ """Get filtered slot list with unexposed entity names in Home Assistant."""
+ if self._unexposed_names_trie is None:
+ # Build trie
+ self._unexposed_names_trie = Trie()
+ for name_tuple in self._get_entity_name_tuples(exposed=False):
+ self._unexposed_names_trie.insert(
+ name_tuple[0].lower(),
+ TextSlotValue.from_tuple(name_tuple, allow_template=False),
+ )
+
+ # Build filtered slot list
+ text_lower = text.strip().lower()
+ return TextSlotList(
+ name="name",
+ values=[
+ result[2] for result in self._unexposed_names_trie.find(text_lower)
+ ],
+ )
+
+ def _get_entity_name_tuples(
+ self, exposed: bool
+ ) -> Iterable[tuple[str, str, dict[str, Any]]]:
+ """Yield (input name, output name, context) tuples for entities."""
entity_registry = er.async_get(self.hass)
- all_entity_names: list[tuple[str, str, dict[str, Any]]] = []
for state in self.hass.states.async_all():
+ entity_exposed = async_should_expose(self.hass, DOMAIN, state.entity_id)
+ if exposed and (not entity_exposed):
+ # Required exposed, entity is not
+ continue
+
+ if (not exposed) and entity_exposed:
+ # Required not exposed, entity is
+ continue
+
+ # Checked against "requires_context" and "excludes_context" in hassil
context = {"domain": state.domain}
if state.attributes:
# Include some attributes
@@ -459,99 +748,18 @@ class DefaultAgent(ConversationEntity):
continue
context[attr] = state.attributes[attr]
- if entity := entity_registry.async_get(state.entity_id):
- # Skip config/hidden entities
- if (entity.entity_category is not None) or (
- entity.hidden_by is not None
- ):
- continue
+ if (
+ entity := entity_registry.async_get(state.entity_id)
+ ) and entity.aliases:
+ for alias in entity.aliases:
+ alias = alias.strip()
+ if not alias:
+ continue
- if entity.aliases:
- # Also add aliases
- for alias in entity.aliases:
- if not alias.strip():
- continue
-
- all_entity_names.append((alias, alias, context))
+ yield (alias, alias, context)
# Default name
- all_entity_names.append((state.name, state.name, context))
-
- slot_lists = {
- **slot_lists,
- "name": TextSlotList.from_tuples(all_entity_names, allow_template=False),
- }
-
- strict_result = self._recognize_strict(
- user_input,
- lang_intents,
- slot_lists,
- intent_context,
- language,
- )
-
- if strict_result is not None:
- # Not a successful match, but useful for an error message.
- # This should fail the intent handling phase (async_match_targets).
- return strict_result
-
- # Try again with missing entities enabled
- maybe_result: RecognizeResult | None = None
- best_num_matched_entities = 0
- best_num_unmatched_entities = 0
- for result in recognize_all(
- user_input.text,
- lang_intents.intents,
- slot_lists=slot_lists,
- intent_context=intent_context,
- allow_unmatched_entities=True,
- ):
- if result.text_chunks_matched < 1:
- # Skip results that don't match any literal text
- continue
-
- # Don't count missing entities that couldn't be filled from context
- num_matched_entities = 0
- for matched_entity in result.entities_list:
- if matched_entity.name not in result.unmatched_entities:
- num_matched_entities += 1
-
- num_unmatched_entities = 0
- for unmatched_entity in result.unmatched_entities_list:
- if isinstance(unmatched_entity, UnmatchedTextEntity):
- if unmatched_entity.text != MISSING_ENTITY:
- num_unmatched_entities += 1
- else:
- num_unmatched_entities += 1
-
- if (
- (maybe_result is None) # first result
- or (num_matched_entities > best_num_matched_entities)
- or (
- # Fewer unmatched entities
- (num_matched_entities == best_num_matched_entities)
- and (num_unmatched_entities < best_num_unmatched_entities)
- )
- or (
- # More literal text matched
- (num_matched_entities == best_num_matched_entities)
- and (num_unmatched_entities == best_num_unmatched_entities)
- and (result.text_chunks_matched > maybe_result.text_chunks_matched)
- )
- or (
- # Prefer match failures with entities
- (result.text_chunks_matched == maybe_result.text_chunks_matched)
- and (
- ("name" in result.entities)
- or ("name" in result.unmatched_entities)
- )
- )
- ):
- maybe_result = result
- best_num_matched_entities = num_matched_entities
- best_num_unmatched_entities = num_unmatched_entities
-
- return maybe_result
+ yield (state.name, state.name, context)
def _recognize_strict(
self,
@@ -562,76 +770,15 @@ class DefaultAgent(ConversationEntity):
language: str,
) -> RecognizeResult | None:
"""Search intents for a strict match to user input."""
- custom_found = False
- name_found = False
- best_results: list[RecognizeResult] = []
- best_name_quality: int | None = None
- best_text_chunks_matched: int | None = None
- for result in recognize_all(
+ return recognize_best(
user_input.text,
lang_intents.intents,
slot_lists=slot_lists,
intent_context=intent_context,
language=language,
- ):
- # Prioritize user intents
- is_custom = (
- result.intent_metadata is not None
- and result.intent_metadata.get(METADATA_CUSTOM_SENTENCE)
- )
-
- if custom_found and not is_custom:
- continue
-
- if not custom_found and is_custom:
- custom_found = True
- # Clear builtin results
- name_found = False
- best_results = []
- best_name_quality = None
- best_text_chunks_matched = None
-
- # Prioritize results with a "name" slot
- name = result.entities.get("name")
- is_name = name and not name.is_wildcard
-
- if name_found and not is_name:
- continue
-
- if not name_found and is_name:
- name_found = True
- # Clear non-name results
- best_results = []
- best_text_chunks_matched = None
-
- if is_name:
- # Prioritize results with a better "name" slot
- name_quality = len(cast(MatchEntity, name).value.split())
- if (best_name_quality is None) or (name_quality > best_name_quality):
- best_name_quality = name_quality
- # Clear worse name results
- best_results = []
- best_text_chunks_matched = None
- elif name_quality < best_name_quality:
- continue
-
- # Prioritize results with more literal text
- # This causes wildcards to match last.
- if (best_text_chunks_matched is None) or (
- result.text_chunks_matched > best_text_chunks_matched
- ):
- best_results = [result]
- best_text_chunks_matched = result.text_chunks_matched
- elif result.text_chunks_matched == best_text_chunks_matched:
- # Accumulate results with the same number of literal text matched.
- # We will resolve the ambiguity below.
- best_results.append(result)
-
- if best_results:
- # Successful strict match
- return best_results[0]
-
- return None
+ best_metadata_key=METADATA_CUSTOM_SENTENCE,
+ best_slot_name="name",
+ )
async def _build_speech(
self,
@@ -717,6 +864,9 @@ class DefaultAgent(ConversationEntity):
self._lang_intents.pop(language, None)
_LOGGER.debug("Cleared intents for language: %s", language)
+ # Intents have changed, so we must clear the cache
+ self._intent_cache.clear()
+
async def async_prepare(self, language: str | None = None) -> None:
"""Load intents for a language."""
if language is None:
@@ -901,10 +1051,15 @@ class DefaultAgent(ConversationEntity):
if self._unsub_clear_slot_list is None:
return
self._slot_lists = None
+ self._exposed_names_trie = None
+ self._unexposed_names_trie = None
for unsub in self._unsub_clear_slot_list:
unsub()
self._unsub_clear_slot_list = None
+ # Slot lists have changed, so we must clear the cache
+ self._intent_cache.clear()
+
@core.callback
def _make_slot_lists(self) -> dict[str, SlotList]:
"""Create slot lists with areas and entity names/aliases."""
@@ -913,8 +1068,6 @@ class DefaultAgent(ConversationEntity):
start = time.monotonic()
- entity_registry = er.async_get(self.hass)
-
# Gather entity names, keeping track of exposed names.
# We try intent recognition with only exposed names first, then all names.
#
@@ -922,35 +1075,7 @@ class DefaultAgent(ConversationEntity):
# have the same name. The intent matcher doesn't gather all matching
# values for a list, just the first. So we will need to match by name no
# matter what.
- exposed_entity_names = []
- for state in self.hass.states.async_all():
- is_exposed = async_should_expose(self.hass, DOMAIN, state.entity_id)
-
- # Checked against "requires_context" and "excludes_context" in hassil
- context = {"domain": state.domain}
- if state.attributes:
- # Include some attributes
- for attr in DEFAULT_EXPOSED_ATTRIBUTES:
- if attr not in state.attributes:
- continue
- context[attr] = state.attributes[attr]
-
- if (
- entity := entity_registry.async_get(state.entity_id)
- ) and entity.aliases:
- for alias in entity.aliases:
- if not alias.strip():
- continue
-
- name_tuple = (alias, alias, context)
- if is_exposed:
- exposed_entity_names.append(name_tuple)
-
- # Default name
- name_tuple = (state.name, state.name, context)
- if is_exposed:
- exposed_entity_names.append(name_tuple)
-
+ exposed_entity_names = list(self._get_entity_name_tuples(exposed=True))
_LOGGER.debug("Exposed entities: %s", exposed_entity_names)
# Expose all areas.
@@ -983,11 +1108,17 @@ class DefaultAgent(ConversationEntity):
floor_names.append((alias, floor.name))
+ # Build trie
+ self._exposed_names_trie = Trie()
+ name_list = TextSlotList.from_tuples(exposed_entity_names, allow_template=False)
+ for name_value in name_list.values:
+ assert isinstance(name_value.text_in, TextChunk)
+ name_text = name_value.text_in.text.strip().lower()
+ self._exposed_names_trie.insert(name_text, name_value)
+
self._slot_lists = {
"area": TextSlotList.from_tuples(area_names, allow_template=False),
- "name": TextSlotList.from_tuples(
- exposed_entity_names, allow_template=False
- ),
+ "name": name_list,
"floor": TextSlotList.from_tuples(floor_names, allow_template=False),
}
@@ -1057,7 +1188,7 @@ class DefaultAgent(ConversationEntity):
) -> core.CALLBACK_TYPE:
"""Register a list of sentences that will trigger a callback when recognized."""
trigger_data = TriggerData(sentences=sentences, callback=callback)
- self._trigger_sentences.append(trigger_data)
+ self.trigger_sentences.append(trigger_data)
# Force rebuild on next use
self._trigger_intents = None
@@ -1074,7 +1205,7 @@ class DefaultAgent(ConversationEntity):
# This works because the intents are rebuilt on every
# register/unregister.
str(trigger_id): {"data": [{"sentences": trigger_data.sentences}]}
- for trigger_id, trigger_data in enumerate(self._trigger_sentences)
+ for trigger_id, trigger_data in enumerate(self.trigger_sentences)
},
}
@@ -1097,18 +1228,20 @@ class DefaultAgent(ConversationEntity):
@core.callback
def _unregister_trigger(self, trigger_data: TriggerData) -> None:
"""Unregister a set of trigger sentences."""
- self._trigger_sentences.remove(trigger_data)
+ self.trigger_sentences.remove(trigger_data)
# Force rebuild on next use
self._trigger_intents = None
- async def _match_triggers(self, sentence: str) -> SentenceTriggerResult | None:
+ async def async_recognize_sentence_trigger(
+ self, user_input: ConversationInput
+ ) -> SentenceTriggerResult | None:
"""Try to match sentence against registered trigger sentences.
Calls the registered callbacks if there's a match and returns a sentence
trigger result.
"""
- if not self._trigger_sentences:
+ if not self.trigger_sentences:
# No triggers registered
return None
@@ -1120,7 +1253,7 @@ class DefaultAgent(ConversationEntity):
matched_triggers: dict[int, RecognizeResult] = {}
matched_template: str | None = None
- for result in recognize_all(sentence, self._trigger_intents):
+ for result in recognize_all(user_input.text, self._trigger_intents):
if result.intent_sentence is not None:
matched_template = result.intent_sentence.text
@@ -1137,12 +1270,86 @@ class DefaultAgent(ConversationEntity):
_LOGGER.debug(
"'%s' matched %s trigger(s): %s",
- sentence,
+ user_input.text,
len(matched_triggers),
list(matched_triggers),
)
- return SentenceTriggerResult(sentence, matched_template, matched_triggers)
+ return SentenceTriggerResult(
+ user_input.text, matched_template, matched_triggers
+ )
+
+ async def _handle_trigger_result(
+ self, result: SentenceTriggerResult, user_input: ConversationInput
+ ) -> str:
+ """Run sentence trigger callbacks and return response text."""
+
+ # Gather callback responses in parallel
+ trigger_callbacks = [
+ self.trigger_sentences[trigger_id].callback(user_input, trigger_result)
+ for trigger_id, trigger_result in result.matched_triggers.items()
+ ]
+
+ # Use first non-empty result as response.
+ #
+ # There may be multiple copies of a trigger running when editing in
+ # the UI, so it's critical that we filter out empty responses here.
+ response_text = ""
+ response_set_by_trigger = False
+ for trigger_future in asyncio.as_completed(trigger_callbacks):
+ trigger_response = await trigger_future
+ if trigger_response is None:
+ continue
+
+ response_text = trigger_response
+ response_set_by_trigger = True
+ break
+
+ if response_set_by_trigger:
+ # Response was explicitly set to empty
+ response_text = response_text or ""
+ elif not response_text:
+ # Use translated acknowledgment for pipeline language
+ language = user_input.language or self.hass.config.language
+ translations = await translation.async_get_translations(
+ self.hass, language, DOMAIN, [DOMAIN]
+ )
+ response_text = translations.get(
+ f"component.{DOMAIN}.conversation.agent.done", "Done"
+ )
+
+ return response_text
+
+ async def async_handle_sentence_triggers(
+ self, user_input: ConversationInput
+ ) -> str | None:
+ """Try to input sentence against sentence triggers and return response text.
+
+ Returns None if no match occurred.
+ """
+ if trigger_result := await self.async_recognize_sentence_trigger(user_input):
+ return await self._handle_trigger_result(trigger_result, user_input)
+
+ return None
+
+ async def async_handle_intents(
+ self,
+ user_input: ConversationInput,
+ ) -> intent.IntentResponse | None:
+ """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.
+ """
+ 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
def _make_error_result(
@@ -1154,7 +1361,6 @@ def _make_error_result(
"""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)
diff --git a/homeassistant/components/conversation/http.py b/homeassistant/components/conversation/http.py
index df1ffc7f74f..8134ecb0eee 100644
--- a/homeassistant/components/conversation/http.py
+++ b/homeassistant/components/conversation/http.py
@@ -6,12 +6,8 @@ from collections.abc import Iterable
from typing import Any
from aiohttp import web
-from hassil.recognize import (
- MISSING_ENTITY,
- RecognizeResult,
- UnmatchedRangeEntity,
- UnmatchedTextEntity,
-)
+from hassil.recognize import MISSING_ENTITY, RecognizeResult
+from hassil.string_matcher import UnmatchedRangeEntity, UnmatchedTextEntity
import voluptuous as vol
from homeassistant.components import http, websocket_api
@@ -28,11 +24,7 @@ from .agent_manager import (
get_agent_manager,
)
from .const import DATA_COMPONENT, DATA_DEFAULT_ENTITY
-from .default_agent import (
- METADATA_CUSTOM_FILE,
- METADATA_CUSTOM_SENTENCE,
- SentenceTriggerResult,
-)
+from .default_agent import METADATA_CUSTOM_FILE, METADATA_CUSTOM_SENTENCE
from .entity import ConversationEntity
from .models import ConversationInput
@@ -44,6 +36,7 @@ def async_setup(hass: HomeAssistant) -> None:
websocket_api.async_register_command(hass, websocket_process)
websocket_api.async_register_command(hass, websocket_prepare)
websocket_api.async_register_command(hass, websocket_list_agents)
+ websocket_api.async_register_command(hass, websocket_list_sentences)
websocket_api.async_register_command(hass, websocket_hass_agent_debug)
@@ -158,6 +151,26 @@ async def websocket_list_agents(
connection.send_message(websocket_api.result_message(msg["id"], {"agents": agents}))
+@websocket_api.websocket_command(
+ {
+ vol.Required("type"): "conversation/sentences/list",
+ }
+)
+@websocket_api.require_admin
+@websocket_api.async_response
+async def websocket_list_sentences(
+ hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict
+) -> None:
+ """List custom registered sentences."""
+ agent = hass.data[DATA_DEFAULT_ENTITY]
+
+ sentences = []
+ for trigger_data in agent.trigger_sentences:
+ sentences.extend(trigger_data.sentences)
+
+ connection.send_result(msg["id"], {"trigger_sentences": sentences})
+
+
@websocket_api.websocket_command(
{
vol.Required("type"): "conversation/agent/homeassistant/debug",
@@ -171,44 +184,41 @@ async def websocket_hass_agent_debug(
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict
) -> None:
"""Return intents that would be matched by the default agent for a list of sentences."""
- results = [
- await hass.data[DATA_DEFAULT_ENTITY].async_recognize(
- ConversationInput(
- text=sentence,
- context=connection.context(msg),
- conversation_id=None,
- device_id=msg.get("device_id"),
- language=msg.get("language", hass.config.language),
- agent_id=None,
- )
- )
- for sentence in msg["sentences"]
- ]
+ agent = hass.data[DATA_DEFAULT_ENTITY]
# Return results for each sentence in the same order as the input.
result_dicts: list[dict[str, Any] | None] = []
- for result in results:
+ for sentence in msg["sentences"]:
+ user_input = ConversationInput(
+ text=sentence,
+ context=connection.context(msg),
+ conversation_id=None,
+ device_id=msg.get("device_id"),
+ language=msg.get("language", hass.config.language),
+ agent_id=None,
+ )
result_dict: dict[str, Any] | None = None
- if isinstance(result, SentenceTriggerResult):
+
+ if trigger_result := await agent.async_recognize_sentence_trigger(user_input):
result_dict = {
# Matched a user-defined sentence trigger.
# We can't provide the response here without executing the
# trigger.
"match": True,
"source": "trigger",
- "sentence_template": result.sentence_template or "",
+ "sentence_template": trigger_result.sentence_template or "",
}
- elif isinstance(result, RecognizeResult):
- successful_match = not result.unmatched_entities
+ elif intent_result := await agent.async_recognize_intent(user_input):
+ successful_match = not intent_result.unmatched_entities
result_dict = {
# Name of the matching intent (or the closest)
"intent": {
- "name": result.intent.name,
+ "name": intent_result.intent.name,
},
# Slot values that would be received by the intent
"slots": { # direct access to values
entity_key: entity.text or entity.value
- for entity_key, entity in result.entities.items()
+ for entity_key, entity in intent_result.entities.items()
},
# Extra slot details, such as the originally matched text
"details": {
@@ -217,7 +227,7 @@ async def websocket_hass_agent_debug(
"value": entity.value,
"text": entity.text,
}
- for entity_key, entity in result.entities.items()
+ for entity_key, entity in intent_result.entities.items()
},
# Entities/areas/etc. that would be targeted
"targets": {},
@@ -226,24 +236,26 @@ async def websocket_hass_agent_debug(
# Text of the sentence template that matched (or was closest)
"sentence_template": "",
# When match is incomplete, this will contain the best slot guesses
- "unmatched_slots": _get_unmatched_slots(result),
+ "unmatched_slots": _get_unmatched_slots(intent_result),
}
if successful_match:
result_dict["targets"] = {
state.entity_id: {"matched": is_matched}
- for state, is_matched in _get_debug_targets(hass, result)
+ for state, is_matched in _get_debug_targets(hass, intent_result)
}
- if result.intent_sentence is not None:
- result_dict["sentence_template"] = result.intent_sentence.text
+ if intent_result.intent_sentence is not None:
+ result_dict["sentence_template"] = intent_result.intent_sentence.text
# Inspect metadata to determine if this matched a custom sentence
- if result.intent_metadata and result.intent_metadata.get(
+ if intent_result.intent_metadata and intent_result.intent_metadata.get(
METADATA_CUSTOM_SENTENCE
):
result_dict["source"] = "custom"
- result_dict["file"] = result.intent_metadata.get(METADATA_CUSTOM_FILE)
+ result_dict["file"] = intent_result.intent_metadata.get(
+ METADATA_CUSTOM_FILE
+ )
else:
result_dict["source"] = "builtin"
diff --git a/homeassistant/components/conversation/manifest.json b/homeassistant/components/conversation/manifest.json
index 8b5c6ef173f..979ea7538c4 100644
--- a/homeassistant/components/conversation/manifest.json
+++ b/homeassistant/components/conversation/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/conversation",
"integration_type": "system",
"quality_scale": "internal",
- "requirements": ["hassil==1.7.4", "home-assistant-intents==2024.11.6"]
+ "requirements": ["hassil==2.1.0", "home-assistant-intents==2025.1.1"]
}
diff --git a/homeassistant/components/conversation/models.py b/homeassistant/components/conversation/models.py
index 724e520e6df..9462c597f23 100644
--- a/homeassistant/components/conversation/models.py
+++ b/homeassistant/components/conversation/models.py
@@ -40,6 +40,21 @@ 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 {
+ "text": self.text,
+ "context": self.context.as_dict(),
+ "conversation_id": self.conversation_id,
+ "device_id": self.device_id,
+ "language": self.language,
+ "agent_id": self.agent_id,
+ "extra_system_prompt": self.extra_system_prompt,
+ }
+
@dataclass(slots=True)
class ConversationResult:
diff --git a/homeassistant/components/conversation/trigger.py b/homeassistant/components/conversation/trigger.py
index ec7ecc76da0..24eb54c5694 100644
--- a/homeassistant/components/conversation/trigger.py
+++ b/homeassistant/components/conversation/trigger.py
@@ -4,7 +4,8 @@ from __future__ import annotations
from typing import Any
-from hassil.recognize import PUNCTUATION, RecognizeResult
+from hassil.recognize import RecognizeResult
+from hassil.util import PUNCTUATION_ALL
import voluptuous as vol
from homeassistant.const import CONF_COMMAND, CONF_PLATFORM
@@ -15,12 +16,13 @@ from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo
from homeassistant.helpers.typing import UNDEFINED, ConfigType
from .const import DATA_DEFAULT_ENTITY, DOMAIN
+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.search(sentence):
+ if PUNCTUATION_ALL.search(sentence):
raise vol.Invalid("sentence should not contain punctuation")
return value
@@ -61,7 +63,7 @@ async def async_attach_trigger(
job = HassJob(action)
async def call_action(
- sentence: str, result: RecognizeResult, device_id: str | None
+ user_input: ConversationInput, result: RecognizeResult
) -> str | None:
"""Call action with right context."""
@@ -82,12 +84,13 @@ async def async_attach_trigger(
trigger_input: dict[str, Any] = { # Satisfy type checker
**trigger_data,
"platform": DOMAIN,
- "sentence": sentence,
+ "sentence": user_input.text,
"details": details,
"slots": { # direct access to values
entity_name: entity["value"] for entity_name, entity in details.items()
},
- "device_id": device_id,
+ "device_id": user_input.device_id,
+ "user_input": user_input.as_dict(),
}
# Wait for the automation to complete
diff --git a/homeassistant/components/cookidoo/__init__.py b/homeassistant/components/cookidoo/__init__.py
new file mode 100644
index 00000000000..48c37c64db0
--- /dev/null
+++ b/homeassistant/components/cookidoo/__init__.py
@@ -0,0 +1,51 @@
+"""The Cookidoo integration."""
+
+from __future__ import annotations
+
+from cookidoo_api import Cookidoo, CookidooConfig, get_localization_options
+
+from homeassistant.const import (
+ CONF_COUNTRY,
+ CONF_EMAIL,
+ CONF_LANGUAGE,
+ CONF_PASSWORD,
+ Platform,
+)
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+
+from .coordinator import CookidooConfigEntry, CookidooDataUpdateCoordinator
+
+PLATFORMS: list[Platform] = [Platform.BUTTON, Platform.TODO]
+
+
+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],
+ )
+
+ 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
+
+ await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
+
+ return True
+
+
+async def async_unload_entry(hass: HomeAssistant, entry: CookidooConfigEntry) -> bool:
+ """Unload a config entry."""
+ return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
diff --git a/homeassistant/components/cookidoo/button.py b/homeassistant/components/cookidoo/button.py
new file mode 100644
index 00000000000..2a20a156db4
--- /dev/null
+++ b/homeassistant/components/cookidoo/button.py
@@ -0,0 +1,70 @@
+"""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
+ self._attr_unique_id = f"{coordinator.config_entry.entry_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
new file mode 100644
index 00000000000..80487ed757f
--- /dev/null
+++ b/homeassistant/components/cookidoo/config_flow.py
@@ -0,0 +1,247 @@
+"""Config flow for Cookidoo integration."""
+
+from __future__ import annotations
+
+from collections.abc import Mapping
+import logging
+from typing import Any
+
+from cookidoo_api import (
+ Cookidoo,
+ CookidooAuthException,
+ CookidooConfig,
+ CookidooRequestException,
+ get_country_options,
+ get_localization_options,
+)
+import voluptuous as vol
+
+from homeassistant.config_entries import (
+ SOURCE_RECONFIGURE,
+ SOURCE_USER,
+ ConfigFlow,
+ 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,
+ LanguageSelector,
+ LanguageSelectorConfig,
+ TextSelector,
+ TextSelectorConfig,
+ TextSelectorType,
+)
+
+from .const import DOMAIN
+
+_LOGGER = logging.getLogger(__name__)
+
+AUTH_DATA_SCHEMA = {
+ vol.Required(CONF_EMAIL): TextSelector(
+ TextSelectorConfig(
+ type=TextSelectorType.EMAIL,
+ autocomplete="email",
+ ),
+ ),
+ vol.Required(CONF_PASSWORD): TextSelector(
+ TextSelectorConfig(
+ type=TextSelectorType.PASSWORD,
+ autocomplete="current-password",
+ ),
+ ),
+}
+
+
+class CookidooConfigFlow(ConfigFlow, domain=DOMAIN):
+ """Handle a config flow for Cookidoo."""
+
+ COUNTRY_DATA_SCHEMA: dict
+ LANGUAGE_DATA_SCHEMA: dict
+
+ user_input: dict[str, Any]
+
+ async def async_step_reconfigure(
+ self, user_input: dict[str, Any]
+ ) -> ConfigFlowResult:
+ """Perform reconfigure upon an user action."""
+ return await self.async_step_user(user_input)
+
+ async def async_step_user(
+ self,
+ user_input: dict[str, Any] | None = None,
+ ) -> ConfigFlowResult:
+ """Handle the user step as well as serve for reconfiguration."""
+ errors: dict[str, str] = {}
+
+ if user_input is not None and not (
+ errors := await self.validate_input(user_input)
+ ):
+ if self.source == SOURCE_USER:
+ self._async_abort_entries_match({CONF_EMAIL: user_input[CONF_EMAIL]})
+ self.user_input = user_input
+ return await self.async_step_language()
+ await self.generate_country_schema()
+ suggested_values: dict = {}
+ if self.source == SOURCE_RECONFIGURE:
+ reconfigure_entry = self._get_reconfigure_entry()
+ suggested_values = {
+ **suggested_values,
+ **reconfigure_entry.data,
+ }
+ if user_input is not None:
+ suggested_values = {**suggested_values, **user_input}
+ return self.async_show_form(
+ step_id="user",
+ data_schema=self.add_suggested_values_to_schema(
+ data_schema=vol.Schema(
+ {**AUTH_DATA_SCHEMA, **self.COUNTRY_DATA_SCHEMA}
+ ),
+ suggested_values=suggested_values,
+ ),
+ description_placeholders={"cookidoo": "Cookidoo"},
+ errors=errors,
+ )
+
+ async def async_step_language(
+ self,
+ language_input: dict[str, Any] | None = None,
+ ) -> ConfigFlowResult:
+ """Async language step to set up the connection."""
+ errors: dict[str, str] = {}
+ if language_input is not None and not (
+ errors := await self.validate_input(self.user_input, language_input)
+ ):
+ if self.source == SOURCE_USER:
+ return self.async_create_entry(
+ title="Cookidoo", data={**self.user_input, **language_input}
+ )
+ reconfigure_entry = self._get_reconfigure_entry()
+ return self.async_update_reload_and_abort(
+ reconfigure_entry,
+ data={
+ **reconfigure_entry.data,
+ **self.user_input,
+ **language_input,
+ },
+ )
+
+ await self.generate_language_schema()
+ return self.async_show_form(
+ step_id="language",
+ data_schema=vol.Schema(self.LANGUAGE_DATA_SCHEMA),
+ description_placeholders={"cookidoo": "Cookidoo"},
+ 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:
+ """Dialog that informs the user that reauth is required."""
+ errors: dict[str, str] = {}
+
+ reauth_entry = self._get_reauth_entry()
+
+ if user_input is not None:
+ 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]}
+ )
+ return self.async_update_reload_and_abort(
+ reauth_entry, data_updates=user_input
+ )
+ return self.async_show_form(
+ step_id="reauth_confirm",
+ data_schema=self.add_suggested_values_to_schema(
+ data_schema=vol.Schema(AUTH_DATA_SCHEMA),
+ suggested_values={CONF_EMAIL: reauth_entry.data[CONF_EMAIL]},
+ ),
+ description_placeholders={"cookidoo": "Cookidoo"},
+ errors=errors,
+ )
+
+ async def generate_country_schema(self) -> None:
+ """Generate country schema."""
+ self.COUNTRY_DATA_SCHEMA = {
+ vol.Required(CONF_COUNTRY): CountrySelector(
+ CountrySelectorConfig(
+ countries=[
+ country.upper() for country in await get_country_options()
+ ],
+ )
+ )
+ }
+
+ async def generate_language_schema(self) -> None:
+ """Generate language schema."""
+ self.LANGUAGE_DATA_SCHEMA = {
+ vol.Required(CONF_LANGUAGE): LanguageSelector(
+ LanguageSelectorConfig(
+ languages=[
+ option.language
+ for option in await get_localization_options(
+ country=self.user_input[CONF_COUNTRY].lower()
+ )
+ ],
+ native_name=True,
+ ),
+ ),
+ }
+
+ async def validate_input(
+ self,
+ user_input: dict[str, Any],
+ language_input: dict[str, Any] | None = None,
+ ) -> dict[str, str]:
+ """Input Helper."""
+
+ errors: dict[str, str] = {}
+
+ data_input: dict[str, Any] = {}
+
+ if self.source == SOURCE_RECONFIGURE:
+ reconfigure_entry = self._get_reconfigure_entry()
+ data_input = {**data_input, **reconfigure_entry.data}
+ data_input = {**data_input, **user_input}
+ if language_input:
+ data_input = {**data_input, **language_input}
+ else:
+ data_input[CONF_LANGUAGE] = (
+ 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],
+ ),
+ )
+ try:
+ await cookidoo.login()
+ if language_input:
+ await cookidoo.get_additional_items()
+ except CookidooRequestException:
+ errors["base"] = "cannot_connect"
+ except CookidooAuthException:
+ errors["base"] = "invalid_auth"
+ except Exception:
+ _LOGGER.exception("Unexpected exception")
+ errors["base"] = "unknown"
+ return errors
diff --git a/homeassistant/components/cookidoo/const.py b/homeassistant/components/cookidoo/const.py
new file mode 100644
index 00000000000..37c584404a0
--- /dev/null
+++ b/homeassistant/components/cookidoo/const.py
@@ -0,0 +1,3 @@
+"""Constants for the Cookidoo integration."""
+
+DOMAIN = "cookidoo"
diff --git a/homeassistant/components/cookidoo/coordinator.py b/homeassistant/components/cookidoo/coordinator.py
new file mode 100644
index 00000000000..ad86d1fb9f1
--- /dev/null
+++ b/homeassistant/components/cookidoo/coordinator.py
@@ -0,0 +1,101 @@
+"""DataUpdateCoordinator for the Cookidoo integration."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from datetime import timedelta
+import logging
+
+from cookidoo_api import (
+ Cookidoo,
+ CookidooAdditionalItem,
+ CookidooAuthException,
+ CookidooException,
+ CookidooIngredientItem,
+ CookidooRequestException,
+)
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import CONF_EMAIL
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import ConfigEntryAuthFailed
+from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
+
+from .const import DOMAIN
+
+_LOGGER = logging.getLogger(__name__)
+
+type CookidooConfigEntry = ConfigEntry[CookidooDataUpdateCoordinator]
+
+
+@dataclass
+class CookidooData:
+ """Cookidoo data type."""
+
+ ingredient_items: list[CookidooIngredientItem]
+ additional_items: list[CookidooAdditionalItem]
+
+
+class CookidooDataUpdateCoordinator(DataUpdateCoordinator[CookidooData]):
+ """A Cookidoo Data Update Coordinator."""
+
+ config_entry: CookidooConfigEntry
+
+ def __init__(
+ self, hass: HomeAssistant, cookidoo: Cookidoo, entry: CookidooConfigEntry
+ ) -> None:
+ """Initialize the Cookidoo data coordinator."""
+ super().__init__(
+ hass,
+ _LOGGER,
+ name=DOMAIN,
+ update_interval=timedelta(seconds=90),
+ config_entry=entry,
+ )
+ self.cookidoo = cookidoo
+
+ async def _async_setup(self) -> None:
+ try:
+ await self.cookidoo.login()
+ except CookidooRequestException as e:
+ raise UpdateFailed(
+ translation_domain=DOMAIN,
+ translation_key="setup_request_exception",
+ ) from e
+ except CookidooAuthException as e:
+ raise ConfigEntryAuthFailed(
+ translation_domain=DOMAIN,
+ translation_key="setup_authentication_exception",
+ translation_placeholders={
+ CONF_EMAIL: self.config_entry.data[CONF_EMAIL]
+ },
+ ) from e
+
+ async def _async_update_data(self) -> CookidooData:
+ try:
+ ingredient_items = await self.cookidoo.get_ingredient_items()
+ additional_items = await self.cookidoo.get_additional_items()
+ except CookidooAuthException:
+ try:
+ await self.cookidoo.refresh_token()
+ except CookidooAuthException as exc:
+ raise ConfigEntryAuthFailed(
+ translation_domain=DOMAIN,
+ translation_key="setup_authentication_exception",
+ translation_placeholders={
+ CONF_EMAIL: self.config_entry.data[CONF_EMAIL]
+ },
+ ) from exc
+ _LOGGER.debug(
+ "Authentication failed but re-authentication was successful, trying again later"
+ )
+ return self.data
+ except CookidooException as e:
+ raise UpdateFailed(
+ translation_domain=DOMAIN,
+ translation_key="update_exception",
+ ) from e
+
+ return CookidooData(
+ ingredient_items=ingredient_items, additional_items=additional_items
+ )
diff --git a/homeassistant/components/cookidoo/entity.py b/homeassistant/components/cookidoo/entity.py
new file mode 100644
index 00000000000..5c8f3ec8441
--- /dev/null
+++ b/homeassistant/components/cookidoo/entity.py
@@ -0,0 +1,30 @@
+"""Base entity for the Cookidoo integration."""
+
+from __future__ import annotations
+
+from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
+from homeassistant.helpers.update_coordinator import CoordinatorEntity
+
+from .const import DOMAIN
+from .coordinator import CookidooDataUpdateCoordinator
+
+
+class CookidooBaseEntity(CoordinatorEntity[CookidooDataUpdateCoordinator]):
+ """Cookidoo base entity."""
+
+ _attr_has_entity_name = True
+
+ def __init__(
+ self,
+ coordinator: CookidooDataUpdateCoordinator,
+ ) -> None:
+ """Initialize the entity."""
+ super().__init__(coordinator)
+
+ self.device_info = DeviceInfo(
+ entry_type=DeviceEntryType.SERVICE,
+ name="Cookidoo",
+ identifiers={(DOMAIN, coordinator.config_entry.entry_id)},
+ manufacturer="Vorwerk International & Co. KmG",
+ model="Cookidoo - Thermomix® recipe portal",
+ )
diff --git a/homeassistant/components/cookidoo/icons.json b/homeassistant/components/cookidoo/icons.json
new file mode 100644
index 00000000000..0e411a70fc2
--- /dev/null
+++ b/homeassistant/components/cookidoo/icons.json
@@ -0,0 +1,17 @@
+{
+ "entity": {
+ "button": {
+ "todo_clear": {
+ "default": "mdi:cart-off"
+ }
+ },
+ "todo": {
+ "ingredient_list": {
+ "default": "mdi:cart-plus"
+ },
+ "additional_item_list": {
+ "default": "mdi:cart-plus"
+ }
+ }
+ }
+}
diff --git a/homeassistant/components/cookidoo/manifest.json b/homeassistant/components/cookidoo/manifest.json
new file mode 100644
index 00000000000..5264e47a709
--- /dev/null
+++ b/homeassistant/components/cookidoo/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "cookidoo",
+ "name": "Cookidoo",
+ "codeowners": ["@miaucl"],
+ "config_flow": true,
+ "documentation": "https://www.home-assistant.io/integrations/cookidoo",
+ "integration_type": "service",
+ "iot_class": "cloud_polling",
+ "loggers": ["cookidoo_api"],
+ "quality_scale": "silver",
+ "requirements": ["cookidoo-api==0.12.2"]
+}
diff --git a/homeassistant/components/cookidoo/quality_scale.yaml b/homeassistant/components/cookidoo/quality_scale.yaml
new file mode 100644
index 00000000000..95a35829079
--- /dev/null
+++ b/homeassistant/components/cookidoo/quality_scale.yaml
@@ -0,0 +1,90 @@
+rules:
+ # Bronze
+ action-setup:
+ status: exempt
+ comment: No service actions implemented
+ appropriate-polling: done
+ brands: done
+ common-modules: done
+ config-flow-test-coverage: done
+ config-flow: done
+ dependency-transparency: done
+ docs-actions:
+ status: exempt
+ comment: No service actions implemented
+ docs-high-level-description: done
+ docs-installation-instructions: done
+ docs-removal-instructions:
+ status: exempt
+ comment: No special external action required
+ entity-event-setup:
+ status: exempt
+ comment: No callbacks are implemented
+ 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
+ log-when-unavailable:
+ status: done
+ comment: Offloaded to coordinator
+ entity-unavailable:
+ status: done
+ comment: Offloaded to coordinator
+ action-exceptions:
+ status: done
+ comment: Only providing todo actions
+ reauthentication-flow: done
+ parallel-updates: done
+ test-coverage: done
+ integration-owner: done
+ docs-installation-parameters: done
+ docs-configuration-parameters:
+ status: exempt
+ comment: No options flow
+
+ # Gold
+ entity-translations: done
+ entity-device-class:
+ status: exempt
+ comment: currently no platform with device classes
+ devices: done
+ entity-category: done
+ entity-disabled-by-default:
+ status: exempt
+ comment: No disabled entities implemented
+ discovery:
+ status: exempt
+ comment: Nothing to discover
+ stale-devices:
+ status: exempt
+ comment: No stale entities possible
+ diagnostics: todo
+ exception-translations: done
+ icon-translations: done
+ reconfiguration-flow: done
+ dynamic-devices:
+ status: exempt
+ comment: No dynamic entities available
+ discovery-update-info:
+ status: exempt
+ comment: No discoverable entities implemented
+ repair-issues:
+ status: exempt
+ comment: No issues/repairs
+ docs-use-cases: todo
+ docs-supported-devices: todo
+ docs-supported-functions: todo
+ docs-data-update: done
+ docs-known-limitations: done
+ docs-troubleshooting: todo
+ docs-examples: todo
+
+ # Platinum
+ async-dependency: done
+ inject-websession: done
+ strict-typing: done
diff --git a/homeassistant/components/cookidoo/strings.json b/homeassistant/components/cookidoo/strings.json
new file mode 100644
index 00000000000..83cc182be16
--- /dev/null
+++ b/homeassistant/components/cookidoo/strings.json
@@ -0,0 +1,88 @@
+{
+ "config": {
+ "step": {
+ "user": {
+ "title": "Setup {cookidoo}",
+ "data": {
+ "email": "[%key:common::config_flow::data::email%]",
+ "password": "[%key:common::config_flow::data::password%]",
+ "country": "Country"
+ },
+ "data_description": {
+ "email": "Email used to access your {cookidoo} account.",
+ "password": "Password used to access your {cookidoo} account.",
+ "country": "Pick your country for the {cookidoo} content."
+ }
+ },
+ "language": {
+ "title": "Setup {cookidoo}",
+ "data": {
+ "language": "[%key:common::config_flow::data::language%]"
+ },
+ "data_description": {
+ "language": "Pick your language for the {cookidoo} content."
+ }
+ },
+ "reauth_confirm": {
+ "title": "Login again to {cookidoo}",
+ "description": "Please log in to {cookidoo} again to continue using this integration.",
+ "data": {
+ "email": "[%key:common::config_flow::data::email%]",
+ "password": "[%key:common::config_flow::data::password%]"
+ },
+ "data_description": {
+ "email": "[%key:component::cookidoo::config::step::user::data_description::email%]",
+ "password": "[%key:component::cookidoo::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_device%]",
+ "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
+ "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
+ }
+ },
+ "entity": {
+ "button": {
+ "todo_clear": {
+ "name": "Clear shopping list and additional purchases"
+ }
+ },
+ "todo": {
+ "ingredient_list": {
+ "name": "Shopping list"
+ },
+ "additional_item_list": {
+ "name": "Additional purchases"
+ }
+ }
+ },
+ "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"
+ },
+ "todo_update_item_failed": {
+ "message": "Failed to update {name} in Cookidoo shopping list"
+ },
+ "todo_delete_item_failed": {
+ "message": "Failed to delete {count} item(s) from Cookidoo shopping list"
+ },
+ "setup_request_exception": {
+ "message": "Failed to connect to server, try again later"
+ },
+ "setup_authentication_exception": {
+ "message": "Authentication failed for {email}, check your email and password"
+ },
+ "update_exception": {
+ "message": "Unable to connect and retrieve data from cookidoo"
+ }
+ }
+}
diff --git a/homeassistant/components/cookidoo/todo.py b/homeassistant/components/cookidoo/todo.py
new file mode 100644
index 00000000000..4a70dadc65a
--- /dev/null
+++ b/homeassistant/components/cookidoo/todo.py
@@ -0,0 +1,185 @@
+"""Todo platform for the Cookidoo integration."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from cookidoo_api import (
+ CookidooAdditionalItem,
+ CookidooException,
+ CookidooIngredientItem,
+)
+
+from homeassistant.components.todo import (
+ TodoItem,
+ TodoItemStatus,
+ TodoListEntity,
+ TodoListEntityFeature,
+)
+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
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ config_entry: CookidooConfigEntry,
+ async_add_entities: AddEntitiesCallback,
+) -> None:
+ """Set up the todo list from a config entry created in the integrations UI."""
+ coordinator = config_entry.runtime_data
+
+ async_add_entities(
+ [
+ CookidooIngredientsTodoListEntity(coordinator),
+ CookidooAdditionalItemTodoListEntity(coordinator),
+ ]
+ )
+
+
+class CookidooIngredientsTodoListEntity(CookidooBaseEntity, TodoListEntity):
+ """A To-do List representation of the ingredients in the Cookidoo Shopping List."""
+
+ _attr_translation_key = "ingredient_list"
+ _attr_supported_features = TodoListEntityFeature.UPDATE_TODO_ITEM
+
+ def __init__(self, coordinator: CookidooDataUpdateCoordinator) -> None:
+ """Initialize the entity."""
+ super().__init__(coordinator)
+ self._attr_unique_id = f"{coordinator.config_entry.entry_id}_ingredients"
+
+ @property
+ def todo_items(self) -> list[TodoItem]:
+ """Return the todo ingredients."""
+ return [
+ TodoItem(
+ uid=item.id,
+ summary=item.name,
+ description=item.description or "",
+ status=(
+ TodoItemStatus.COMPLETED
+ if item.is_owned
+ else TodoItemStatus.NEEDS_ACTION
+ ),
+ )
+ for item in self.coordinator.data.ingredient_items
+ ]
+
+ async def async_update_todo_item(self, item: TodoItem) -> None:
+ """Update an ingredient to the To-do list.
+
+ Cookidoo ingredients can be changed in state, but not in summary or description. This is currently not possible to distinguish in home assistant and just fails silently.
+ """
+ try:
+ if TYPE_CHECKING:
+ assert item.uid
+ await self.coordinator.cookidoo.edit_ingredient_items_ownership(
+ [
+ CookidooIngredientItem(
+ id=item.uid,
+ name="",
+ description="",
+ is_owned=item.status == TodoItemStatus.COMPLETED,
+ )
+ ]
+ )
+ except CookidooException as e:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="todo_update_item_failed",
+ translation_placeholders={"name": item.summary or ""},
+ ) from e
+
+ await self.coordinator.async_refresh()
+
+
+class CookidooAdditionalItemTodoListEntity(CookidooBaseEntity, TodoListEntity):
+ """A To-do List representation of the additional items in the Cookidoo Shopping List."""
+
+ _attr_translation_key = "additional_item_list"
+ _attr_supported_features = (
+ TodoListEntityFeature.CREATE_TODO_ITEM
+ | TodoListEntityFeature.UPDATE_TODO_ITEM
+ | TodoListEntityFeature.DELETE_TODO_ITEM
+ )
+
+ def __init__(self, coordinator: CookidooDataUpdateCoordinator) -> None:
+ """Initialize the entity."""
+ super().__init__(coordinator)
+ self._attr_unique_id = f"{coordinator.config_entry.entry_id}_additional_items"
+
+ @property
+ def todo_items(self) -> list[TodoItem]:
+ """Return the todo items."""
+
+ return [
+ TodoItem(
+ uid=item.id,
+ summary=item.name,
+ status=(
+ TodoItemStatus.COMPLETED
+ if item.is_owned
+ else TodoItemStatus.NEEDS_ACTION
+ ),
+ )
+ for item in self.coordinator.data.additional_items
+ ]
+
+ async def async_create_todo_item(self, item: TodoItem) -> None:
+ """Add an item to the To-do list."""
+
+ try:
+ if TYPE_CHECKING:
+ assert item.summary
+ await self.coordinator.cookidoo.add_additional_items([item.summary])
+ except CookidooException as e:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="todo_save_item_failed",
+ translation_placeholders={"name": item.summary or ""},
+ ) from e
+
+ await self.coordinator.async_refresh()
+
+ async def async_update_todo_item(self, item: TodoItem) -> None:
+ """Update an item to the To-do list."""
+
+ try:
+ if TYPE_CHECKING:
+ assert item.uid
+ assert item.summary
+ new_item = CookidooAdditionalItem(
+ id=item.uid,
+ name=item.summary,
+ is_owned=item.status == TodoItemStatus.COMPLETED,
+ )
+ await self.coordinator.cookidoo.edit_additional_items_ownership([new_item])
+ await self.coordinator.cookidoo.edit_additional_items([new_item])
+ except CookidooException as e:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="todo_update_item_failed",
+ translation_placeholders={"name": item.summary or ""},
+ ) from e
+
+ await self.coordinator.async_refresh()
+
+ async def async_delete_todo_items(self, uids: list[str]) -> None:
+ """Delete an item from the To-do list."""
+
+ try:
+ await self.coordinator.cookidoo.remove_additional_items(uids)
+ except CookidooException as e:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="todo_delete_item_failed",
+ translation_placeholders={"count": str(len(uids))},
+ ) from e
+
+ await self.coordinator.async_refresh()
diff --git a/homeassistant/components/coolmaster/climate.py b/homeassistant/components/coolmaster/climate.py
index d3cb7122109..29be416d57e 100644
--- a/homeassistant/components/coolmaster/climate.py
+++ b/homeassistant/components/coolmaster/climate.py
@@ -55,7 +55,6 @@ class CoolmasterClimate(CoolmasterEntity, ClimateEntity):
"""Representation of a coolmaster climate device."""
_attr_name = None
- _enable_turn_on_off_backwards_compatibility = False
def __init__(self, coordinator, unit_id, info, supported_modes):
"""Initialize the climate device."""
diff --git a/homeassistant/components/counter/strings.json b/homeassistant/components/counter/strings.json
index fb1f6467f4a..2c52fb43b9f 100644
--- a/homeassistant/components/counter/strings.json
+++ b/homeassistant/components/counter/strings.json
@@ -29,19 +29,19 @@
"services": {
"decrement": {
"name": "Decrement",
- "description": "Decrements a counter."
+ "description": "Decrements a counter by its step size."
},
"increment": {
"name": "Increment",
- "description": "Increments a counter."
+ "description": "Increments a counter by its step size."
},
"reset": {
"name": "Reset",
- "description": "Resets a counter."
+ "description": "Resets a counter to its initial value."
},
"set_value": {
"name": "Set",
- "description": "Sets the counter value.",
+ "description": "Sets the counter to a specific value.",
"fields": {
"value": {
"name": "Value",
diff --git a/homeassistant/components/cover/__init__.py b/homeassistant/components/cover/__init__.py
index ea11761a753..001bff51991 100644
--- a/homeassistant/components/cover/__init__.py
+++ b/homeassistant/components/cover/__init__.py
@@ -89,36 +89,8 @@ class CoverDeviceClass(StrEnum):
DEVICE_CLASSES_SCHEMA = vol.All(vol.Lower, vol.Coerce(CoverDeviceClass))
-
-# DEVICE_CLASS* below are deprecated as of 2021.12
-# use the CoverDeviceClass enum instead.
DEVICE_CLASSES = [cls.value for cls in CoverDeviceClass]
-_DEPRECATED_DEVICE_CLASS_AWNING = DeprecatedConstantEnum(
- CoverDeviceClass.AWNING, "2025.1"
-)
-_DEPRECATED_DEVICE_CLASS_BLIND = DeprecatedConstantEnum(
- CoverDeviceClass.BLIND, "2025.1"
-)
-_DEPRECATED_DEVICE_CLASS_CURTAIN = DeprecatedConstantEnum(
- CoverDeviceClass.CURTAIN, "2025.1"
-)
-_DEPRECATED_DEVICE_CLASS_DAMPER = DeprecatedConstantEnum(
- CoverDeviceClass.DAMPER, "2025.1"
-)
-_DEPRECATED_DEVICE_CLASS_DOOR = DeprecatedConstantEnum(CoverDeviceClass.DOOR, "2025.1")
-_DEPRECATED_DEVICE_CLASS_GARAGE = DeprecatedConstantEnum(
- CoverDeviceClass.GARAGE, "2025.1"
-)
-_DEPRECATED_DEVICE_CLASS_GATE = DeprecatedConstantEnum(CoverDeviceClass.GATE, "2025.1")
-_DEPRECATED_DEVICE_CLASS_SHADE = DeprecatedConstantEnum(
- CoverDeviceClass.SHADE, "2025.1"
-)
-_DEPRECATED_DEVICE_CLASS_SHUTTER = DeprecatedConstantEnum(
- CoverDeviceClass.SHUTTER, "2025.1"
-)
-_DEPRECATED_DEVICE_CLASS_WINDOW = DeprecatedConstantEnum(
- CoverDeviceClass.WINDOW, "2025.1"
-)
+
# mypy: disallow-any-generics
@@ -136,27 +108,6 @@ class CoverEntityFeature(IntFlag):
SET_TILT_POSITION = 128
-# These SUPPORT_* constants are deprecated as of Home Assistant 2022.5.
-# Please use the CoverEntityFeature enum instead.
-_DEPRECATED_SUPPORT_OPEN = DeprecatedConstantEnum(CoverEntityFeature.OPEN, "2025.1")
-_DEPRECATED_SUPPORT_CLOSE = DeprecatedConstantEnum(CoverEntityFeature.CLOSE, "2025.1")
-_DEPRECATED_SUPPORT_SET_POSITION = DeprecatedConstantEnum(
- CoverEntityFeature.SET_POSITION, "2025.1"
-)
-_DEPRECATED_SUPPORT_STOP = DeprecatedConstantEnum(CoverEntityFeature.STOP, "2025.1")
-_DEPRECATED_SUPPORT_OPEN_TILT = DeprecatedConstantEnum(
- CoverEntityFeature.OPEN_TILT, "2025.1"
-)
-_DEPRECATED_SUPPORT_CLOSE_TILT = DeprecatedConstantEnum(
- CoverEntityFeature.CLOSE_TILT, "2025.1"
-)
-_DEPRECATED_SUPPORT_STOP_TILT = DeprecatedConstantEnum(
- CoverEntityFeature.STOP_TILT, "2025.1"
-)
-_DEPRECATED_SUPPORT_SET_TILT_POSITION = DeprecatedConstantEnum(
- CoverEntityFeature.SET_TILT_POSITION, "2025.1"
-)
-
ATTR_CURRENT_POSITION = "current_position"
ATTR_CURRENT_TILT_POSITION = "current_tilt_position"
ATTR_POSITION = "position"
diff --git a/homeassistant/components/cppm_tracker/manifest.json b/homeassistant/components/cppm_tracker/manifest.json
index d8c387cdbf4..ca2fdf71a45 100644
--- a/homeassistant/components/cppm_tracker/manifest.json
+++ b/homeassistant/components/cppm_tracker/manifest.json
@@ -4,5 +4,6 @@
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/cppm_tracker",
"iot_class": "local_polling",
+ "quality_scale": "legacy",
"requirements": ["clearpasspy==1.0.2"]
}
diff --git a/homeassistant/components/cpuspeed/config_flow.py b/homeassistant/components/cpuspeed/config_flow.py
index ac35cc0fc4f..21dc577b5bf 100644
--- a/homeassistant/components/cpuspeed/config_flow.py
+++ b/homeassistant/components/cpuspeed/config_flow.py
@@ -23,7 +23,6 @@ class CPUSpeedFlowHandler(ConfigFlow, domain=DOMAIN):
) -> ConfigFlowResult:
"""Handle a flow initialized by the user."""
await self.async_set_unique_id(DOMAIN)
- self._abort_if_unique_id_configured()
if user_input is None:
return self.async_show_form(step_id="user")
diff --git a/homeassistant/components/cpuspeed/manifest.json b/homeassistant/components/cpuspeed/manifest.json
index ff3a41d9c09..0c7f549a1b9 100644
--- a/homeassistant/components/cpuspeed/manifest.json
+++ b/homeassistant/components/cpuspeed/manifest.json
@@ -6,5 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/cpuspeed",
"integration_type": "device",
"iot_class": "local_push",
- "requirements": ["py-cpuinfo==9.0.0"]
+ "requirements": ["py-cpuinfo==9.0.0"],
+ "single_config_entry": true
}
diff --git a/homeassistant/components/cpuspeed/strings.json b/homeassistant/components/cpuspeed/strings.json
index e82c6a0db12..6f4b3133b1b 100644
--- a/homeassistant/components/cpuspeed/strings.json
+++ b/homeassistant/components/cpuspeed/strings.json
@@ -8,7 +8,6 @@
}
},
"abort": {
- "already_configured": "[%key:common::config_flow::abort::single_instance_allowed%]",
"not_compatible": "Unable to get CPU information, this integration is not compatible with your system"
}
}
diff --git a/homeassistant/components/crownstone/config_flow.py b/homeassistant/components/crownstone/config_flow.py
index bf6e9204714..2a96098421a 100644
--- a/homeassistant/components/crownstone/config_flow.py
+++ b/homeassistant/components/crownstone/config_flow.py
@@ -49,7 +49,7 @@ class BaseCrownstoneFlowHandler(ConfigEntryBaseFlow):
cloud: CrownstoneCloud
def __init__(
- self, flow_type: str, create_entry_cb: Callable[..., ConfigFlowResult]
+ self, flow_type: str, create_entry_cb: Callable[[], ConfigFlowResult]
) -> None:
"""Set up flow instance."""
self.flow_type = flow_type
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/cups/manifest.json b/homeassistant/components/cups/manifest.json
index 3e5b46770fb..c8f19236ce7 100644
--- a/homeassistant/components/cups/manifest.json
+++ b/homeassistant/components/cups/manifest.json
@@ -4,5 +4,6 @@
"codeowners": ["@fabaff"],
"documentation": "https://www.home-assistant.io/integrations/cups",
"iot_class": "local_polling",
- "requirements": ["pycups==1.9.73"]
+ "quality_scale": "legacy",
+ "requirements": ["pycups==2.0.4"]
}
diff --git a/homeassistant/components/currencylayer/manifest.json b/homeassistant/components/currencylayer/manifest.json
index d66331c4ab0..82d9d4050d4 100644
--- a/homeassistant/components/currencylayer/manifest.json
+++ b/homeassistant/components/currencylayer/manifest.json
@@ -3,5 +3,6 @@
"name": "currencylayer",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/currencylayer",
- "iot_class": "cloud_polling"
+ "iot_class": "cloud_polling",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/daikin/climate.py b/homeassistant/components/daikin/climate.py
index 39e92ab1921..751683656f2 100644
--- a/homeassistant/components/daikin/climate.py
+++ b/homeassistant/components/daikin/climate.py
@@ -104,7 +104,6 @@ class DaikinClimate(DaikinEntity, ClimateEntity):
_attr_target_temperature_step = 1
_attr_fan_modes: list[str]
_attr_swing_modes: list[str]
- _enable_turn_on_off_backwards_compatibility = False
def __init__(self, coordinator: DaikinCoordinator) -> None:
"""Initialize the climate device."""
diff --git a/homeassistant/components/daikin/manifest.json b/homeassistant/components/daikin/manifest.json
index f6e9cb78efb..f794d97a9ba 100644
--- a/homeassistant/components/daikin/manifest.json
+++ b/homeassistant/components/daikin/manifest.json
@@ -6,6 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/daikin",
"iot_class": "local_polling",
"loggers": ["pydaikin"],
- "requirements": ["pydaikin==2.13.7"],
+ "requirements": ["pydaikin==2.13.8"],
"zeroconf": ["_dkapi._tcp.local."]
}
diff --git a/homeassistant/components/danfoss_air/manifest.json b/homeassistant/components/danfoss_air/manifest.json
index 9eea3221bbe..57cb1aa7218 100644
--- a/homeassistant/components/danfoss_air/manifest.json
+++ b/homeassistant/components/danfoss_air/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/danfoss_air",
"iot_class": "local_polling",
"loggers": ["pydanfossair"],
+ "quality_scale": "legacy",
"requirements": ["pydanfossair==0.1.0"]
}
diff --git a/homeassistant/components/datadog/manifest.json b/homeassistant/components/datadog/manifest.json
index 4ae24a80c6c..ca9681effca 100644
--- a/homeassistant/components/datadog/manifest.json
+++ b/homeassistant/components/datadog/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/datadog",
"iot_class": "local_push",
"loggers": ["datadog"],
+ "quality_scale": "legacy",
"requirements": ["datadog==0.15.0"]
}
diff --git a/homeassistant/components/ddwrt/manifest.json b/homeassistant/components/ddwrt/manifest.json
index 98ea17b0659..9a2b2470131 100644
--- a/homeassistant/components/ddwrt/manifest.json
+++ b/homeassistant/components/ddwrt/manifest.json
@@ -3,5 +3,6 @@
"name": "DD-WRT",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/ddwrt",
- "iot_class": "local_polling"
+ "iot_class": "local_polling",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/deako/__init__.py b/homeassistant/components/deako/__init__.py
index fdcf09fad60..7a169defe01 100644
--- a/homeassistant/components/deako/__init__.py
+++ b/homeassistant/components/deako/__init__.py
@@ -4,8 +4,7 @@ from __future__ import annotations
import logging
-from pydeako.deako import Deako, DeviceListTimeout, FindDevicesTimeout
-from pydeako.discover import DeakoDiscoverer
+from pydeako import Deako, DeakoDiscoverer, FindDevicesError
from homeassistant.components import zeroconf
from homeassistant.config_entries import ConfigEntry
@@ -30,12 +29,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: DeakoConfigEntry) -> boo
await connection.connect()
try:
await connection.find_devices()
- except DeviceListTimeout as exc: # device list never received
- _LOGGER.warning("Device not responding to device list")
- await connection.disconnect()
- raise ConfigEntryNotReady(exc) from exc
- except FindDevicesTimeout as exc: # total devices expected not received
- _LOGGER.warning("Device not responding to device requests")
+ except FindDevicesError as exc:
+ _LOGGER.warning("Error finding devices: %s", exc)
await connection.disconnect()
raise ConfigEntryNotReady(exc) from exc
diff --git a/homeassistant/components/deako/config_flow.py b/homeassistant/components/deako/config_flow.py
index d0676fa81d9..273cbf2795e 100644
--- a/homeassistant/components/deako/config_flow.py
+++ b/homeassistant/components/deako/config_flow.py
@@ -1,6 +1,6 @@
"""Config flow for deako."""
-from pydeako.discover import DeakoDiscoverer, DevicesNotFoundException
+from pydeako import DeakoDiscoverer, DevicesNotFoundException
from homeassistant.components import zeroconf
from homeassistant.core import HomeAssistant
diff --git a/homeassistant/components/deako/light.py b/homeassistant/components/deako/light.py
index c7ff8765402..75b01935c9a 100644
--- a/homeassistant/components/deako/light.py
+++ b/homeassistant/components/deako/light.py
@@ -2,7 +2,7 @@
from typing import Any
-from pydeako.deako import Deako
+from pydeako import Deako
from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity
from homeassistant.core import HomeAssistant
diff --git a/homeassistant/components/deako/manifest.json b/homeassistant/components/deako/manifest.json
index e3099439b9d..f4f4782530b 100644
--- a/homeassistant/components/deako/manifest.json
+++ b/homeassistant/components/deako/manifest.json
@@ -7,7 +7,7 @@
"documentation": "https://www.home-assistant.io/integrations/deako",
"iot_class": "local_polling",
"loggers": ["pydeako"],
- "requirements": ["pydeako==0.5.4"],
+ "requirements": ["pydeako==0.6.0"],
"single_config_entry": true,
"zeroconf": ["_deako._tcp.local."]
}
diff --git a/homeassistant/components/debugpy/manifest.json b/homeassistant/components/debugpy/manifest.json
index 1e31e002a81..078af8c67a5 100644
--- a/homeassistant/components/debugpy/manifest.json
+++ b/homeassistant/components/debugpy/manifest.json
@@ -6,5 +6,5 @@
"integration_type": "service",
"iot_class": "local_push",
"quality_scale": "internal",
- "requirements": ["debugpy==1.8.6"]
+ "requirements": ["debugpy==1.8.11"]
}
diff --git a/homeassistant/components/deconz/climate.py b/homeassistant/components/deconz/climate.py
index 1e228dc6c48..690f943379d 100644
--- a/homeassistant/components/deconz/climate.py
+++ b/homeassistant/components/deconz/climate.py
@@ -101,7 +101,6 @@ class DeconzThermostat(DeconzDevice[Thermostat], ClimateEntity):
TYPE = CLIMATE_DOMAIN
_attr_temperature_unit = UnitOfTemperature.CELSIUS
- _enable_turn_on_off_backwards_compatibility = False
def __init__(self, device: Thermostat, hub: DeconzHub) -> None:
"""Set up thermostat device."""
diff --git a/homeassistant/components/deconz/fan.py b/homeassistant/components/deconz/fan.py
index 48f29cf9b72..26e4d3328b8 100644
--- a/homeassistant/components/deconz/fan.py
+++ b/homeassistant/components/deconz/fan.py
@@ -65,7 +65,6 @@ class DeconzFan(DeconzDevice[Light], FanEntity):
| FanEntityFeature.TURN_ON
| FanEntityFeature.TURN_OFF
)
- _enable_turn_on_off_backwards_compatibility = False
def __init__(self, device: Light, hub: DeconzHub) -> None:
"""Set up fan."""
diff --git a/homeassistant/components/deconz/light.py b/homeassistant/components/deconz/light.py
index 95a97959d5b..d82c05f14eb 100644
--- a/homeassistant/components/deconz/light.py
+++ b/homeassistant/components/deconz/light.py
@@ -12,12 +12,14 @@ from pydeconz.models.light.light import Light, LightAlert, LightColorMode, Light
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
- ATTR_COLOR_TEMP,
+ ATTR_COLOR_TEMP_KELVIN,
ATTR_EFFECT,
ATTR_FLASH,
ATTR_HS_COLOR,
ATTR_TRANSITION,
ATTR_XY_COLOR,
+ DEFAULT_MAX_KELVIN,
+ DEFAULT_MIN_KELVIN,
DOMAIN as LIGHT_DOMAIN,
EFFECT_COLORLOOP,
FLASH_LONG,
@@ -30,7 +32,11 @@ 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
-from homeassistant.util.color import color_hs_to_xy
+from homeassistant.util.color import (
+ color_hs_to_xy,
+ color_temperature_kelvin_to_mired,
+ color_temperature_mired_to_kelvin,
+)
from .const import DOMAIN as DECONZ_DOMAIN, POWER_PLUGS
from .entity import DeconzDevice
@@ -187,6 +193,8 @@ class DeconzBaseLight[_LightDeviceT: Group | Light](
TYPE = LIGHT_DOMAIN
_attr_color_mode = ColorMode.UNKNOWN
+ _attr_min_color_temp_kelvin = DEFAULT_MIN_KELVIN
+ _attr_max_color_temp_kelvin = DEFAULT_MAX_KELVIN
def __init__(self, device: _LightDeviceT, hub: DeconzHub) -> None:
"""Set up light."""
@@ -256,9 +264,11 @@ class DeconzBaseLight[_LightDeviceT: Group | Light](
return self._device.brightness
@property
- def color_temp(self) -> int | None:
+ def color_temp_kelvin(self) -> int | None:
"""Return the CT color value."""
- return self._device.color_temp
+ if self._device.color_temp is None or self._device.color_temp == 0:
+ return None
+ return color_temperature_mired_to_kelvin(self._device.color_temp)
@property
def hs_color(self) -> tuple[float, float] | None:
@@ -284,8 +294,10 @@ class DeconzBaseLight[_LightDeviceT: Group | Light](
if ATTR_BRIGHTNESS in kwargs:
data["brightness"] = kwargs[ATTR_BRIGHTNESS]
- if ATTR_COLOR_TEMP in kwargs:
- data["color_temperature"] = kwargs[ATTR_COLOR_TEMP]
+ if ATTR_COLOR_TEMP_KELVIN in kwargs:
+ data["color_temperature"] = color_temperature_kelvin_to_mired(
+ kwargs[ATTR_COLOR_TEMP_KELVIN]
+ )
if ATTR_HS_COLOR in kwargs:
if ColorMode.XY in self._attr_supported_color_modes:
@@ -338,14 +350,18 @@ class DeconzLight(DeconzBaseLight[Light]):
"""Representation of a deCONZ light."""
@property
- def max_mireds(self) -> int:
- """Return the warmest color_temp that this light supports."""
- return self._device.max_color_temp or super().max_mireds
+ def min_color_temp_kelvin(self) -> int:
+ """Return the warmest color_temp_kelvin that this light supports."""
+ if max_color_temp_mireds := self._device.max_color_temp:
+ return color_temperature_mired_to_kelvin(max_color_temp_mireds)
+ return super().min_color_temp_kelvin
@property
- def min_mireds(self) -> int:
- """Return the coldest color_temp that this light supports."""
- return self._device.min_color_temp or super().min_mireds
+ def max_color_temp_kelvin(self) -> int:
+ """Return the coldest color_temp_kelvin that this light supports."""
+ if min_color_temp_mireds := self._device.min_color_temp:
+ return color_temperature_mired_to_kelvin(min_color_temp_mireds)
+ return super().max_color_temp_kelvin
@callback
def async_update_callback(self) -> None:
diff --git a/homeassistant/components/deconz/manifest.json b/homeassistant/components/deconz/manifest.json
index 04aaa6bc324..93ae8e392c8 100644
--- a/homeassistant/components/deconz/manifest.json
+++ b/homeassistant/components/deconz/manifest.json
@@ -7,7 +7,6 @@
"integration_type": "hub",
"iot_class": "local_push",
"loggers": ["pydeconz"],
- "quality_scale": "platinum",
"requirements": ["pydeconz==118"],
"ssdp": [
{
diff --git a/homeassistant/components/decora/manifest.json b/homeassistant/components/decora/manifest.json
index bef42f8b4ab..64dc01d09a1 100644
--- a/homeassistant/components/decora/manifest.json
+++ b/homeassistant/components/decora/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/decora",
"iot_class": "local_polling",
"loggers": ["bluepy", "decora"],
+ "quality_scale": "legacy",
"requirements": ["bluepy==1.3.0", "decora==0.6"]
}
diff --git a/homeassistant/components/decora_wifi/manifest.json b/homeassistant/components/decora_wifi/manifest.json
index 0bead527e78..25892dc3e64 100644
--- a/homeassistant/components/decora_wifi/manifest.json
+++ b/homeassistant/components/decora_wifi/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/decora_wifi",
"iot_class": "cloud_polling",
"loggers": ["decora_wifi"],
+ "quality_scale": "legacy",
"requirements": ["decora-wifi==1.4"]
}
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/manifest.json b/homeassistant/components/delijn/manifest.json
index d25dab4234e..b87242d6e94 100644
--- a/homeassistant/components/delijn/manifest.json
+++ b/homeassistant/components/delijn/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/delijn",
"iot_class": "cloud_polling",
"loggers": ["pydelijn"],
+ "quality_scale": "legacy",
"requirements": ["pydelijn==1.1.0"]
}
diff --git a/homeassistant/components/demo/climate.py b/homeassistant/components/demo/climate.py
index ff0ed5746ca..d5b763caa5a 100644
--- a/homeassistant/components/demo/climate.py
+++ b/homeassistant/components/demo/climate.py
@@ -43,6 +43,7 @@ async def async_setup_entry(
target_humidity=None,
current_humidity=None,
swing_mode=None,
+ swing_horizontal_mode=None,
hvac_mode=HVACMode.HEAT,
hvac_action=HVACAction.HEATING,
target_temp_high=None,
@@ -60,6 +61,7 @@ async def async_setup_entry(
target_humidity=67.4,
current_humidity=54.2,
swing_mode="off",
+ swing_horizontal_mode="auto",
hvac_mode=HVACMode.COOL,
hvac_action=HVACAction.COOLING,
target_temp_high=None,
@@ -78,6 +80,7 @@ async def async_setup_entry(
target_humidity=None,
current_humidity=None,
swing_mode="auto",
+ swing_horizontal_mode=None,
hvac_mode=HVACMode.HEAT_COOL,
hvac_action=None,
target_temp_high=24,
@@ -95,7 +98,6 @@ class DemoClimate(ClimateEntity):
_attr_name = None
_attr_should_poll = False
_attr_translation_key = "ubercool"
- _enable_turn_on_off_backwards_compatibility = False
def __init__(
self,
@@ -109,6 +111,7 @@ class DemoClimate(ClimateEntity):
target_humidity: float | None,
current_humidity: float | None,
swing_mode: str | None,
+ swing_horizontal_mode: str | None,
hvac_mode: HVACMode,
hvac_action: HVACAction | None,
target_temp_high: float | None,
@@ -129,6 +132,8 @@ class DemoClimate(ClimateEntity):
self._attr_supported_features |= ClimateEntityFeature.TARGET_HUMIDITY
if swing_mode is not None:
self._attr_supported_features |= ClimateEntityFeature.SWING_MODE
+ if swing_horizontal_mode is not None:
+ self._attr_supported_features |= ClimateEntityFeature.SWING_HORIZONTAL_MODE
if HVACMode.HEAT_COOL in hvac_modes or HVACMode.AUTO in hvac_modes:
self._attr_supported_features |= (
ClimateEntityFeature.TARGET_TEMPERATURE_RANGE
@@ -147,9 +152,11 @@ class DemoClimate(ClimateEntity):
self._hvac_action = hvac_action
self._hvac_mode = hvac_mode
self._current_swing_mode = swing_mode
+ self._current_swing_horizontal_mode = swing_horizontal_mode
self._fan_modes = ["on_low", "on_high", "auto_low", "auto_high", "off"]
self._hvac_modes = hvac_modes
self._swing_modes = ["auto", "1", "2", "3", "off"]
+ self._swing_horizontal_modes = ["auto", "rangefull", "off"]
self._target_temperature_high = target_temp_high
self._target_temperature_low = target_temp_low
self._attr_device_info = DeviceInfo(
@@ -242,6 +249,16 @@ class DemoClimate(ClimateEntity):
"""List of available swing modes."""
return self._swing_modes
+ @property
+ def swing_horizontal_mode(self) -> str | None:
+ """Return the swing setting."""
+ return self._current_swing_horizontal_mode
+
+ @property
+ def swing_horizontal_modes(self) -> list[str]:
+ """List of available swing modes."""
+ return self._swing_horizontal_modes
+
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperatures."""
if kwargs.get(ATTR_TEMPERATURE) is not None:
@@ -266,6 +283,11 @@ class DemoClimate(ClimateEntity):
self._current_swing_mode = swing_mode
self.async_write_ha_state()
+ async def async_set_swing_horizontal_mode(self, swing_horizontal_mode: str) -> None:
+ """Set new swing mode."""
+ self._current_swing_horizontal_mode = swing_horizontal_mode
+ self.async_write_ha_state()
+
async def async_set_fan_mode(self, fan_mode: str) -> None:
"""Set new fan mode."""
self._current_fan_mode = fan_mode
diff --git a/homeassistant/components/demo/fan.py b/homeassistant/components/demo/fan.py
index 064ee3bb4f7..42e7f9e2434 100644
--- a/homeassistant/components/demo/fan.py
+++ b/homeassistant/components/demo/fan.py
@@ -100,7 +100,6 @@ class BaseDemoFan(FanEntity):
_attr_should_poll = False
_attr_translation_key = "demo"
- _enable_turn_on_off_backwards_compatibility = False
def __init__(
self,
diff --git a/homeassistant/components/demo/icons.json b/homeassistant/components/demo/icons.json
index 17425a6d119..eafcbb9161a 100644
--- a/homeassistant/components/demo/icons.json
+++ b/homeassistant/components/demo/icons.json
@@ -19,6 +19,13 @@
"auto": "mdi:arrow-oscillating",
"off": "mdi:arrow-oscillating-off"
}
+ },
+ "swing_horizontal_mode": {
+ "state": {
+ "rangefull": "mdi:pan-horizontal",
+ "auto": "mdi:compare-horizontal",
+ "off": "mdi:arrow-oscillating-off"
+ }
}
}
}
diff --git a/homeassistant/components/demo/light.py b/homeassistant/components/demo/light.py
index c859fef3b76..ec98a056b3e 100644
--- a/homeassistant/components/demo/light.py
+++ b/homeassistant/components/demo/light.py
@@ -7,12 +7,14 @@ from typing import Any
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
- ATTR_COLOR_TEMP,
+ ATTR_COLOR_TEMP_KELVIN,
ATTR_EFFECT,
ATTR_HS_COLOR,
ATTR_RGBW_COLOR,
ATTR_RGBWW_COLOR,
ATTR_WHITE,
+ DEFAULT_MAX_KELVIN,
+ DEFAULT_MIN_KELVIN,
ColorMode,
LightEntity,
LightEntityFeature,
@@ -28,7 +30,7 @@ LIGHT_COLORS = [(56, 86), (345, 75)]
LIGHT_EFFECT_LIST = ["rainbow", "none"]
-LIGHT_TEMPS = [240, 380]
+LIGHT_TEMPS = [4166, 2631]
SUPPORT_DEMO = {ColorMode.HS, ColorMode.COLOR_TEMP}
SUPPORT_DEMO_HS_WHITE = {ColorMode.HS, ColorMode.WHITE}
@@ -100,6 +102,9 @@ class DemoLight(LightEntity):
_attr_name = None
_attr_should_poll = False
+ _attr_max_color_temp_kelvin = DEFAULT_MAX_KELVIN
+ _attr_min_color_temp_kelvin = DEFAULT_MIN_KELVIN
+
def __init__(
self,
unique_id: str,
@@ -185,8 +190,8 @@ class DemoLight(LightEntity):
return self._rgbww_color
@property
- def color_temp(self) -> int:
- """Return the CT color temperature."""
+ def color_temp_kelvin(self) -> int | None:
+ """Return the color temperature value in Kelvin."""
return self._ct
@property
@@ -216,9 +221,9 @@ class DemoLight(LightEntity):
if ATTR_BRIGHTNESS in kwargs:
self._brightness = kwargs[ATTR_BRIGHTNESS]
- if ATTR_COLOR_TEMP in kwargs:
+ if ATTR_COLOR_TEMP_KELVIN in kwargs:
self._color_mode = ColorMode.COLOR_TEMP
- self._ct = kwargs[ATTR_COLOR_TEMP]
+ self._ct = kwargs[ATTR_COLOR_TEMP_KELVIN]
if ATTR_EFFECT in kwargs:
self._effect = kwargs[ATTR_EFFECT]
diff --git a/homeassistant/components/demo/strings.json b/homeassistant/components/demo/strings.json
index aa5554e9fcc..da72b33d3ca 100644
--- a/homeassistant/components/demo/strings.json
+++ b/homeassistant/components/demo/strings.json
@@ -42,6 +42,13 @@
"auto": "Auto",
"off": "[%key:common::state::off%]"
}
+ },
+ "swing_horizontal_mode": {
+ "state": {
+ "rangefull": "Full range",
+ "auto": "Auto",
+ "off": "[%key:common::state::off%]"
+ }
}
}
}
diff --git a/homeassistant/components/demo/vacuum.py b/homeassistant/components/demo/vacuum.py
index d4c3820d29e..3dd945ab82e 100644
--- a/homeassistant/components/demo/vacuum.py
+++ b/homeassistant/components/demo/vacuum.py
@@ -7,12 +7,8 @@ from typing import Any
from homeassistant.components.vacuum import (
ATTR_CLEANED_AREA,
- STATE_CLEANING,
- STATE_DOCKED,
- STATE_IDLE,
- STATE_PAUSED,
- STATE_RETURNING,
StateVacuumEntity,
+ VacuumActivity,
VacuumEntityFeature,
)
from homeassistant.config_entries import ConfigEntry
@@ -91,16 +87,11 @@ class StateDemoVacuum(StateVacuumEntity):
"""Initialize the vacuum."""
self._attr_name = name
self._attr_supported_features = supported_features
- self._state = STATE_DOCKED
+ self._attr_activity = VacuumActivity.DOCKED
self._fan_speed = FAN_SPEEDS[1]
self._cleaned_area: float = 0
self._battery_level = 100
- @property
- def state(self) -> str:
- """Return the current state of the vacuum."""
- return self._state
-
@property
def battery_level(self) -> int:
"""Return the current battery level of the vacuum."""
@@ -123,33 +114,33 @@ class StateDemoVacuum(StateVacuumEntity):
def start(self) -> None:
"""Start or resume the cleaning task."""
- if self._state != STATE_CLEANING:
- self._state = STATE_CLEANING
+ if self._attr_activity != VacuumActivity.CLEANING:
+ self._attr_activity = VacuumActivity.CLEANING
self._cleaned_area += 1.32
self._battery_level -= 1
self.schedule_update_ha_state()
def pause(self) -> None:
"""Pause the cleaning task."""
- if self._state == STATE_CLEANING:
- self._state = STATE_PAUSED
+ if self._attr_activity == VacuumActivity.CLEANING:
+ self._attr_activity = VacuumActivity.PAUSED
self.schedule_update_ha_state()
def stop(self, **kwargs: Any) -> None:
"""Stop the cleaning task, do not return to dock."""
- self._state = STATE_IDLE
+ self._attr_activity = VacuumActivity.IDLE
self.schedule_update_ha_state()
def return_to_base(self, **kwargs: Any) -> None:
"""Return dock to charging base."""
- self._state = STATE_RETURNING
+ self._attr_activity = VacuumActivity.RETURNING
self.schedule_update_ha_state()
event.call_later(self.hass, 30, self.__set_state_to_dock)
def clean_spot(self, **kwargs: Any) -> None:
"""Perform a spot clean-up."""
- self._state = STATE_CLEANING
+ self._attr_activity = VacuumActivity.CLEANING
self._cleaned_area += 1.32
self._battery_level -= 1
self.schedule_update_ha_state()
@@ -167,12 +158,12 @@ class StateDemoVacuum(StateVacuumEntity):
"persistent_notification",
service_data={"message": "I'm here!", "title": "Locate request"},
)
- self._state = STATE_IDLE
+ self._attr_activity = VacuumActivity.IDLE
self.async_write_ha_state()
async def async_clean_spot(self, **kwargs: Any) -> None:
"""Locate the vacuum's position."""
- self._state = STATE_CLEANING
+ self._attr_activity = VacuumActivity.CLEANING
self.async_write_ha_state()
async def async_send_command(
@@ -182,9 +173,9 @@ class StateDemoVacuum(StateVacuumEntity):
**kwargs: Any,
) -> None:
"""Send a command to the vacuum."""
- self._state = STATE_IDLE
+ self._attr_activity = VacuumActivity.IDLE
self.async_write_ha_state()
def __set_state_to_dock(self, _: datetime) -> None:
- self._state = STATE_DOCKED
+ self._attr_activity = VacuumActivity.DOCKED
self.schedule_update_ha_state()
diff --git a/homeassistant/components/denon/manifest.json b/homeassistant/components/denon/manifest.json
index d94e8a264e3..9e840b43fcf 100644
--- a/homeassistant/components/denon/manifest.json
+++ b/homeassistant/components/denon/manifest.json
@@ -3,5 +3,6 @@
"name": "Denon Network Receivers",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/denon",
- "iot_class": "local_polling"
+ "iot_class": "local_polling",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/denonavr/manifest.json b/homeassistant/components/denonavr/manifest.json
index eff70b94a18..328ab504bd1 100644
--- a/homeassistant/components/denonavr/manifest.json
+++ b/homeassistant/components/denonavr/manifest.json
@@ -6,7 +6,7 @@
"documentation": "https://www.home-assistant.io/integrations/denonavr",
"iot_class": "local_push",
"loggers": ["denonavr"],
- "requirements": ["denonavr==1.0.0"],
+ "requirements": ["denonavr==1.0.1"],
"ssdp": [
{
"manufacturer": "Denon",
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/strings.json b/homeassistant/components/derivative/strings.json
index 4b66c893d57..bfdf861a019 100644
--- a/homeassistant/components/derivative/strings.json
+++ b/homeassistant/components/derivative/strings.json
@@ -3,7 +3,7 @@
"config": {
"step": {
"user": {
- "title": "Add Derivative sensor",
+ "title": "Create Derivative sensor",
"description": "Create a sensor that estimates the derivative of a sensor.",
"data": {
"name": "[%key:common::config_flow::data::name%]",
diff --git a/homeassistant/components/device_tracker/__init__.py b/homeassistant/components/device_tracker/__init__.py
index 28991483cda..313373e3181 100644
--- a/homeassistant/components/device_tracker/__init__.py
+++ b/homeassistant/components/device_tracker/__init__.py
@@ -2,15 +2,8 @@
from __future__ import annotations
-from functools import partial
-
from homeassistant.const import ATTR_GPS_ACCURACY, STATE_HOME # noqa: F401
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.deprecation import (
- all_with_deprecated_constants,
- check_if_deprecated_constant,
- dir_with_deprecated_constants,
-)
from homeassistant.helpers.typing import ConfigType
from homeassistant.loader import bind_hass
@@ -23,10 +16,6 @@ from .config_entry import ( # noqa: F401
async_unload_entry,
)
from .const import ( # noqa: F401
- _DEPRECATED_SOURCE_TYPE_BLUETOOTH,
- _DEPRECATED_SOURCE_TYPE_BLUETOOTH_LE,
- _DEPRECATED_SOURCE_TYPE_GPS,
- _DEPRECATED_SOURCE_TYPE_ROUTER,
ATTR_ATTRIBUTES,
ATTR_BATTERY,
ATTR_DEV_ID,
@@ -72,13 +61,3 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the device tracker."""
async_setup_legacy_integration(hass, config)
return True
-
-
-# As we import deprecated constants from the const module, we need to add these two functions
-# otherwise this module will be logged for using deprecated constants and not the custom component
-# 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/device_tracker/const.py b/homeassistant/components/device_tracker/const.py
index 964b7faab9b..c9e4d4e910a 100644
--- a/homeassistant/components/device_tracker/const.py
+++ b/homeassistant/components/device_tracker/const.py
@@ -4,16 +4,9 @@ from __future__ import annotations
from datetime import timedelta
from enum import StrEnum
-from functools import partial
import logging
from typing import Final
-from homeassistant.helpers.deprecation import (
- DeprecatedConstantEnum,
- all_with_deprecated_constants,
- check_if_deprecated_constant,
- dir_with_deprecated_constants,
-)
from homeassistant.util.signal_type import SignalType
LOGGER: Final = logging.getLogger(__package__)
@@ -34,19 +27,6 @@ class SourceType(StrEnum):
BLUETOOTH_LE = "bluetooth_le"
-# SOURCE_TYPE_* below are deprecated as of 2022.9
-# use the SourceType enum instead.
-_DEPRECATED_SOURCE_TYPE_GPS: Final = DeprecatedConstantEnum(SourceType.GPS, "2025.1")
-_DEPRECATED_SOURCE_TYPE_ROUTER: Final = DeprecatedConstantEnum(
- SourceType.ROUTER, "2025.1"
-)
-_DEPRECATED_SOURCE_TYPE_BLUETOOTH: Final = DeprecatedConstantEnum(
- SourceType.BLUETOOTH, "2025.1"
-)
-_DEPRECATED_SOURCE_TYPE_BLUETOOTH_LE: Final = DeprecatedConstantEnum(
- SourceType.BLUETOOTH_LE, "2025.1"
-)
-
CONF_SCAN_INTERVAL: Final = "interval_seconds"
SCAN_INTERVAL: Final = timedelta(seconds=12)
@@ -72,10 +52,3 @@ ATTR_IP: Final = "ip"
CONNECTED_DEVICE_REGISTERED = SignalType[dict[str, str | None]](
"device_tracker_connected_device_registered"
)
-
-# 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/device_tracker/strings.json b/homeassistant/components/device_tracker/strings.json
index d6e36d92300..294333a5d80 100644
--- a/homeassistant/components/device_tracker/strings.json
+++ b/homeassistant/components/device_tracker/strings.json
@@ -48,7 +48,7 @@
"services": {
"see": {
"name": "See",
- "description": "Records a seen tracked device.",
+ "description": "Manually update the records of a seen legacy device tracker in the known_devices.yaml file.",
"fields": {
"mac": {
"name": "MAC address",
diff --git a/homeassistant/components/devolo_home_control/__init__.py b/homeassistant/components/devolo_home_control/__init__.py
index 7755e0f22b4..e86b7b753c8 100644
--- a/homeassistant/components/devolo_home_control/__init__.py
+++ b/homeassistant/components/devolo_home_control/__init__.py
@@ -18,7 +18,7 @@ from homeassistant.core import Event, HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers.device_registry import DeviceEntry
-from .const import CONF_MYDEVOLO, DEFAULT_MYDEVOLO, GATEWAY_SERIAL_PATTERN, PLATFORMS
+from .const import GATEWAY_SERIAL_PATTERN, PLATFORMS
type DevoloHomeControlConfigEntry = ConfigEntry[list[HomeControl]]
@@ -102,5 +102,4 @@ def configure_mydevolo(conf: dict[str, Any] | MappingProxyType[str, Any]) -> Myd
mydevolo = Mydevolo()
mydevolo.user = conf[CONF_USERNAME]
mydevolo.password = conf[CONF_PASSWORD]
- mydevolo.url = conf.get(CONF_MYDEVOLO, DEFAULT_MYDEVOLO)
return mydevolo
diff --git a/homeassistant/components/devolo_home_control/binary_sensor.py b/homeassistant/components/devolo_home_control/binary_sensor.py
index 449b1c7659f..d24033a80b9 100644
--- a/homeassistant/components/devolo_home_control/binary_sensor.py
+++ b/homeassistant/components/devolo_home_control/binary_sensor.py
@@ -81,14 +81,8 @@ class DevoloBinaryDeviceEntity(DevoloDeviceEntity, BinarySensorEntity):
or self._binary_sensor_property.sensor_type
)
- if device_instance.binary_sensor_property[element_uid].sub_type != "":
- self._attr_name = device_instance.binary_sensor_property[
- element_uid
- ].sub_type.capitalize()
- else:
- self._attr_name = device_instance.binary_sensor_property[
- element_uid
- ].sensor_type.capitalize()
+ if device_instance.binary_sensor_property[element_uid].sub_type == "overload":
+ self._attr_translation_key = "overload"
self._value = self._binary_sensor_property.state
@@ -129,7 +123,8 @@ class DevoloRemoteControl(DevoloDeviceEntity, BinarySensorEntity):
self._key = key
self._attr_is_on = False
- self._attr_name = f"Button {key}"
+ self._attr_translation_key = "button"
+ self._attr_translation_placeholders = {"key": str(key)}
def _sync(self, message: tuple) -> None:
"""Update the binary sensor state."""
diff --git a/homeassistant/components/devolo_home_control/climate.py b/homeassistant/components/devolo_home_control/climate.py
index 29177ae2437..1f407eb6804 100644
--- a/homeassistant/components/devolo_home_control/climate.py
+++ b/homeassistant/components/devolo_home_control/climate.py
@@ -56,7 +56,6 @@ class DevoloClimateDeviceEntity(DevoloMultiLevelSwitchDeviceEntity, ClimateEntit
_attr_precision = PRECISION_TENTHS
_attr_hvac_mode = HVACMode.HEAT
_attr_hvac_modes = [HVACMode.HEAT]
- _enable_turn_on_off_backwards_compatibility = False
def __init__(
self, homecontrol: HomeControl, device_instance: Zwave, element_uid: str
diff --git a/homeassistant/components/devolo_home_control/config_flow.py b/homeassistant/components/devolo_home_control/config_flow.py
index bfb083e0c44..e15204af7c2 100644
--- a/homeassistant/components/devolo_home_control/config_flow.py
+++ b/homeassistant/components/devolo_home_control/config_flow.py
@@ -18,7 +18,7 @@ from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import callback
from . import configure_mydevolo
-from .const import CONF_MYDEVOLO, DEFAULT_MYDEVOLO, DOMAIN, SUPPORTED_MODEL_TYPES
+from .const import DOMAIN, SUPPORTED_MODEL_TYPES
from .exceptions import CredentialsInvalid, UuidChanged
@@ -35,14 +35,11 @@ class DevoloHomeControlFlowHandler(ConfigFlow, domain=DOMAIN):
vol.Required(CONF_USERNAME): str,
vol.Required(CONF_PASSWORD): str,
}
- self._url = DEFAULT_MYDEVOLO
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle a flow initiated by the user."""
- if self.show_advanced_options:
- self.data_schema[vol.Required(CONF_MYDEVOLO, default=self._url)] = str
if user_input is None:
return self._show_form(step_id="user")
try:
@@ -78,7 +75,6 @@ class DevoloHomeControlFlowHandler(ConfigFlow, domain=DOMAIN):
) -> ConfigFlowResult:
"""Handle reauthentication."""
self._reauth_entry = self._get_reauth_entry()
- self._url = entry_data[CONF_MYDEVOLO]
self.data_schema = {
vol.Required(CONF_USERNAME, default=entry_data[CONF_USERNAME]): str,
vol.Required(CONF_PASSWORD): str,
@@ -104,7 +100,6 @@ class DevoloHomeControlFlowHandler(ConfigFlow, domain=DOMAIN):
async def _connect_mydevolo(self, user_input: dict[str, Any]) -> ConfigFlowResult:
"""Connect to mydevolo."""
- user_input[CONF_MYDEVOLO] = user_input.get(CONF_MYDEVOLO, self._url)
mydevolo = configure_mydevolo(conf=user_input)
credentials_valid = await self.hass.async_add_executor_job(
mydevolo.credentials_valid
@@ -121,7 +116,6 @@ class DevoloHomeControlFlowHandler(ConfigFlow, domain=DOMAIN):
data={
CONF_PASSWORD: mydevolo.password,
CONF_USERNAME: mydevolo.user,
- CONF_MYDEVOLO: mydevolo.url,
},
)
diff --git a/homeassistant/components/devolo_home_control/const.py b/homeassistant/components/devolo_home_control/const.py
index eb48a6d269e..bd2282ad99f 100644
--- a/homeassistant/components/devolo_home_control/const.py
+++ b/homeassistant/components/devolo_home_control/const.py
@@ -5,7 +5,6 @@ import re
from homeassistant.const import Platform
DOMAIN = "devolo_home_control"
-DEFAULT_MYDEVOLO = "https://www.mydevolo.com"
PLATFORMS = [
Platform.BINARY_SENSOR,
Platform.CLIMATE,
@@ -15,6 +14,5 @@ PLATFORMS = [
Platform.SIREN,
Platform.SWITCH,
]
-CONF_MYDEVOLO = "mydevolo_url"
GATEWAY_SERIAL_PATTERN = re.compile(r"\d{16}")
SUPPORTED_MODEL_TYPES = ["2600", "2601"]
diff --git a/homeassistant/components/devolo_home_control/manifest.json b/homeassistant/components/devolo_home_control/manifest.json
index eb85e827551..a9715fffa84 100644
--- a/homeassistant/components/devolo_home_control/manifest.json
+++ b/homeassistant/components/devolo_home_control/manifest.json
@@ -8,7 +8,6 @@
"integration_type": "hub",
"iot_class": "local_push",
"loggers": ["devolo_home_control_api"],
- "quality_scale": "gold",
"requirements": ["devolo-home-control-api==0.18.3"],
"zeroconf": ["_dvl-deviceapi._tcp.local."]
}
diff --git a/homeassistant/components/devolo_home_control/sensor.py b/homeassistant/components/devolo_home_control/sensor.py
index 61a63419732..8d0a7f0313c 100644
--- a/homeassistant/components/devolo_home_control/sensor.py
+++ b/homeassistant/components/devolo_home_control/sensor.py
@@ -116,9 +116,11 @@ class DevoloGenericMultiLevelDeviceEntity(DevoloMultiLevelDeviceEntity):
self._multi_level_sensor_property.sensor_type
)
self._attr_native_unit_of_measurement = self._multi_level_sensor_property.unit
- self._attr_name = self._multi_level_sensor_property.sensor_type.capitalize()
self._value = self._multi_level_sensor_property.value
+ if self._multi_level_sensor_property.sensor_type == "light":
+ self._attr_translation_key = "brightness"
+
if element_uid.startswith("devolo.VoltageMultiLevelSensor:"):
self._attr_entity_registry_enabled_default = False
@@ -128,7 +130,6 @@ class DevoloBatteryEntity(DevoloMultiLevelDeviceEntity):
_attr_entity_category = EntityCategory.DIAGNOSTIC
_attr_native_unit_of_measurement = PERCENTAGE
- _attr_name = "Battery level"
_attr_device_class = SensorDeviceClass.BATTERY
_attr_state_class = SensorStateClass.MEASUREMENT
@@ -175,8 +176,6 @@ class DevoloConsumptionEntity(DevoloMultiLevelDeviceEntity):
device_instance.consumption_property[element_uid], consumption
)
- self._attr_name = f"{consumption.capitalize()} consumption"
-
@property
def unique_id(self) -> str:
"""Return the unique ID of the entity.
diff --git a/homeassistant/components/devolo_home_control/strings.json b/homeassistant/components/devolo_home_control/strings.json
index eeae9aa2e2f..be853e2d89d 100644
--- a/homeassistant/components/devolo_home_control/strings.json
+++ b/homeassistant/components/devolo_home_control/strings.json
@@ -12,17 +12,38 @@
"user": {
"data": {
"username": "Email / devolo ID",
- "password": "[%key:common::config_flow::data::password%]",
- "mydevolo_url": "mydevolo URL"
+ "password": "[%key:common::config_flow::data::password%]"
+ },
+ "data_description": {
+ "username": "Email address you used to register the central unit at mydevolo.",
+ "password": "Password of your mydevolo account."
}
},
"zeroconf_confirm": {
"data": {
"username": "[%key:component::devolo_home_control::config::step::user::data::username%]",
- "password": "[%key:common::config_flow::data::password%]",
- "mydevolo_url": "[%key:component::devolo_home_control::config::step::user::data::mydevolo_url%]"
+ "password": "[%key:common::config_flow::data::password%]"
+ },
+ "data_description": {
+ "username": "[%key:component::devolo_home_control::config::step::user::data_description::username%]",
+ "password": "[%key:component::devolo_home_control::config::step::user::data_description::password%]"
}
}
}
+ },
+ "entity": {
+ "binary_sensor": {
+ "button": {
+ "name": "Button {key}"
+ },
+ "overload": {
+ "name": "Overload"
+ }
+ },
+ "sensor": {
+ "brightness": {
+ "name": "Brightness"
+ }
+ }
}
}
diff --git a/homeassistant/components/devolo_home_network/__init__.py b/homeassistant/components/devolo_home_network/__init__.py
index 70a94531431..7f6784f2404 100644
--- a/homeassistant/components/devolo_home_network/__init__.py
+++ b/homeassistant/components/devolo_home_network/__init__.py
@@ -83,7 +83,6 @@ async def async_setup_entry(
)
except DeviceNotFound as err:
raise ConfigEntryNotReady(
- f"Unable to connect to {entry.data[CONF_IP_ADDRESS]}",
translation_domain=DOMAIN,
translation_key="connection_failed",
translation_placeholders={"ip_address": entry.data[CONF_IP_ADDRESS]},
@@ -98,7 +97,11 @@ async def async_setup_entry(
try:
return await device.device.async_check_firmware_available()
except DeviceUnavailable as err:
- raise UpdateFailed(err) from err
+ raise UpdateFailed(
+ translation_domain=DOMAIN,
+ translation_key="update_failed",
+ translation_placeholders={"error": str(err)},
+ ) from err
async def async_update_connected_plc_devices() -> LogicalNetwork:
"""Fetch data from API endpoint."""
@@ -107,7 +110,11 @@ async def async_setup_entry(
try:
return await device.plcnet.async_get_network_overview()
except DeviceUnavailable as err:
- raise UpdateFailed(err) from err
+ raise UpdateFailed(
+ translation_domain=DOMAIN,
+ translation_key="update_failed",
+ translation_placeholders={"error": str(err)},
+ ) from err
async def async_update_guest_wifi_status() -> WifiGuestAccessGet:
"""Fetch data from API endpoint."""
@@ -116,10 +123,14 @@ async def async_setup_entry(
try:
return await device.device.async_get_wifi_guest_access()
except DeviceUnavailable as err:
- raise UpdateFailed(err) from err
+ raise UpdateFailed(
+ translation_domain=DOMAIN,
+ translation_key="update_failed",
+ translation_placeholders={"error": str(err)},
+ ) from err
except DevicePasswordProtected as err:
raise ConfigEntryAuthFailed(
- err, translation_domain=DOMAIN, translation_key="password_wrong"
+ translation_domain=DOMAIN, translation_key="password_wrong"
) from err
async def async_update_led_status() -> bool:
@@ -129,7 +140,11 @@ async def async_setup_entry(
try:
return await device.device.async_get_led_setting()
except DeviceUnavailable as err:
- raise UpdateFailed(err) from err
+ raise UpdateFailed(
+ translation_domain=DOMAIN,
+ translation_key="update_failed",
+ translation_placeholders={"error": str(err)},
+ ) from err
async def async_update_last_restart() -> int:
"""Fetch data from API endpoint."""
@@ -138,10 +153,14 @@ async def async_setup_entry(
try:
return await device.device.async_uptime()
except DeviceUnavailable as err:
- raise UpdateFailed(err) from err
+ raise UpdateFailed(
+ translation_domain=DOMAIN,
+ translation_key="update_failed",
+ translation_placeholders={"error": str(err)},
+ ) from err
except DevicePasswordProtected as err:
raise ConfigEntryAuthFailed(
- err, translation_domain=DOMAIN, translation_key="password_wrong"
+ translation_domain=DOMAIN, translation_key="password_wrong"
) from err
async def async_update_wifi_connected_station() -> list[ConnectedStationInfo]:
@@ -151,7 +170,11 @@ async def async_setup_entry(
try:
return await device.device.async_get_wifi_connected_station()
except DeviceUnavailable as err:
- raise UpdateFailed(err) from err
+ raise UpdateFailed(
+ translation_domain=DOMAIN,
+ translation_key="update_failed",
+ translation_placeholders={"error": str(err)},
+ ) from err
async def async_update_wifi_neighbor_access_points() -> list[NeighborAPInfo]:
"""Fetch data from API endpoint."""
@@ -160,7 +183,11 @@ async def async_setup_entry(
try:
return await device.device.async_get_wifi_neighbor_access_points()
except DeviceUnavailable as err:
- raise UpdateFailed(err) from err
+ raise UpdateFailed(
+ translation_domain=DOMAIN,
+ translation_key="update_failed",
+ translation_placeholders={"error": str(err)},
+ ) from err
async def disconnect(event: Event) -> None:
"""Disconnect from device."""
diff --git a/homeassistant/components/devolo_home_network/manifest.json b/homeassistant/components/devolo_home_network/manifest.json
index 27fd08898c0..d10e14f9081 100644
--- a/homeassistant/components/devolo_home_network/manifest.json
+++ b/homeassistant/components/devolo_home_network/manifest.json
@@ -7,7 +7,6 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["devolo_plc_api"],
- "quality_scale": "platinum",
"requirements": ["devolo-plc-api==1.4.1"],
"zeroconf": [
{
diff --git a/homeassistant/components/devolo_home_network/strings.json b/homeassistant/components/devolo_home_network/strings.json
index 0799bb14172..4b683b5d2fa 100644
--- a/homeassistant/components/devolo_home_network/strings.json
+++ b/homeassistant/components/devolo_home_network/strings.json
@@ -6,11 +6,17 @@
"description": "[%key:common::config_flow::description::confirm_setup%]",
"data": {
"ip_address": "[%key:common::config_flow::data::ip%]"
+ },
+ "data_description": {
+ "ip_address": "IP address of your devolo Home Network device. This can be found in the devolo Home Network App on the device dashboard."
}
},
"reauth_confirm": {
"data": {
"password": "[%key:common::config_flow::data::password%]"
+ },
+ "data_description": {
+ "password": "Password you protected the device with."
}
},
"zeroconf_confirm": {
@@ -94,6 +100,9 @@
},
"password_wrong": {
"message": "The used password is wrong"
+ },
+ "update_failed": {
+ "message": "Error while updating the data: {error}"
}
}
}
diff --git a/homeassistant/components/dexcom/__init__.py b/homeassistant/components/dexcom/__init__.py
index b9a3bdba12d..e93e8e66358 100644
--- a/homeassistant/components/dexcom/__init__.py
+++ b/homeassistant/components/dexcom/__init__.py
@@ -6,12 +6,12 @@ import logging
from pydexcom import AccountError, Dexcom, GlucoseReading, SessionError
from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import CONF_PASSWORD, CONF_UNIT_OF_MEASUREMENT, CONF_USERNAME
+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, MG_DL, PLATFORMS, SERVER_OUS
+from .const import CONF_SERVER, DOMAIN, PLATFORMS, SERVER_OUS
_LOGGER = logging.getLogger(__name__)
@@ -32,11 +32,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
except SessionError as error:
raise ConfigEntryNotReady from error
- if not entry.options:
- hass.config_entries.async_update_entry(
- entry, options={CONF_UNIT_OF_MEASUREMENT: MG_DL}
- )
-
async def async_update_data():
try:
return await hass.async_add_executor_job(dexcom.get_current_glucose_reading)
@@ -55,8 +50,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
- entry.async_on_unload(entry.add_update_listener(update_listener))
-
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
@@ -67,8 +60,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok
-
-
-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/dexcom/config_flow.py b/homeassistant/components/dexcom/config_flow.py
index c5c830dedf6..90917e0ce2c 100644
--- a/homeassistant/components/dexcom/config_flow.py
+++ b/homeassistant/components/dexcom/config_flow.py
@@ -7,16 +7,10 @@ from typing import Any
from pydexcom import AccountError, Dexcom, SessionError
import voluptuous as vol
-from homeassistant.config_entries import (
- ConfigEntry,
- ConfigFlow,
- ConfigFlowResult,
- OptionsFlow,
-)
-from homeassistant.const import CONF_PASSWORD, CONF_UNIT_OF_MEASUREMENT, CONF_USERNAME
-from homeassistant.core import callback
+from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
+from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
-from .const import CONF_SERVER, DOMAIN, MG_DL, MMOL_L, SERVER_OUS, SERVER_US
+from .const import CONF_SERVER, DOMAIN, SERVER_OUS, SERVER_US
DATA_SCHEMA = vol.Schema(
{
@@ -62,34 +56,3 @@ class DexcomConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_show_form(
step_id="user", data_schema=DATA_SCHEMA, errors=errors
)
-
- @staticmethod
- @callback
- def async_get_options_flow(
- config_entry: ConfigEntry,
- ) -> DexcomOptionsFlowHandler:
- """Get the options flow for this handler."""
- return DexcomOptionsFlowHandler()
-
-
-class DexcomOptionsFlowHandler(OptionsFlow):
- """Handle a option flow for Dexcom."""
-
- async def async_step_init(
- self, user_input: dict[str, Any] | None = None
- ) -> ConfigFlowResult:
- """Handle options flow."""
- if user_input is not None:
- return self.async_create_entry(title="", data=user_input)
-
- data_schema = vol.Schema(
- {
- vol.Optional(
- CONF_UNIT_OF_MEASUREMENT,
- default=self.config_entry.options.get(
- CONF_UNIT_OF_MEASUREMENT, MG_DL
- ),
- ): vol.In({MG_DL, MMOL_L}),
- }
- )
- return self.async_show_form(step_id="init", data_schema=data_schema)
diff --git a/homeassistant/components/dexcom/const.py b/homeassistant/components/dexcom/const.py
index 487a844eb2b..66999e51e4b 100644
--- a/homeassistant/components/dexcom/const.py
+++ b/homeassistant/components/dexcom/const.py
@@ -5,9 +5,6 @@ from homeassistant.const import Platform
DOMAIN = "dexcom"
PLATFORMS = [Platform.SENSOR]
-MMOL_L = "mmol/L"
-MG_DL = "mg/dL"
-
CONF_SERVER = "server"
SERVER_OUS = "EU"
diff --git a/homeassistant/components/dexcom/sensor.py b/homeassistant/components/dexcom/sensor.py
index 10b30f39fcb..850678e7ac9 100644
--- a/homeassistant/components/dexcom/sensor.py
+++ b/homeassistant/components/dexcom/sensor.py
@@ -6,7 +6,7 @@ from pydexcom import GlucoseReading
from homeassistant.components.sensor import SensorDeviceClass, SensorEntity
from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import CONF_UNIT_OF_MEASUREMENT, CONF_USERNAME
+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
@@ -15,7 +15,7 @@ from homeassistant.helpers.update_coordinator import (
DataUpdateCoordinator,
)
-from .const import DOMAIN, MG_DL
+from .const import DOMAIN
TRENDS = {
1: "rising_quickly",
@@ -36,13 +36,10 @@ async def async_setup_entry(
"""Set up the Dexcom sensors."""
coordinator = hass.data[DOMAIN][config_entry.entry_id]
username = config_entry.data[CONF_USERNAME]
- unit_of_measurement = config_entry.options[CONF_UNIT_OF_MEASUREMENT]
async_add_entities(
[
DexcomGlucoseTrendSensor(coordinator, username, config_entry.entry_id),
- DexcomGlucoseValueSensor(
- coordinator, username, config_entry.entry_id, unit_of_measurement
- ),
+ DexcomGlucoseValueSensor(coordinator, username, config_entry.entry_id),
],
)
@@ -73,6 +70,10 @@ class DexcomSensorEntity(
class DexcomGlucoseValueSensor(DexcomSensorEntity):
"""Representation of a Dexcom glucose value sensor."""
+ _attr_device_class = SensorDeviceClass.BLOOD_GLUCOSE_CONCENTRATION
+ _attr_native_unit_of_measurement = (
+ UnitOfBloodGlucoseConcentration.MILLIGRAMS_PER_DECILITER
+ )
_attr_translation_key = "glucose_value"
def __init__(
@@ -80,18 +81,15 @@ class DexcomGlucoseValueSensor(DexcomSensorEntity):
coordinator: DataUpdateCoordinator,
username: str,
entry_id: str,
- unit_of_measurement: str,
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator, username, entry_id, "value")
- self._attr_native_unit_of_measurement = unit_of_measurement
- self._key = "mg_dl" if unit_of_measurement == MG_DL else "mmol_l"
@property
def native_value(self):
"""Return the state of the sensor."""
if self.coordinator.data:
- return getattr(self.coordinator.data, self._key)
+ return self.coordinator.data.mg_dl
return None
diff --git a/homeassistant/components/digital_ocean/manifest.json b/homeassistant/components/digital_ocean/manifest.json
index 7fee8ca5b2b..819a557491a 100644
--- a/homeassistant/components/digital_ocean/manifest.json
+++ b/homeassistant/components/digital_ocean/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/digital_ocean",
"iot_class": "local_polling",
"loggers": ["digitalocean"],
+ "quality_scale": "legacy",
"requirements": ["python-digitalocean==1.13.2"]
}
diff --git a/homeassistant/components/directv/manifest.json b/homeassistant/components/directv/manifest.json
index 957bbff0acc..bee2c297635 100644
--- a/homeassistant/components/directv/manifest.json
+++ b/homeassistant/components/directv/manifest.json
@@ -6,7 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/directv",
"iot_class": "local_polling",
"loggers": ["directv"],
- "quality_scale": "silver",
"requirements": ["directv==0.4.0"],
"ssdp": [
{
diff --git a/homeassistant/components/discogs/manifest.json b/homeassistant/components/discogs/manifest.json
index fceb214aded..f724b4bc6fd 100644
--- a/homeassistant/components/discogs/manifest.json
+++ b/homeassistant/components/discogs/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/discogs",
"iot_class": "cloud_polling",
"loggers": ["discogs_client"],
+ "quality_scale": "legacy",
"requirements": ["discogs-client==2.3.0"]
}
diff --git a/homeassistant/components/discovergy/__init__.py b/homeassistant/components/discovergy/__init__.py
index 72aa6c19a21..81c33adc052 100644
--- a/homeassistant/components/discovergy/__init__.py
+++ b/homeassistant/components/discovergy/__init__.py
@@ -60,11 +60,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: DiscovergyConfigEntry) -
return True
-async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_unload_entry(hass: HomeAssistant, entry: DiscovergyConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
-async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
+async def async_reload_entry(hass: HomeAssistant, entry: DiscovergyConfigEntry) -> None:
"""Handle an options update."""
await hass.config_entries.async_reload(entry.entry_id)
diff --git a/homeassistant/components/discovergy/config_flow.py b/homeassistant/components/discovergy/config_flow.py
index 05ed90bf354..f24fdd1e43d 100644
--- a/homeassistant/components/discovergy/config_flow.py
+++ b/homeassistant/components/discovergy/config_flow.py
@@ -11,12 +11,7 @@ from pydiscovergy.authentication import BasicAuth
import pydiscovergy.error as discovergyError
import voluptuous as vol
-from homeassistant.config_entries import (
- SOURCE_REAUTH,
- ConfigEntry,
- ConfigFlow,
- ConfigFlowResult,
-)
+from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD
from homeassistant.helpers.httpx_client import get_async_client
from homeassistant.helpers.selector import (
@@ -57,35 +52,14 @@ class DiscovergyConfigFlow(ConfigFlow, domain=DOMAIN):
VERSION = 1
- _existing_entry: ConfigEntry
-
- async def async_step_user(
- self, user_input: dict[str, Any] | None = None
- ) -> ConfigFlowResult:
- """Handle the initial step."""
- if user_input is None:
- return self.async_show_form(
- step_id="user",
- data_schema=CONFIG_SCHEMA,
- )
-
- return await self._validate_and_save(user_input)
-
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Handle the initial step."""
- self._existing_entry = self._get_reauth_entry()
- return await self.async_step_reauth_confirm()
+ return await self.async_step_user()
- async def async_step_reauth_confirm(
- self, user_input: dict[str, Any] | None = None
- ) -> ConfigFlowResult:
- """Handle the reauth step."""
- return await self._validate_and_save(user_input, step_id="reauth_confirm")
-
- async def _validate_and_save(
- self, user_input: Mapping[str, Any] | None = None, step_id: str = "user"
+ async def async_step_user(
+ self, user_input: Mapping[str, Any] | None = None
) -> ConfigFlowResult:
"""Validate user input and create config entry."""
errors = {}
@@ -106,17 +80,17 @@ class DiscovergyConfigFlow(ConfigFlow, domain=DOMAIN):
_LOGGER.exception("Unexpected error occurred while getting meters")
errors["base"] = "unknown"
else:
+ await self.async_set_unique_id(user_input[CONF_EMAIL].lower())
+
if self.source == SOURCE_REAUTH:
+ self._abort_if_unique_id_mismatch(reason="account_mismatch")
return self.async_update_reload_and_abort(
- entry=self._existing_entry,
- data={
- CONF_EMAIL: user_input[CONF_EMAIL],
+ entry=self._get_reauth_entry(),
+ data_updates={
CONF_PASSWORD: user_input[CONF_PASSWORD],
},
)
- # set unique id to title which is the account email
- await self.async_set_unique_id(user_input[CONF_EMAIL].lower())
self._abort_if_unique_id_configured()
return self.async_create_entry(
@@ -124,10 +98,10 @@ class DiscovergyConfigFlow(ConfigFlow, domain=DOMAIN):
)
return self.async_show_form(
- step_id=step_id,
+ step_id="user",
data_schema=self.add_suggested_values_to_schema(
CONFIG_SCHEMA,
- self._existing_entry.data
+ self._get_reauth_entry().data
if self.source == SOURCE_REAUTH
else user_input,
),
diff --git a/homeassistant/components/discovergy/quality_scale.yaml b/homeassistant/components/discovergy/quality_scale.yaml
new file mode 100644
index 00000000000..3caeaa6bbe0
--- /dev/null
+++ b/homeassistant/components/discovergy/quality_scale.yaml
@@ -0,0 +1,96 @@
+rules:
+ # Bronze
+ action-setup:
+ status: exempt
+ comment: |
+ The integration does not provide any additional actions.
+ appropriate-polling: done
+ brands: done
+ common-modules: done
+ config-flow-test-coverage: done
+ config-flow:
+ status: todo
+ comment: |
+ The data_descriptions are missing.
+ 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: todo
+ 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:
+ status: exempt
+ comment: |
+ The integration does not provide any additional actions.
+ config-entry-unloading: done
+ docs-configuration-parameters:
+ status: exempt
+ comment: |
+ The integration does not provide any additional options.
+ docs-installation-parameters: todo
+ entity-unavailable: done
+ integration-owner: done
+ log-when-unavailable: done
+ parallel-updates: todo
+ reauthentication-flow: done
+ test-coverage: done
+
+ # Gold
+ devices: done
+ diagnostics: done
+ discovery-update-info:
+ status: exempt
+ comment: |
+ This integration cannot be discovered, it is a connecting to a cloud service.
+ discovery:
+ status: exempt
+ comment: |
+ This integration cannot be discovered, it is a connecting to a cloud service.
+ 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: |
+ The integration connects to a single device per configuration entry.
+ entity-category: done
+ entity-device-class: done
+ entity-disabled-by-default: done
+ entity-translations: done
+ exception-translations: todo
+ icon-translations:
+ status: exempt
+ comment: |
+ The integration does not provide any additional icons.
+ reconfiguration-flow: todo
+ repair-issues:
+ status: exempt
+ comment: |
+ This integration does not raise any repairable issues.
+ stale-devices:
+ status: exempt
+ comment: |
+ This integration connect to a single device per configuration entry.
+
+ # Platinum
+ async-dependency: done
+ inject-websession: done
+ strict-typing: done
diff --git a/homeassistant/components/discovergy/strings.json b/homeassistant/components/discovergy/strings.json
index 9a91fa92dc4..b626a11ea1e 100644
--- a/homeassistant/components/discovergy/strings.json
+++ b/homeassistant/components/discovergy/strings.json
@@ -6,12 +6,6 @@
"email": "[%key:common::config_flow::data::email%]",
"password": "[%key:common::config_flow::data::password%]"
}
- },
- "reauth_confirm": {
- "data": {
- "email": "[%key:common::config_flow::data::email%]",
- "password": "[%key:common::config_flow::data::password%]"
- }
}
},
"error": {
@@ -21,6 +15,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.",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
}
},
diff --git a/homeassistant/components/dlib_face_detect/manifest.json b/homeassistant/components/dlib_face_detect/manifest.json
index e395a84f206..e8476583081 100644
--- a/homeassistant/components/dlib_face_detect/manifest.json
+++ b/homeassistant/components/dlib_face_detect/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/dlib_face_detect",
"iot_class": "local_push",
"loggers": ["face_recognition"],
+ "quality_scale": "legacy",
"requirements": ["face-recognition==1.2.3"]
}
diff --git a/homeassistant/components/dlib_face_identify/manifest.json b/homeassistant/components/dlib_face_identify/manifest.json
index 60c0ef3c766..2a764e4a3e8 100644
--- a/homeassistant/components/dlib_face_identify/manifest.json
+++ b/homeassistant/components/dlib_face_identify/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/dlib_face_identify",
"iot_class": "local_push",
"loggers": ["face_recognition"],
+ "quality_scale": "legacy",
"requirements": ["face-recognition==1.2.3"]
}
diff --git a/homeassistant/components/dlna_dmr/manifest.json b/homeassistant/components/dlna_dmr/manifest.json
index 84024d5bde1..af16379e9c9 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.41.0", "getmac==0.9.4"],
+ "requirements": ["async-upnp-client==0.42.0", "getmac==0.9.4"],
"ssdp": [
{
"deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1",
diff --git a/homeassistant/components/dlna_dms/manifest.json b/homeassistant/components/dlna_dms/manifest.json
index 091e083ceda..ac5bf3719e3 100644
--- a/homeassistant/components/dlna_dms/manifest.json
+++ b/homeassistant/components/dlna_dms/manifest.json
@@ -7,8 +7,7 @@
"dependencies": ["ssdp"],
"documentation": "https://www.home-assistant.io/integrations/dlna_dms",
"iot_class": "local_polling",
- "quality_scale": "platinum",
- "requirements": ["async-upnp-client==0.41.0"],
+ "requirements": ["async-upnp-client==0.42.0"],
"ssdp": [
{
"deviceType": "urn:schemas-upnp-org:device:MediaServer:1",
diff --git a/homeassistant/components/dominos/manifest.json b/homeassistant/components/dominos/manifest.json
index 442f433db7c..5618c6f0d87 100644
--- a/homeassistant/components/dominos/manifest.json
+++ b/homeassistant/components/dominos/manifest.json
@@ -6,5 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/dominos",
"iot_class": "cloud_polling",
"loggers": ["pizzapi"],
+ "quality_scale": "legacy",
"requirements": ["pizzapi==0.0.6"]
}
diff --git a/homeassistant/components/doods/manifest.json b/homeassistant/components/doods/manifest.json
index fabb2c30190..2c672dd4abb 100644
--- a/homeassistant/components/doods/manifest.json
+++ b/homeassistant/components/doods/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/doods",
"iot_class": "local_polling",
"loggers": ["pydoods"],
- "requirements": ["pydoods==1.0.2", "Pillow==10.4.0"]
+ "quality_scale": "legacy",
+ "requirements": ["pydoods==1.0.2", "Pillow==11.1.0"]
}
diff --git a/homeassistant/components/dovado/manifest.json b/homeassistant/components/dovado/manifest.json
index 9a0fc46ad16..78b1e0c6719 100644
--- a/homeassistant/components/dovado/manifest.json
+++ b/homeassistant/components/dovado/manifest.json
@@ -5,5 +5,6 @@
"disabled": "This integration is disabled because it uses non-open source code to operate.",
"documentation": "https://www.home-assistant.io/integrations/dovado",
"iot_class": "local_polling",
+ "quality_scale": "legacy",
"requirements": ["dovado==0.4.1"]
}
diff --git a/homeassistant/components/downloader/strings.json b/homeassistant/components/downloader/strings.json
index 11a2bda8fce..7db7ea459d7 100644
--- a/homeassistant/components/downloader/strings.json
+++ b/homeassistant/components/downloader/strings.json
@@ -23,15 +23,15 @@
},
"subdir": {
"name": "Subdirectory",
- "description": "Download into subdirectory."
+ "description": "Relative download path."
},
"filename": {
"name": "Filename",
- "description": "Determine the filename."
+ "description": "Custom name for the downloaded file."
},
"overwrite": {
"name": "Overwrite",
- "description": "Whether to overwrite the file or not."
+ "description": "Overwrite file if it exists."
}
}
}
diff --git a/homeassistant/components/dsmr/sensor.py b/homeassistant/components/dsmr/sensor.py
index a069c32be04..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
@@ -549,7 +555,7 @@ async def async_setup_entry(
dsmr_version = entry.data[CONF_DSMR_VERSION]
entities: list[DSMREntity] = []
initialized: bool = False
- add_entities_handler: Callable[..., None] | None
+ add_entities_handler: Callable[[], None] | None
@callback
def init_async_add_entities(telegram: Telegram) -> None:
diff --git a/homeassistant/components/dsmr_reader/manifest.json b/homeassistant/components/dsmr_reader/manifest.json
index 7adb664fbd8..9c0e6da2c46 100644
--- a/homeassistant/components/dsmr_reader/manifest.json
+++ b/homeassistant/components/dsmr_reader/manifest.json
@@ -6,6 +6,5 @@
"dependencies": ["mqtt"],
"documentation": "https://www.home-assistant.io/integrations/dsmr_reader",
"iot_class": "local_push",
- "mqtt": ["dsmr/#"],
- "quality_scale": "gold"
+ "mqtt": ["dsmr/#"]
}
diff --git a/homeassistant/components/dte_energy_bridge/__init__.py b/homeassistant/components/dte_energy_bridge/__init__.py
deleted file mode 100644
index 2525d047bce..00000000000
--- a/homeassistant/components/dte_energy_bridge/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-"""The dte_energy_bridge component."""
diff --git a/homeassistant/components/dte_energy_bridge/manifest.json b/homeassistant/components/dte_energy_bridge/manifest.json
deleted file mode 100644
index f5b57d82869..00000000000
--- a/homeassistant/components/dte_energy_bridge/manifest.json
+++ /dev/null
@@ -1,7 +0,0 @@
-{
- "domain": "dte_energy_bridge",
- "name": "DTE Energy Bridge",
- "codeowners": [],
- "documentation": "https://www.home-assistant.io/integrations/dte_energy_bridge",
- "iot_class": "local_polling"
-}
diff --git a/homeassistant/components/dte_energy_bridge/sensor.py b/homeassistant/components/dte_energy_bridge/sensor.py
deleted file mode 100644
index a0b9253034e..00000000000
--- a/homeassistant/components/dte_energy_bridge/sensor.py
+++ /dev/null
@@ -1,127 +0,0 @@
-"""Support for monitoring energy usage using the DTE energy bridge."""
-
-from __future__ import annotations
-
-from http import HTTPStatus
-import logging
-
-import requests
-import voluptuous as vol
-
-from homeassistant.components.sensor import (
- PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA,
- SensorDeviceClass,
- SensorEntity,
- SensorStateClass,
-)
-from homeassistant.const import CONF_NAME, UnitOfPower
-from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from homeassistant.helpers.issue_registry import IssueSeverity, create_issue
-from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
-
-_LOGGER = logging.getLogger(__name__)
-
-CONF_IP_ADDRESS = "ip"
-CONF_VERSION = "version"
-
-DEFAULT_NAME = "Current Energy Usage"
-DEFAULT_VERSION = 1
-DOMAIN = "dte_energy_bridge"
-
-PLATFORM_SCHEMA = SENSOR_PLATFORM_SCHEMA.extend(
- {
- vol.Required(CONF_IP_ADDRESS): cv.string,
- vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
- vol.Optional(CONF_VERSION, default=DEFAULT_VERSION): vol.All(
- vol.Coerce(int), vol.Any(1, 2)
- ),
- }
-)
-
-
-def setup_platform(
- hass: HomeAssistant,
- config: ConfigType,
- add_entities: AddEntitiesCallback,
- discovery_info: DiscoveryInfoType | None = None,
-) -> None:
- """Set up the DTE energy bridge sensor."""
- create_issue(
- hass,
- DOMAIN,
- "deprecated_integration",
- breaks_in_ha_version="2025.1.0",
- is_fixable=False,
- issue_domain=DOMAIN,
- severity=IssueSeverity.WARNING,
- translation_key="deprecated_integration",
- translation_placeholders={"domain": DOMAIN},
- )
-
- name = config[CONF_NAME]
- ip_address = config[CONF_IP_ADDRESS]
- version = config[CONF_VERSION]
-
- add_entities([DteEnergyBridgeSensor(ip_address, name, version)], True)
-
-
-class DteEnergyBridgeSensor(SensorEntity):
- """Implementation of the DTE Energy Bridge sensors."""
-
- _attr_device_class = SensorDeviceClass.POWER
- _attr_native_unit_of_measurement = UnitOfPower.KILO_WATT
- _attr_state_class = SensorStateClass.MEASUREMENT
-
- def __init__(self, ip_address, name, version):
- """Initialize the sensor."""
- self._version = version
-
- if self._version == 1:
- self._url = f"http://{ip_address}/instantaneousdemand"
- elif self._version == 2:
- self._url = f"http://{ip_address}:8888/zigbee/se/instantaneousdemand"
-
- self._attr_name = name
-
- def update(self) -> None:
- """Get the energy usage data from the DTE energy bridge."""
- try:
- response = requests.get(self._url, timeout=5)
- except (requests.exceptions.RequestException, ValueError):
- _LOGGER.warning(
- "Could not update status for DTE Energy Bridge (%s)", self._attr_name
- )
- return
-
- if response.status_code != HTTPStatus.OK:
- _LOGGER.warning(
- "Invalid status_code from DTE Energy Bridge: %s (%s)",
- response.status_code,
- self._attr_name,
- )
- return
-
- response_split = response.text.split()
-
- if len(response_split) != 2:
- _LOGGER.warning(
- 'Invalid response from DTE Energy Bridge: "%s" (%s)',
- response.text,
- self._attr_name,
- )
- return
-
- val = float(response_split[0])
-
- # A workaround for a bug in the DTE energy bridge.
- # The returned value can randomly be in W or kW. Checking for a
- # a decimal seems to be a reliable way to determine the units.
- # Limiting to version 1 because version 2 apparently always returns
- # values in the format 000000.000 kW, but the scaling is Watts
- # NOT kWatts
- if self._version == 1 and "." in response_split[0]:
- self._attr_native_value = val
- else:
- self._attr_native_value = val / 1000
diff --git a/homeassistant/components/dte_energy_bridge/strings.json b/homeassistant/components/dte_energy_bridge/strings.json
deleted file mode 100644
index f75867b8faa..00000000000
--- a/homeassistant/components/dte_energy_bridge/strings.json
+++ /dev/null
@@ -1,8 +0,0 @@
-{
- "issues": {
- "deprecated_integration": {
- "title": "The DTE Energy Bridge integration will be removed",
- "description": "The DTE Energy Bridge integration will be removed as new users can't get any supported devices, and the integration will fail as soon as a current device gets internet access.\n\n Please remove all `{domain}`platform sensors from your configuration and restart Home Assistant."
- }
- }
-}
diff --git a/homeassistant/components/dublin_bus_transport/manifest.json b/homeassistant/components/dublin_bus_transport/manifest.json
index 1866da8ed8d..3df22b0da00 100644
--- a/homeassistant/components/dublin_bus_transport/manifest.json
+++ b/homeassistant/components/dublin_bus_transport/manifest.json
@@ -3,5 +3,6 @@
"name": "Dublin Bus",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/dublin_bus_transport",
- "iot_class": "cloud_polling"
+ "iot_class": "cloud_polling",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/duckdns/manifest.json b/homeassistant/components/duckdns/manifest.json
index b14da053450..b48ed0b2394 100644
--- a/homeassistant/components/duckdns/manifest.json
+++ b/homeassistant/components/duckdns/manifest.json
@@ -3,5 +3,6 @@
"name": "Duck DNS",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/duckdns",
- "iot_class": "cloud_polling"
+ "iot_class": "cloud_polling",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/duotecno/climate.py b/homeassistant/components/duotecno/climate.py
index 77b602c8716..0355d2855d3 100644
--- a/homeassistant/components/duotecno/climate.py
+++ b/homeassistant/components/duotecno/climate.py
@@ -57,7 +57,6 @@ class DuotecnoClimate(DuotecnoEntity, ClimateEntity):
_attr_hvac_modes = list(HVACMODE_REVERSE)
_attr_preset_modes = list(PRESETMODES)
_attr_translation_key = "duotecno"
- _enable_turn_on_off_backwards_compatibility = False
@property
def current_temperature(self) -> float | None:
diff --git a/homeassistant/components/duotecno/manifest.json b/homeassistant/components/duotecno/manifest.json
index 2a427e36e84..7a79902eae3 100644
--- a/homeassistant/components/duotecno/manifest.json
+++ b/homeassistant/components/duotecno/manifest.json
@@ -6,7 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/duotecno",
"iot_class": "local_push",
"loggers": ["pyduotecno", "pyduotecno-node", "pyduotecno-unit"],
- "quality_scale": "silver",
"requirements": ["pyDuotecno==2024.10.1"],
"single_config_entry": true
}
diff --git a/homeassistant/components/dweet/manifest.json b/homeassistant/components/dweet/manifest.json
index 4badf76f2e9..b4efd0744fb 100644
--- a/homeassistant/components/dweet/manifest.json
+++ b/homeassistant/components/dweet/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/dweet",
"iot_class": "cloud_polling",
"loggers": ["dweepy"],
+ "quality_scale": "legacy",
"requirements": ["dweepy==0.3.0"]
}
diff --git a/homeassistant/components/dynalite/__init__.py b/homeassistant/components/dynalite/__init__.py
index 59b8e464bb0..7388c43cb89 100644
--- a/homeassistant/components/dynalite/__init__.py
+++ b/homeassistant/components/dynalite/__init__.py
@@ -4,21 +4,17 @@ from __future__ import annotations
import voluptuous as vol
-from homeassistant import config_entries
from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
-# Loading the config flow file will register the flow
from .bridge import DynaliteBridge
from .const import (
ATTR_AREA,
ATTR_CHANNEL,
ATTR_HOST,
- CONF_BRIDGES,
DOMAIN,
LOGGER,
PLATFORMS,
@@ -27,41 +23,14 @@ from .const import (
)
from .convert_config import convert_config
from .panel import async_register_dynalite_frontend
-from .schema import BRIDGE_SCHEMA
-CONFIG_SCHEMA = vol.Schema(
- vol.All(
- cv.deprecated(DOMAIN),
- {
- DOMAIN: vol.Schema(
- {vol.Optional(CONF_BRIDGES): vol.All(cv.ensure_list, [BRIDGE_SCHEMA])}
- ),
- },
- ),
- extra=vol.ALLOW_EXTRA,
-)
+CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Dynalite platform."""
- conf = config.get(DOMAIN, {})
- LOGGER.debug("Setting up dynalite component config = %s", conf)
hass.data[DOMAIN] = {}
- bridges = conf.get(CONF_BRIDGES, [])
-
- for bridge_conf in bridges:
- host = bridge_conf[CONF_HOST]
- LOGGER.debug("Starting config entry flow host=%s conf=%s", host, bridge_conf)
-
- hass.async_create_task(
- hass.config_entries.flow.async_init(
- DOMAIN,
- context={"source": config_entries.SOURCE_IMPORT},
- data=bridge_conf,
- )
- )
-
async def dynalite_service(service_call: ServiceCall) -> None:
data = service_call.data
host = data.get(ATTR_HOST, "")
diff --git a/homeassistant/components/dynalite/config_flow.py b/homeassistant/components/dynalite/config_flow.py
index 928f7043a49..4b111c25cc9 100644
--- a/homeassistant/components/dynalite/config_flow.py
+++ b/homeassistant/components/dynalite/config_flow.py
@@ -8,9 +8,7 @@ import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_PORT
-from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN
from homeassistant.helpers import config_validation as cv
-from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from .bridge import DynaliteBridge
from .const import DEFAULT_PORT, DOMAIN, LOGGER
@@ -26,38 +24,6 @@ class DynaliteFlowHandler(ConfigFlow, domain=DOMAIN):
"""Initialize the Dynalite flow."""
self.host = None
- async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult:
- """Import a new bridge as a config entry."""
- LOGGER.debug("Starting async_step_import (deprecated) - %s", import_data)
- # Raise an issue that this is deprecated and has been imported
- async_create_issue(
- self.hass,
- HOMEASSISTANT_DOMAIN,
- f"deprecated_yaml_{DOMAIN}",
- breaks_in_ha_version="2023.12.0",
- is_fixable=False,
- is_persistent=False,
- issue_domain=DOMAIN,
- severity=IssueSeverity.WARNING,
- translation_key="deprecated_yaml",
- translation_placeholders={
- "domain": DOMAIN,
- "integration_title": "Dynalite",
- },
- )
-
- host = import_data[CONF_HOST]
- # Check if host already exists
- for entry in self._async_current_entries():
- if entry.data[CONF_HOST] == host:
- self.hass.config_entries.async_update_entry(
- entry, data=dict(import_data)
- )
- return self.async_abort(reason="already_configured")
-
- # New entry
- return await self._try_create(import_data)
-
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
diff --git a/homeassistant/components/dynalite/const.py b/homeassistant/components/dynalite/const.py
index c1cb1a0fb1b..4712b14bea3 100644
--- a/homeassistant/components/dynalite/const.py
+++ b/homeassistant/components/dynalite/const.py
@@ -16,7 +16,6 @@ ACTIVE_OFF = "off"
ACTIVE_ON = "on"
CONF_AREA = "area"
CONF_AUTO_DISCOVER = "autodiscover"
-CONF_BRIDGES = "bridges"
CONF_CHANNEL = "channel"
CONF_CHANNEL_COVER = "channel_cover"
CONF_CLOSE_PRESET = "close"
diff --git a/homeassistant/components/easyenergy/__init__.py b/homeassistant/components/easyenergy/__init__.py
index e520631158a..0548431f09d 100644
--- a/homeassistant/components/easyenergy/__init__.py
+++ b/homeassistant/components/easyenergy/__init__.py
@@ -2,7 +2,6 @@
from __future__ import annotations
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
@@ -10,10 +9,10 @@ from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
from .const import DOMAIN
-from .coordinator import EasyEnergyDataUpdateCoordinator
+from .coordinator import EasyEnergyConfigEntry, EasyEnergyDataUpdateCoordinator
from .services import async_setup_services
-PLATFORMS = [Platform.SENSOR]
+PLATFORMS: list[Platform] = [Platform.SENSOR]
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
@@ -25,25 +24,22 @@ 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: EasyEnergyConfigEntry) -> bool:
"""Set up easyEnergy from a config entry."""
- coordinator = EasyEnergyDataUpdateCoordinator(hass)
+ coordinator = EasyEnergyDataUpdateCoordinator(hass, entry)
try:
await coordinator.async_config_entry_first_refresh()
except ConfigEntryNotReady:
await coordinator.easyenergy.close()
raise
- 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: EasyEnergyConfigEntry) -> bool:
"""Unload easyEnergy 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/easyenergy/coordinator.py b/homeassistant/components/easyenergy/coordinator.py
index 8c1c593af93..e36bdf188ee 100644
--- a/homeassistant/components/easyenergy/coordinator.py
+++ b/homeassistant/components/easyenergy/coordinator.py
@@ -21,6 +21,8 @@ from homeassistant.util import dt as dt_util
from .const import DOMAIN, LOGGER, SCAN_INTERVAL, THRESHOLD_HOUR
+type EasyEnergyConfigEntry = ConfigEntry[EasyEnergyDataUpdateCoordinator]
+
class EasyEnergyData(NamedTuple):
"""Class for defining data in dict."""
@@ -33,15 +35,16 @@ class EasyEnergyData(NamedTuple):
class EasyEnergyDataUpdateCoordinator(DataUpdateCoordinator[EasyEnergyData]):
"""Class to manage fetching easyEnergy data from single endpoint."""
- config_entry: ConfigEntry
+ config_entry: EasyEnergyConfigEntry
- def __init__(self, hass: HomeAssistant) -> None:
+ def __init__(self, hass: HomeAssistant, entry: EasyEnergyConfigEntry) -> None:
"""Initialize global easyEnergy data updater."""
super().__init__(
hass,
LOGGER,
name=DOMAIN,
update_interval=SCAN_INTERVAL,
+ config_entry=entry,
)
self.easyenergy = EasyEnergy(session=async_get_clientsession(hass))
diff --git a/homeassistant/components/easyenergy/diagnostics.py b/homeassistant/components/easyenergy/diagnostics.py
index d6912e1c926..64f30ba61fd 100644
--- a/homeassistant/components/easyenergy/diagnostics.py
+++ b/homeassistant/components/easyenergy/diagnostics.py
@@ -5,12 +5,9 @@ from __future__ import annotations
from datetime import timedelta
from typing import Any
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from . import EasyEnergyDataUpdateCoordinator
-from .const import DOMAIN
-from .coordinator import EasyEnergyData
+from .coordinator import EasyEnergyConfigEntry, EasyEnergyData
def get_gas_price(data: EasyEnergyData, hours: int) -> float | None:
@@ -32,41 +29,42 @@ def get_gas_price(data: EasyEnergyData, hours: int) -> float | None:
async def async_get_config_entry_diagnostics(
- hass: HomeAssistant, entry: ConfigEntry
+ hass: HomeAssistant, entry: EasyEnergyConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
- coordinator: EasyEnergyDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
+ coordinator_data = entry.runtime_data.data
+ energy_today = coordinator_data.energy_today
return {
"entry": {
"title": entry.title,
},
"energy_usage": {
- "current_hour_price": coordinator.data.energy_today.current_usage_price,
- "next_hour_price": coordinator.data.energy_today.price_at_time(
- coordinator.data.energy_today.utcnow() + timedelta(hours=1)
+ "current_hour_price": energy_today.current_usage_price,
+ "next_hour_price": energy_today.price_at_time(
+ energy_today.utcnow() + timedelta(hours=1)
),
- "average_price": coordinator.data.energy_today.average_usage_price,
- "max_price": coordinator.data.energy_today.extreme_usage_prices[1],
- "min_price": coordinator.data.energy_today.extreme_usage_prices[0],
- "highest_price_time": coordinator.data.energy_today.highest_usage_price_time,
- "lowest_price_time": coordinator.data.energy_today.lowest_usage_price_time,
- "percentage_of_max": coordinator.data.energy_today.pct_of_max_usage,
+ "average_price": energy_today.average_usage_price,
+ "max_price": energy_today.extreme_usage_prices[1],
+ "min_price": energy_today.extreme_usage_prices[0],
+ "highest_price_time": energy_today.highest_usage_price_time,
+ "lowest_price_time": energy_today.lowest_usage_price_time,
+ "percentage_of_max": energy_today.pct_of_max_usage,
},
"energy_return": {
- "current_hour_price": coordinator.data.energy_today.current_return_price,
- "next_hour_price": coordinator.data.energy_today.price_at_time(
- coordinator.data.energy_today.utcnow() + timedelta(hours=1), "return"
+ "current_hour_price": energy_today.current_return_price,
+ "next_hour_price": energy_today.price_at_time(
+ energy_today.utcnow() + timedelta(hours=1), "return"
),
- "average_price": coordinator.data.energy_today.average_return_price,
- "max_price": coordinator.data.energy_today.extreme_return_prices[1],
- "min_price": coordinator.data.energy_today.extreme_return_prices[0],
- "highest_price_time": coordinator.data.energy_today.highest_return_price_time,
- "lowest_price_time": coordinator.data.energy_today.lowest_return_price_time,
- "percentage_of_max": coordinator.data.energy_today.pct_of_max_return,
+ "average_price": energy_today.average_return_price,
+ "max_price": energy_today.extreme_return_prices[1],
+ "min_price": energy_today.extreme_return_prices[0],
+ "highest_price_time": energy_today.highest_return_price_time,
+ "lowest_price_time": energy_today.lowest_return_price_time,
+ "percentage_of_max": energy_today.pct_of_max_return,
},
"gas": {
- "current_hour_price": get_gas_price(coordinator.data, 0),
- "next_hour_price": get_gas_price(coordinator.data, 1),
+ "current_hour_price": get_gas_price(coordinator_data, 0),
+ "next_hour_price": get_gas_price(coordinator_data, 1),
},
}
diff --git a/homeassistant/components/easyenergy/manifest.json b/homeassistant/components/easyenergy/manifest.json
index 4d45dc2d399..5cecb1d49f6 100644
--- a/homeassistant/components/easyenergy/manifest.json
+++ b/homeassistant/components/easyenergy/manifest.json
@@ -4,7 +4,8 @@
"codeowners": ["@klaasnicolaas"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/easyenergy",
+ "integration_type": "service",
"iot_class": "cloud_polling",
- "quality_scale": "platinum",
- "requirements": ["easyenergy==2.1.2"]
+ "requirements": ["easyenergy==2.1.2"],
+ "single_config_entry": true
}
diff --git a/homeassistant/components/easyenergy/sensor.py b/homeassistant/components/easyenergy/sensor.py
index 65fe2558d46..6976a38da49 100644
--- a/homeassistant/components/easyenergy/sensor.py
+++ b/homeassistant/components/easyenergy/sensor.py
@@ -13,7 +13,6 @@ from homeassistant.components.sensor import (
SensorEntityDescription,
SensorStateClass,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CURRENCY_EURO,
PERCENTAGE,
@@ -27,7 +26,11 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, SERVICE_TYPE_DEVICE_NAMES
-from .coordinator import EasyEnergyData, EasyEnergyDataUpdateCoordinator
+from .coordinator import (
+ EasyEnergyConfigEntry,
+ EasyEnergyData,
+ EasyEnergyDataUpdateCoordinator,
+)
@dataclass(frozen=True, kw_only=True)
@@ -208,10 +211,12 @@ def get_gas_price(data: EasyEnergyData, hours: int) -> float | None:
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: EasyEnergyConfigEntry,
+ async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up easyEnergy sensors based on a config entry."""
- coordinator: EasyEnergyDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
+ coordinator = entry.runtime_data
async_add_entities(
EasyEnergySensorEntity(coordinator=coordinator, description=description)
for description in SENSORS
diff --git a/homeassistant/components/easyenergy/services.py b/homeassistant/components/easyenergy/services.py
index 5b80cfafd08..f5ee89d5325 100644
--- a/homeassistant/components/easyenergy/services.py
+++ b/homeassistant/components/easyenergy/services.py
@@ -10,7 +10,7 @@ from typing import Final
from easyenergy import Electricity, Gas, VatOption
import voluptuous as vol
-from homeassistant.config_entries import ConfigEntry, ConfigEntryState
+from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import (
HomeAssistant,
ServiceCall,
@@ -23,7 +23,7 @@ from homeassistant.helpers import selector
from homeassistant.util import dt as dt_util
from .const import DOMAIN
-from .coordinator import EasyEnergyDataUpdateCoordinator
+from .coordinator import EasyEnergyConfigEntry, EasyEnergyDataUpdateCoordinator
ATTR_CONFIG_ENTRY: Final = "config_entry"
ATTR_START: Final = "start"
@@ -86,12 +86,12 @@ def __serialize_prices(prices: list[dict[str, float | datetime]]) -> ServiceResp
}
-def __get_coordinator(
- hass: HomeAssistant, call: ServiceCall
-) -> EasyEnergyDataUpdateCoordinator:
+def __get_coordinator(call: ServiceCall) -> EasyEnergyDataUpdateCoordinator:
"""Get the coordinator from the entry."""
entry_id: str = call.data[ATTR_CONFIG_ENTRY]
- entry: ConfigEntry | None = hass.config_entries.async_get_entry(entry_id)
+ entry: EasyEnergyConfigEntry | None = call.hass.config_entries.async_get_entry(
+ entry_id
+ )
if not entry:
raise ServiceValidationError(
@@ -110,18 +110,16 @@ def __get_coordinator(
},
)
- coordinator: EasyEnergyDataUpdateCoordinator = hass.data[DOMAIN][entry_id]
- return coordinator
+ return entry.runtime_data
async def __get_prices(
call: ServiceCall,
*,
- hass: HomeAssistant,
price_type: PriceType,
) -> ServiceResponse:
"""Get prices from easyEnergy."""
- coordinator = __get_coordinator(hass, call)
+ coordinator = __get_coordinator(call)
start = __get_date(call.data.get(ATTR_START))
end = __get_date(call.data.get(ATTR_END))
@@ -157,21 +155,21 @@ def async_setup_services(hass: HomeAssistant) -> None:
hass.services.async_register(
DOMAIN,
GAS_SERVICE_NAME,
- partial(__get_prices, hass=hass, price_type=PriceType.GAS),
+ partial(__get_prices, price_type=PriceType.GAS),
schema=SERVICE_SCHEMA,
supports_response=SupportsResponse.ONLY,
)
hass.services.async_register(
DOMAIN,
ENERGY_USAGE_SERVICE_NAME,
- partial(__get_prices, hass=hass, price_type=PriceType.ENERGY_USAGE),
+ partial(__get_prices, price_type=PriceType.ENERGY_USAGE),
schema=SERVICE_SCHEMA,
supports_response=SupportsResponse.ONLY,
)
hass.services.async_register(
DOMAIN,
ENERGY_RETURN_SERVICE_NAME,
- partial(__get_prices, hass=hass, price_type=PriceType.ENERGY_RETURN),
+ partial(__get_prices, price_type=PriceType.ENERGY_RETURN),
schema=SERVICE_SCHEMA,
supports_response=SupportsResponse.ONLY,
)
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/manifest.json b/homeassistant/components/ebox/manifest.json
index 952f9dc133d..d87c85b6612 100644
--- a/homeassistant/components/ebox/manifest.json
+++ b/homeassistant/components/ebox/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/ebox",
"iot_class": "cloud_polling",
"loggers": ["pyebox"],
+ "quality_scale": "legacy",
"requirements": ["pyebox==1.1.4"]
}
diff --git a/homeassistant/components/ebusd/manifest.json b/homeassistant/components/ebusd/manifest.json
index 3ce18d6e8d3..b82e8f1b910 100644
--- a/homeassistant/components/ebusd/manifest.json
+++ b/homeassistant/components/ebusd/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/ebusd",
"iot_class": "local_polling",
"loggers": ["ebusdpy"],
+ "quality_scale": "legacy",
"requirements": ["ebusdpy==0.0.17"]
}
diff --git a/homeassistant/components/ecoal_boiler/manifest.json b/homeassistant/components/ecoal_boiler/manifest.json
index 75dc95ae121..4d8202f8fde 100644
--- a/homeassistant/components/ecoal_boiler/manifest.json
+++ b/homeassistant/components/ecoal_boiler/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/ecoal_boiler",
"iot_class": "local_polling",
"loggers": ["ecoaliface"],
+ "quality_scale": "legacy",
"requirements": ["ecoaliface==0.4.0"]
}
diff --git a/homeassistant/components/ecobee/climate.py b/homeassistant/components/ecobee/climate.py
index 6a9ec0d5db9..709926d8496 100644
--- a/homeassistant/components/ecobee/climate.py
+++ b/homeassistant/components/ecobee/climate.py
@@ -353,7 +353,6 @@ class Thermostat(ClimateEntity):
_attr_fan_modes = [FAN_AUTO, FAN_ON]
_attr_name = None
_attr_has_entity_name = True
- _enable_turn_on_off_backwards_compatibility = False
_attr_translation_key = "ecobee"
def __init__(
diff --git a/homeassistant/components/ecobee/number.py b/homeassistant/components/ecobee/number.py
index ab09407903d..ed3744bf11e 100644
--- a/homeassistant/components/ecobee/number.py
+++ b/homeassistant/components/ecobee/number.py
@@ -6,9 +6,14 @@ from collections.abc import Awaitable, Callable
from dataclasses import dataclass
import logging
-from homeassistant.components.number import NumberEntity, NumberEntityDescription
+from homeassistant.components.number import (
+ NumberDeviceClass,
+ NumberEntity,
+ NumberEntityDescription,
+ NumberMode,
+)
from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import UnitOfTime
+from homeassistant.const import UnitOfTemperature, UnitOfTime
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
@@ -54,21 +59,30 @@ async def async_setup_entry(
) -> None:
"""Set up the ecobee thermostat number entity."""
data: EcobeeData = hass.data[DOMAIN]
- _LOGGER.debug("Adding min time ventilators numbers (if present)")
- async_add_entities(
+ assert data is not None
+
+ entities: list[NumberEntity] = [
+ EcobeeVentilatorMinTime(data, index, numbers)
+ for index, thermostat in enumerate(data.ecobee.thermostats)
+ if thermostat["settings"]["ventilatorType"] != "none"
+ for numbers in VENTILATOR_NUMBERS
+ ]
+
+ _LOGGER.debug("Adding compressor min temp number (if present)")
+ entities.extend(
(
- EcobeeVentilatorMinTime(data, index, numbers)
+ EcobeeCompressorMinTemp(data, index)
for index, thermostat in enumerate(data.ecobee.thermostats)
- if thermostat["settings"]["ventilatorType"] != "none"
- for numbers in VENTILATOR_NUMBERS
- ),
- True,
+ if thermostat["settings"]["hasHeatPump"]
+ )
)
+ async_add_entities(entities, True)
+
class EcobeeVentilatorMinTime(EcobeeBaseEntity, NumberEntity):
- """A number class, representing min time for an ecobee thermostat with ventilator attached."""
+ """A number class, representing min time for an ecobee thermostat with ventilator attached."""
entity_description: EcobeeNumberEntityDescription
@@ -105,3 +119,53 @@ class EcobeeVentilatorMinTime(EcobeeBaseEntity, NumberEntity):
"""Set new ventilator Min On Time value."""
self.entity_description.set_fn(self.data, self.thermostat_index, int(value))
self.update_without_throttle = True
+
+
+class EcobeeCompressorMinTemp(EcobeeBaseEntity, NumberEntity):
+ """Minimum outdoor temperature at which the compressor will operate.
+
+ This applies more to air source heat pumps than geothermal. This serves as a safety
+ feature (compressors have a minimum operating temperature) as well as
+ providing the ability to choose fuel in a dual-fuel system (i.e. choose between
+ electrical heat pump and fossil auxiliary heat depending on Time of Use, Solar,
+ etc.).
+ Note that python-ecobee-api refers to this as Aux Cutover Threshold, but Ecobee
+ uses Compressor Protection Min Temp.
+ """
+
+ _attr_device_class = NumberDeviceClass.TEMPERATURE
+ _attr_has_entity_name = True
+ _attr_icon = "mdi:thermometer-off"
+ _attr_mode = NumberMode.BOX
+ _attr_native_min_value = -25
+ _attr_native_max_value = 66
+ _attr_native_step = 5
+ _attr_native_unit_of_measurement = UnitOfTemperature.FAHRENHEIT
+ _attr_translation_key = "compressor_protection_min_temp"
+
+ def __init__(
+ self,
+ data: EcobeeData,
+ thermostat_index: int,
+ ) -> None:
+ """Initialize ecobee compressor min temperature."""
+ super().__init__(data, thermostat_index)
+ self._attr_unique_id = f"{self.base_unique_id}_compressor_protection_min_temp"
+ self.update_without_throttle = False
+
+ async def async_update(self) -> None:
+ """Get the latest state from the thermostat."""
+ if self.update_without_throttle:
+ await self.data.update(no_throttle=True)
+ self.update_without_throttle = False
+ else:
+ await self.data.update()
+
+ self._attr_native_value = (
+ (self.thermostat["settings"]["compressorProtectionMinTemp"]) / 10
+ )
+
+ def set_native_value(self, value: float) -> None:
+ """Set new compressor minimum temperature."""
+ self.data.ecobee.set_aux_cutover_threshold(self.thermostat_index, value)
+ self.update_without_throttle = True
diff --git a/homeassistant/components/ecobee/strings.json b/homeassistant/components/ecobee/strings.json
index 18929cb45de..8c636bd9b04 100644
--- a/homeassistant/components/ecobee/strings.json
+++ b/homeassistant/components/ecobee/strings.json
@@ -33,15 +33,18 @@
},
"number": {
"ventilator_min_type_home": {
- "name": "Ventilator min time home"
+ "name": "Ventilator minimum time home"
},
"ventilator_min_type_away": {
- "name": "Ventilator min time away"
+ "name": "Ventilator minimum time away"
+ },
+ "compressor_protection_min_temp": {
+ "name": "Compressor minimum temperature"
}
},
"switch": {
"aux_heat_only": {
- "name": "Aux heat only"
+ "name": "Auxiliary heat only"
}
}
},
diff --git a/homeassistant/components/econet/climate.py b/homeassistant/components/econet/climate.py
index bac123bf206..cdf82f6817f 100644
--- a/homeassistant/components/econet/climate.py
+++ b/homeassistant/components/econet/climate.py
@@ -68,7 +68,6 @@ class EcoNetThermostat(EcoNetEntity, ClimateEntity):
_attr_should_poll = True
_attr_temperature_unit = UnitOfTemperature.FAHRENHEIT
- _enable_turn_on_off_backwards_compatibility = False
def __init__(self, thermostat):
"""Initialize."""
diff --git a/homeassistant/components/ecovacs/button.py b/homeassistant/components/ecovacs/button.py
index 5d76b38bed8..2759ca972df 100644
--- a/homeassistant/components/ecovacs/button.py
+++ b/homeassistant/components/ecovacs/button.py
@@ -2,7 +2,12 @@
from dataclasses import dataclass
-from deebot_client.capabilities import CapabilityExecute, CapabilityLifeSpan
+from deebot_client.capabilities import (
+ CapabilityExecute,
+ CapabilityExecuteTypes,
+ CapabilityLifeSpan,
+)
+from deebot_client.commands import StationAction
from deebot_client.events import LifeSpan
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
@@ -11,7 +16,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import EcovacsConfigEntry
-from .const import SUPPORTED_LIFESPANS
+from .const import SUPPORTED_LIFESPANS, SUPPORTED_STATION_ACTIONS
from .entity import (
EcovacsCapabilityEntityDescription,
EcovacsDescriptionEntity,
@@ -35,6 +40,13 @@ class EcovacsLifespanButtonEntityDescription(ButtonEntityDescription):
component: LifeSpan
+@dataclass(kw_only=True, frozen=True)
+class EcovacsStationActionButtonEntityDescription(ButtonEntityDescription):
+ """Ecovacs station action button entity description."""
+
+ action: StationAction
+
+
ENTITY_DESCRIPTIONS: tuple[EcovacsButtonEntityDescription, ...] = (
EcovacsButtonEntityDescription(
capability_fn=lambda caps: caps.map.relocation if caps.map else None,
@@ -44,6 +56,16 @@ ENTITY_DESCRIPTIONS: tuple[EcovacsButtonEntityDescription, ...] = (
),
)
+STATION_ENTITY_DESCRIPTIONS = tuple(
+ EcovacsStationActionButtonEntityDescription(
+ action=action,
+ key=f"station_action_{action.name.lower()}",
+ translation_key=f"station_action_{action.name.lower()}",
+ )
+ for action in SUPPORTED_STATION_ACTIONS
+)
+
+
LIFESPAN_ENTITY_DESCRIPTIONS = tuple(
EcovacsLifespanButtonEntityDescription(
component=component,
@@ -74,6 +96,15 @@ async def async_setup_entry(
for description in LIFESPAN_ENTITY_DESCRIPTIONS
if description.component in device.capabilities.life_span.types
)
+ entities.extend(
+ EcovacsStationActionButtonEntity(
+ device, device.capabilities.station.action, description
+ )
+ for device in controller.devices
+ if device.capabilities.station
+ for description in STATION_ENTITY_DESCRIPTIONS
+ if description.action in device.capabilities.station.action.types
+ )
async_add_entities(entities)
@@ -103,3 +134,18 @@ class EcovacsResetLifespanButtonEntity(
await self._device.execute_command(
self._capability.reset(self.entity_description.component)
)
+
+
+class EcovacsStationActionButtonEntity(
+ EcovacsDescriptionEntity[CapabilityExecuteTypes[StationAction]],
+ ButtonEntity,
+):
+ """Ecovacs station action button entity."""
+
+ entity_description: EcovacsStationActionButtonEntityDescription
+
+ async def async_press(self) -> None:
+ """Press the button."""
+ await self._device.execute_command(
+ self._capability.execute(self.entity_description.action)
+ )
diff --git a/homeassistant/components/ecovacs/const.py b/homeassistant/components/ecovacs/const.py
index ac7a268f1bd..0bfe9cfd544 100644
--- a/homeassistant/components/ecovacs/const.py
+++ b/homeassistant/components/ecovacs/const.py
@@ -2,6 +2,7 @@
from enum import StrEnum
+from deebot_client.commands import StationAction
from deebot_client.events import LifeSpan
DOMAIN = "ecovacs"
@@ -19,8 +20,11 @@ SUPPORTED_LIFESPANS = (
LifeSpan.SIDE_BRUSH,
LifeSpan.UNIT_CARE,
LifeSpan.ROUND_MOP,
+ LifeSpan.STATION_FILTER,
)
+SUPPORTED_STATION_ACTIONS = (StationAction.EMPTY_DUSTBIN,)
+
LEGACY_SUPPORTED_LIFESPANS = (
"main_brush",
"side_brush",
diff --git a/homeassistant/components/ecovacs/controller.py b/homeassistant/components/ecovacs/controller.py
index ec67845cf9f..69dd0f0813f 100644
--- a/homeassistant/components/ecovacs/controller.py
+++ b/homeassistant/components/ecovacs/controller.py
@@ -13,7 +13,6 @@ from deebot_client.authentication import Authenticator, create_rest_config
from deebot_client.const import UNDEFINED, UndefinedType
from deebot_client.device import Device
from deebot_client.exceptions import DeebotError, InvalidAuthenticationError
-from deebot_client.models import DeviceInfo
from deebot_client.mqtt_client import MqttClient, create_mqtt_config
from deebot_client.util import md5
from deebot_client.util.continents import get_continent
@@ -81,25 +80,32 @@ class EcovacsController:
try:
devices = await self._api_client.get_devices()
credentials = await self._authenticator.authenticate()
- for device_config in devices:
- if isinstance(device_config, DeviceInfo):
- # MQTT device
- device = Device(device_config, self._authenticator)
- mqtt = await self._get_mqtt_client()
- await device.initialize(mqtt)
- self._devices.append(device)
- else:
- # Legacy device
- bot = VacBot(
- credentials.user_id,
- EcoVacsAPI.REALM,
- self._device_id[0:8],
- credentials.token,
- device_config,
- self._continent,
- monitor=True,
- )
- self._legacy_devices.append(bot)
+ for device_info in devices.mqtt:
+ device = Device(device_info, self._authenticator)
+ mqtt = await self._get_mqtt_client()
+ await device.initialize(mqtt)
+ self._devices.append(device)
+ for device_config in devices.xmpp:
+ bot = VacBot(
+ credentials.user_id,
+ EcoVacsAPI.REALM,
+ self._device_id[0:8],
+ credentials.token,
+ device_config,
+ self._continent,
+ monitor=True,
+ )
+ self._legacy_devices.append(bot)
+ for device_config in devices.not_supported:
+ _LOGGER.warning(
+ (
+ 'Device "%s" not supported. More information at '
+ "https://github.com/DeebotUniverse/client.py/issues/612: %s"
+ ),
+ device_config["deviceName"],
+ device_config,
+ )
+
except InvalidAuthenticationError as ex:
raise ConfigEntryError("Invalid credentials") from ex
except DeebotError as ex:
diff --git a/homeassistant/components/ecovacs/icons.json b/homeassistant/components/ecovacs/icons.json
index 6097f43a4e4..b0e2a0595bf 100644
--- a/homeassistant/components/ecovacs/icons.json
+++ b/homeassistant/components/ecovacs/icons.json
@@ -27,11 +27,17 @@
"reset_lifespan_side_brush": {
"default": "mdi:broom"
},
+ "reset_lifespan_station_filter": {
+ "default": "mdi:air-filter"
+ },
"reset_lifespan_unit_care": {
"default": "mdi:robot-vacuum"
},
"reset_lifespan_round_mop": {
"default": "mdi:broom"
+ },
+ "station_action_empty_dustbin": {
+ "default": "mdi:delete-restore"
}
},
"event": {
@@ -72,6 +78,9 @@
"lifespan_side_brush": {
"default": "mdi:broom"
},
+ "lifespan_station_filter": {
+ "default": "mdi:air-filter"
+ },
"lifespan_unit_care": {
"default": "mdi:robot-vacuum"
},
@@ -87,6 +96,9 @@
"network_ssid": {
"default": "mdi:wifi"
},
+ "station_state": {
+ "default": "mdi:home"
+ },
"stats_area": {
"default": "mdi:floor-plan"
},
diff --git a/homeassistant/components/ecovacs/manifest.json b/homeassistant/components/ecovacs/manifest.json
index 33977b3b0de..67d18c4784c 100644
--- a/homeassistant/components/ecovacs/manifest.json
+++ b/homeassistant/components/ecovacs/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/ecovacs",
"iot_class": "cloud_push",
"loggers": ["sleekxmppfs", "sucks", "deebot_client"],
- "requirements": ["py-sucks==0.9.10", "deebot-client==8.4.0"]
+ "requirements": ["py-sucks==0.9.10", "deebot-client==10.1.0"]
}
diff --git a/homeassistant/components/ecovacs/number.py b/homeassistant/components/ecovacs/number.py
index 2b9bdc1a425..adf282560a9 100644
--- a/homeassistant/components/ecovacs/number.py
+++ b/homeassistant/components/ecovacs/number.py
@@ -95,7 +95,7 @@ async def async_setup_entry(
class EcovacsNumberEntity(
- EcovacsDescriptionEntity[CapabilitySet[EventT, int]],
+ EcovacsDescriptionEntity[CapabilitySet[EventT, [int]]],
NumberEntity,
):
"""Ecovacs number entity."""
diff --git a/homeassistant/components/ecovacs/select.py b/homeassistant/components/ecovacs/select.py
index c8b01a0f83a..3c3852f05ec 100644
--- a/homeassistant/components/ecovacs/select.py
+++ b/homeassistant/components/ecovacs/select.py
@@ -66,7 +66,7 @@ async def async_setup_entry(
class EcovacsSelectEntity(
- EcovacsDescriptionEntity[CapabilitySetTypes[EventT, str]],
+ EcovacsDescriptionEntity[CapabilitySetTypes[EventT, [str], str]],
SelectEntity,
):
"""Ecovacs select entity."""
@@ -77,7 +77,7 @@ class EcovacsSelectEntity(
def __init__(
self,
device: Device,
- capability: CapabilitySetTypes[EventT, str],
+ capability: CapabilitySetTypes[EventT, [str], str],
entity_description: EcovacsSelectEntityDescription,
**kwargs: Any,
) -> None:
diff --git a/homeassistant/components/ecovacs/sensor.py b/homeassistant/components/ecovacs/sensor.py
index 28c4efbd0c6..0e906c6cb16 100644
--- a/homeassistant/components/ecovacs/sensor.py
+++ b/homeassistant/components/ecovacs/sensor.py
@@ -16,6 +16,7 @@ from deebot_client.events import (
NetworkInfoEvent,
StatsEvent,
TotalStatsEvent,
+ station,
)
from sucks import VacBot
@@ -26,11 +27,11 @@ from homeassistant.components.sensor import (
SensorStateClass,
)
from homeassistant.const import (
- AREA_SQUARE_METERS,
ATTR_BATTERY_LEVEL,
CONF_DESCRIPTION,
PERCENTAGE,
EntityCategory,
+ UnitOfArea,
UnitOfTime,
)
from homeassistant.core import HomeAssistant
@@ -46,7 +47,7 @@ from .entity import (
EcovacsLegacyEntity,
EventT,
)
-from .util import get_supported_entitites
+from .util import get_name_key, get_options, get_supported_entitites
@dataclass(kw_only=True, frozen=True)
@@ -67,7 +68,7 @@ ENTITY_DESCRIPTIONS: tuple[EcovacsSensorEntityDescription, ...] = (
capability_fn=lambda caps: caps.stats.clean,
value_fn=lambda e: e.area,
translation_key="stats_area",
- native_unit_of_measurement=AREA_SQUARE_METERS,
+ native_unit_of_measurement=UnitOfArea.SQUARE_METERS,
),
EcovacsSensorEntityDescription[StatsEvent](
key="stats_time",
@@ -84,7 +85,7 @@ ENTITY_DESCRIPTIONS: tuple[EcovacsSensorEntityDescription, ...] = (
value_fn=lambda e: e.area,
key="total_stats_area",
translation_key="total_stats_area",
- native_unit_of_measurement=AREA_SQUARE_METERS,
+ native_unit_of_measurement=UnitOfArea.SQUARE_METERS,
state_class=SensorStateClass.TOTAL_INCREASING,
),
EcovacsSensorEntityDescription[TotalStatsEvent](
@@ -136,6 +137,15 @@ ENTITY_DESCRIPTIONS: tuple[EcovacsSensorEntityDescription, ...] = (
entity_registry_enabled_default=False,
entity_category=EntityCategory.DIAGNOSTIC,
),
+ # Station
+ EcovacsSensorEntityDescription[station.StationEvent](
+ capability_fn=lambda caps: caps.station.state if caps.station else None,
+ value_fn=lambda e: get_name_key(e.state),
+ key="station_state",
+ translation_key="station_state",
+ device_class=SensorDeviceClass.ENUM,
+ options=get_options(station.State),
+ ),
)
diff --git a/homeassistant/components/ecovacs/strings.json b/homeassistant/components/ecovacs/strings.json
index c9de461ad5b..723bdef17f8 100644
--- a/homeassistant/components/ecovacs/strings.json
+++ b/homeassistant/components/ecovacs/strings.json
@@ -46,6 +46,9 @@
"relocate": {
"name": "Relocate"
},
+ "reset_lifespan_base_station_filter": {
+ "name": "Reset station filter lifespan"
+ },
"reset_lifespan_blade": {
"name": "Reset blade lifespan"
},
@@ -66,6 +69,9 @@
},
"reset_lifespan_side_brush": {
"name": "Reset side brush lifespan"
+ },
+ "station_action_empty_dustbin": {
+ "name": "Empty dustbin"
}
},
"event": {
@@ -107,6 +113,9 @@
}
}
},
+ "lifespan_base_station_filter": {
+ "name": "Station filter lifespan"
+ },
"lifespan_blade": {
"name": "Blade lifespan"
},
@@ -140,6 +149,13 @@
"network_ssid": {
"name": "Wi-Fi SSID"
},
+ "station_state": {
+ "name": "Station state",
+ "state": {
+ "idle": "[%key:common::state::idle%]",
+ "emptying_dustbin": "Emptying dustbin"
+ }
+ },
"stats_area": {
"name": "Area cleaned"
},
diff --git a/homeassistant/components/ecovacs/switch.py b/homeassistant/components/ecovacs/switch.py
index 872981b5c28..288d092d391 100644
--- a/homeassistant/components/ecovacs/switch.py
+++ b/homeassistant/components/ecovacs/switch.py
@@ -131,7 +131,7 @@ class EcovacsSwitchEntity(
await super().async_added_to_hass()
async def on_event(event: EnableEvent) -> None:
- self._attr_is_on = event.enable
+ self._attr_is_on = event.enabled
self.async_write_ha_state()
self._subscribe(self._capability.event, on_event)
diff --git a/homeassistant/components/ecovacs/util.py b/homeassistant/components/ecovacs/util.py
index a4894de8968..0cfbf1e8f91 100644
--- a/homeassistant/components/ecovacs/util.py
+++ b/homeassistant/components/ecovacs/util.py
@@ -7,6 +7,8 @@ import random
import string
from typing import TYPE_CHECKING
+from deebot_client.events.station import State
+
from homeassistant.core import HomeAssistant, callback
from homeassistant.util import slugify
@@ -47,4 +49,13 @@ def get_supported_entitites(
@callback
def get_name_key(enum: Enum) -> str:
"""Return the lower case name of the enum."""
+ if enum is State.EMPTYING:
+ # Will be fixed in the next major release of deebot-client
+ return "emptying_dustbin"
return enum.name.lower()
+
+
+@callback
+def get_options(enum: type[Enum]) -> list[str]:
+ """Return the options for the enum."""
+ return [get_name_key(option) for option in enum]
diff --git a/homeassistant/components/ecovacs/vacuum.py b/homeassistant/components/ecovacs/vacuum.py
index 0d14267e08d..bc78981d1db 100644
--- a/homeassistant/components/ecovacs/vacuum.py
+++ b/homeassistant/components/ecovacs/vacuum.py
@@ -13,14 +13,9 @@ from deebot_client.models import CleanAction, CleanMode, Room, State
import sucks
from homeassistant.components.vacuum import (
- STATE_CLEANING,
- STATE_DOCKED,
- STATE_ERROR,
- STATE_IDLE,
- STATE_PAUSED,
- STATE_RETURNING,
StateVacuumEntity,
StateVacuumEntityDescription,
+ VacuumActivity,
VacuumEntityFeature,
)
from homeassistant.core import HomeAssistant, SupportsResponse
@@ -123,22 +118,22 @@ class EcovacsLegacyVacuum(EcovacsLegacyEntity, StateVacuumEntity):
self.schedule_update_ha_state()
@property
- def state(self) -> str | None:
+ def activity(self) -> VacuumActivity | None:
"""Return the state of the vacuum cleaner."""
if self.error is not None:
- return STATE_ERROR
+ return VacuumActivity.ERROR
if self.device.is_cleaning:
- return STATE_CLEANING
+ return VacuumActivity.CLEANING
if self.device.is_charging:
- return STATE_DOCKED
+ return VacuumActivity.DOCKED
if self.device.vacuum_status == sucks.CLEAN_MODE_STOP:
- return STATE_IDLE
+ return VacuumActivity.IDLE
if self.device.vacuum_status == sucks.CHARGE_MODE_RETURNING:
- return STATE_RETURNING
+ return VacuumActivity.RETURNING
return None
@@ -168,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:
@@ -202,7 +192,7 @@ class EcovacsLegacyVacuum(EcovacsLegacyEntity, StateVacuumEntity):
def set_fan_speed(self, fan_speed: str, **kwargs: Any) -> None:
"""Set fan speed."""
- if self.state == STATE_CLEANING:
+ if self.state == VacuumActivity.CLEANING:
self.device.run(sucks.Clean(mode=self.device.clean_status, speed=fan_speed))
def send_command(
@@ -225,12 +215,12 @@ class EcovacsLegacyVacuum(EcovacsLegacyEntity, StateVacuumEntity):
_STATE_TO_VACUUM_STATE = {
- State.IDLE: STATE_IDLE,
- State.CLEANING: STATE_CLEANING,
- State.RETURNING: STATE_RETURNING,
- State.DOCKED: STATE_DOCKED,
- State.ERROR: STATE_ERROR,
- State.PAUSED: STATE_PAUSED,
+ State.IDLE: VacuumActivity.IDLE,
+ State.CLEANING: VacuumActivity.CLEANING,
+ State.RETURNING: VacuumActivity.RETURNING,
+ State.DOCKED: VacuumActivity.DOCKED,
+ State.ERROR: VacuumActivity.ERROR,
+ State.PAUSED: VacuumActivity.PAUSED,
}
_ATTR_ROOMS = "rooms"
@@ -284,7 +274,7 @@ class EcovacsVacuum(
self.async_write_ha_state()
async def on_status(event: StateEvent) -> None:
- self._attr_state = _STATE_TO_VACUUM_STATE[event.state]
+ self._attr_activity = _STATE_TO_VACUUM_STATE[event.state]
self.async_write_ha_state()
self._subscribe(self._capability.battery.event, on_battery)
diff --git a/homeassistant/components/ecowitt/strings.json b/homeassistant/components/ecowitt/strings.json
index 95fcc3c3bb0..aaacb5e03dd 100644
--- a/homeassistant/components/ecowitt/strings.json
+++ b/homeassistant/components/ecowitt/strings.json
@@ -6,7 +6,7 @@
}
},
"create_entry": {
- "default": "To finish setting up the integration, use the Ecowitt App (on your phone) or access the Ecowitt WebUI in a browser at the station IP address.\n\nPick your station -> Menu Others -> DIY Upload Servers. Hit next and select 'Customized'\n\n- Server IP: `{server}`\n- Path: `{path}`\n- Port: `{port}`\n\nSelect **Save**."
+ "default": "To finish setting up the integration, you need to tell the Ecowitt station to send data to Home Assistant at the following address:\n\n- Server IP / Host Name: `{server}`\n- Path: `{path}`\n- Port: `{port}`\n\nYou can access the Ecowitt configuration in one of two ways:\n\n1. Use the Ecowitt App (on your phone):\n - Select the Menu Icon (☰) on the upper left, then **My Devices** → **Pick your station**\n - Select the Ellipsis Icon (⋯) → **Others**\n - Select **DIY Upload Servers** → **Customized**\n - Make sure to choose 'Protocol Type Same As: Ecowitt'\n - Enter the Server IP / Host Name, Path, and Port (printed above). _Note: The path has to match! Remove the first forward slash from the path, as the app will prepend one._\n - Save\n1. Navigate to the Ecowitt web UI in a browser at the station IP address:\n - Select **Weather Services** then scroll down to 'Customized'\n - Make sure to select 'Customized: 🔘 Enable' and 'Protocol Type Same As: 🔘 Ecowitt'\n - Enter the Server IP / Host Name, Path, and Port (printed above).\n - Save"
}
}
}
diff --git a/homeassistant/components/eddystone_temperature/manifest.json b/homeassistant/components/eddystone_temperature/manifest.json
index b15a88d099f..18e67f55667 100644
--- a/homeassistant/components/eddystone_temperature/manifest.json
+++ b/homeassistant/components/eddystone_temperature/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/eddystone_temperature",
"iot_class": "local_polling",
"loggers": ["beacontools"],
+ "quality_scale": "legacy",
"requirements": ["beacontools[scan]==2.1.0"]
}
diff --git a/homeassistant/components/edimax/manifest.json b/homeassistant/components/edimax/manifest.json
index f104ec40e64..a226ef3bbe8 100644
--- a/homeassistant/components/edimax/manifest.json
+++ b/homeassistant/components/edimax/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/edimax",
"iot_class": "local_polling",
"loggers": ["pyedimax"],
+ "quality_scale": "legacy",
"requirements": ["pyedimax==0.2.1"]
}
diff --git a/homeassistant/components/egardia/manifest.json b/homeassistant/components/egardia/manifest.json
index 99f39c99cbc..08eb82df0e7 100644
--- a/homeassistant/components/egardia/manifest.json
+++ b/homeassistant/components/egardia/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/egardia",
"iot_class": "local_polling",
"loggers": ["pythonegardia"],
+ "quality_scale": "legacy",
"requirements": ["pythonegardia==1.0.52"]
}
diff --git a/homeassistant/components/eheimdigital/__init__.py b/homeassistant/components/eheimdigital/__init__.py
new file mode 100644
index 00000000000..cf08f45bed5
--- /dev/null
+++ b/homeassistant/components/eheimdigital/__init__.py
@@ -0,0 +1,51 @@
+"""The EHEIM Digital integration."""
+
+from __future__ import annotations
+
+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 .coordinator import EheimDigitalUpdateCoordinator
+
+PLATFORMS = [Platform.LIGHT]
+
+type EheimDigitalConfigEntry = ConfigEntry[EheimDigitalUpdateCoordinator]
+
+
+async def async_setup_entry(
+ hass: HomeAssistant, entry: EheimDigitalConfigEntry
+) -> bool:
+ """Set up EHEIM Digital from a config entry."""
+
+ coordinator = EheimDigitalUpdateCoordinator(hass)
+ 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: EheimDigitalConfigEntry
+) -> bool:
+ """Unload a config entry."""
+ await entry.runtime_data.hub.close()
+ return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
+
+
+async def async_remove_config_entry_device(
+ hass: HomeAssistant,
+ config_entry: EheimDigitalConfigEntry,
+ device_entry: DeviceEntry,
+) -> bool:
+ """Remove a config entry from a device."""
+ return not any(
+ identifier
+ for identifier in device_entry.identifiers
+ if identifier[0] == DOMAIN
+ and identifier[1] in config_entry.runtime_data.hub.devices
+ )
diff --git a/homeassistant/components/eheimdigital/config_flow.py b/homeassistant/components/eheimdigital/config_flow.py
new file mode 100644
index 00000000000..6994c6f65b5
--- /dev/null
+++ b/homeassistant/components/eheimdigital/config_flow.py
@@ -0,0 +1,127 @@
+"""Config flow for EHEIM Digital."""
+
+from __future__ import annotations
+
+import asyncio
+from typing import TYPE_CHECKING, Any
+
+from aiohttp import ClientError
+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 .const import DOMAIN, LOGGER
+
+CONFIG_SCHEMA = vol.Schema(
+ {vol.Required(CONF_HOST, default="eheimdigital.local"): selector.TextSelector()}
+)
+
+
+class EheimDigitalConfigFlow(ConfigFlow, domain=DOMAIN):
+ """The EHEIM Digital config flow."""
+
+ def __init__(self) -> None:
+ """Initialize the config flow."""
+ super().__init__()
+ self.data: dict[str, Any] = {}
+ self.main_device_added_event = asyncio.Event()
+
+ async def async_step_zeroconf(
+ self, discovery_info: ZeroconfServiceInfo
+ ) -> ConfigFlowResult:
+ """Handle zeroconf discovery."""
+ self.data[CONF_HOST] = host = discovery_info.host
+
+ self._async_abort_entries_match(self.data)
+
+ hub = EheimDigitalHub(
+ host=host,
+ session=async_get_clientsession(self.hass),
+ loop=self.hass.loop,
+ main_device_added_event=self.main_device_added_event,
+ )
+ try:
+ await hub.connect()
+
+ async with asyncio.timeout(2):
+ # This event gets triggered when the first message is received from
+ # the device, it contains the data necessary to create the main device.
+ # This removes the race condition where the main device is accessed
+ # before the response from the device is parsed.
+ await self.main_device_added_event.wait()
+ if TYPE_CHECKING:
+ # At this point the main device is always set
+ assert isinstance(hub.main, EheimDigitalDevice)
+ await hub.close()
+ except (ClientError, TimeoutError):
+ return self.async_abort(reason="cannot_connect")
+ except Exception: # noqa: BLE001
+ return self.async_abort(reason="unknown")
+ await self.async_set_unique_id(hub.main.mac_address)
+ self._abort_if_unique_id_configured(updates={CONF_HOST: host})
+ 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_HOST],
+ data={CONF_HOST: self.data[CONF_HOST]},
+ )
+
+ self._set_confirm_only()
+ return self.async_show_form(step_id="discovery_confirm")
+
+ async def async_step_user(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Handle the user step."""
+ if user_input is None:
+ return self.async_show_form(step_id=SOURCE_USER, data_schema=CONFIG_SCHEMA)
+
+ self._async_abort_entries_match(user_input)
+ errors: dict[str, str] = {}
+ hub = EheimDigitalHub(
+ host=user_input[CONF_HOST],
+ session=async_get_clientsession(self.hass),
+ loop=self.hass.loop,
+ main_device_added_event=self.main_device_added_event,
+ )
+
+ try:
+ await hub.connect()
+
+ async with asyncio.timeout(2):
+ # This event gets triggered when the first message is received from
+ # the device, it contains the data necessary to create the main device.
+ # This removes the race condition where the main device is accessed
+ # before the response from the device is parsed.
+ await self.main_device_added_event.wait()
+ if TYPE_CHECKING:
+ # At this point the main device is always set
+ assert isinstance(hub.main, EheimDigitalDevice)
+ await self.async_set_unique_id(
+ hub.main.mac_address, raise_on_progress=False
+ )
+ await hub.close()
+ except (ClientError, TimeoutError):
+ errors["base"] = "cannot_connect"
+ except Exception: # noqa: BLE001
+ errors["base"] = "unknown"
+ LOGGER.exception("Unknown exception occurred")
+ else:
+ self._abort_if_unique_id_configured()
+ return self.async_create_entry(data=user_input, title=user_input[CONF_HOST])
+ return self.async_show_form(
+ step_id=SOURCE_USER,
+ data_schema=CONFIG_SCHEMA,
+ errors=errors,
+ )
diff --git a/homeassistant/components/eheimdigital/const.py b/homeassistant/components/eheimdigital/const.py
new file mode 100644
index 00000000000..5ed9303be40
--- /dev/null
+++ b/homeassistant/components/eheimdigital/const.py
@@ -0,0 +1,17 @@
+"""Constants for the EHEIM Digital integration."""
+
+from logging import Logger, getLogger
+
+from eheimdigital.types import LightMode
+
+from homeassistant.components.light import EFFECT_OFF
+
+LOGGER: Logger = getLogger(__package__)
+DOMAIN = "eheimdigital"
+
+EFFECT_DAYCL_MODE = "daycl_mode"
+
+EFFECT_TO_LIGHT_MODE = {
+ EFFECT_DAYCL_MODE: LightMode.DAYCL_MODE,
+ EFFECT_OFF: LightMode.MAN_MODE,
+}
diff --git a/homeassistant/components/eheimdigital/coordinator.py b/homeassistant/components/eheimdigital/coordinator.py
new file mode 100644
index 00000000000..f122a1227c5
--- /dev/null
+++ b/homeassistant/components/eheimdigital/coordinator.py
@@ -0,0 +1,78 @@
+"""Data update coordinator for the EHEIM Digital integration."""
+
+from __future__ import annotations
+
+from collections.abc import Callable, Coroutine
+from typing import Any
+
+from aiohttp import ClientError
+from eheimdigital.device import EheimDigitalDevice
+from eheimdigital.hub import EheimDigitalHub
+from eheimdigital.types import EheimDeviceType
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import CONF_HOST
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.helpers.entity_component import DEFAULT_SCAN_INTERVAL
+from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
+
+from .const import DOMAIN, LOGGER
+
+type AsyncSetupDeviceEntitiesCallback = Callable[[str], Coroutine[Any, Any, None]]
+
+
+class EheimDigitalUpdateCoordinator(
+ DataUpdateCoordinator[dict[str, EheimDigitalDevice]]
+):
+ """The EHEIM Digital data update coordinator."""
+
+ config_entry: ConfigEntry
+
+ def __init__(self, hass: HomeAssistant) -> None:
+ """Initialize the EHEIM Digital data update coordinator."""
+ super().__init__(
+ hass, LOGGER, name=DOMAIN, update_interval=DEFAULT_SCAN_INTERVAL
+ )
+ self.hub = EheimDigitalHub(
+ host=self.config_entry.data[CONF_HOST],
+ session=async_get_clientsession(hass),
+ loop=hass.loop,
+ receive_callback=self._async_receive_callback,
+ device_found_callback=self._async_device_found,
+ )
+ self.known_devices: set[str] = set()
+ self.platform_callbacks: set[AsyncSetupDeviceEntitiesCallback] = set()
+
+ def add_platform_callback(
+ self,
+ async_setup_device_entities: AsyncSetupDeviceEntitiesCallback,
+ ) -> None:
+ """Add the setup callbacks from a specific platform."""
+ self.platform_callbacks.add(async_setup_device_entities)
+
+ async def _async_device_found(
+ self, device_address: str, device_type: EheimDeviceType
+ ) -> None:
+ """Set up a new device found.
+
+ This function is called from the library whenever a new device is added.
+ """
+
+ if device_address not in self.known_devices:
+ for platform_callback in self.platform_callbacks:
+ await platform_callback(device_address)
+
+ async def _async_receive_callback(self) -> None:
+ self.async_set_updated_data(self.hub.devices)
+
+ async def _async_setup(self) -> None:
+ await self.hub.connect()
+ await self.hub.update()
+
+ async def _async_update_data(self) -> dict[str, EheimDigitalDevice]:
+ try:
+ await self.hub.update()
+ except ClientError as ex:
+ raise UpdateFailed from ex
+ return self.data
diff --git a/homeassistant/components/eheimdigital/entity.py b/homeassistant/components/eheimdigital/entity.py
new file mode 100644
index 00000000000..c0f91a4b798
--- /dev/null
+++ b/homeassistant/components/eheimdigital/entity.py
@@ -0,0 +1,53 @@
+"""Base entity for EHEIM Digital."""
+
+from abc import ABC, abstractmethod
+from typing import TYPE_CHECKING
+
+from eheimdigital.device import EheimDigitalDevice
+
+from homeassistant.const import CONF_HOST
+from homeassistant.core import callback
+from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
+from homeassistant.helpers.update_coordinator import CoordinatorEntity
+
+from .const import DOMAIN
+from .coordinator import EheimDigitalUpdateCoordinator
+
+
+class EheimDigitalEntity[_DeviceT: EheimDigitalDevice](
+ CoordinatorEntity[EheimDigitalUpdateCoordinator], ABC
+):
+ """Represent a EHEIM Digital entity."""
+
+ _attr_has_entity_name = True
+
+ def __init__(
+ self, coordinator: EheimDigitalUpdateCoordinator, device: _DeviceT
+ ) -> None:
+ """Initialize a EHEIM Digital entity."""
+ super().__init__(coordinator)
+ if TYPE_CHECKING:
+ # At this point at least one device is found and so there is always a main device set
+ assert isinstance(coordinator.hub.main, EheimDigitalDevice)
+ self._attr_device_info = DeviceInfo(
+ configuration_url=f"http://{coordinator.config_entry.data[CONF_HOST]}",
+ name=device.name,
+ connections={(CONNECTION_NETWORK_MAC, device.mac_address)},
+ manufacturer="EHEIM",
+ model=device.device_type.model_name,
+ identifiers={(DOMAIN, device.mac_address)},
+ suggested_area=device.aquarium_name,
+ sw_version=device.sw_version,
+ via_device=(DOMAIN, coordinator.hub.main.mac_address),
+ )
+ self._device = device
+ self._device_address = device.mac_address
+
+ @abstractmethod
+ def _async_update_attrs(self) -> None: ...
+
+ @callback
+ def _handle_coordinator_update(self) -> None:
+ """Update attributes when the coordinator updates."""
+ self._async_update_attrs()
+ super()._handle_coordinator_update()
diff --git a/homeassistant/components/eheimdigital/light.py b/homeassistant/components/eheimdigital/light.py
new file mode 100644
index 00000000000..a119e0bda8d
--- /dev/null
+++ b/homeassistant/components/eheimdigital/light.py
@@ -0,0 +1,127 @@
+"""EHEIM Digital lights."""
+
+from typing import Any
+
+from eheimdigital.classic_led_ctrl import EheimDigitalClassicLEDControl
+from eheimdigital.types import EheimDigitalClientError, LightMode
+
+from homeassistant.components.light import (
+ ATTR_BRIGHTNESS,
+ ATTR_EFFECT,
+ EFFECT_OFF,
+ ColorMode,
+ LightEntity,
+ LightEntityFeature,
+)
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.util.color import brightness_to_value, value_to_brightness
+
+from . import EheimDigitalConfigEntry
+from .const import EFFECT_DAYCL_MODE, EFFECT_TO_LIGHT_MODE
+from .coordinator import EheimDigitalUpdateCoordinator
+from .entity import EheimDigitalEntity
+
+BRIGHTNESS_SCALE = (1, 100)
+
+# 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 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]
+ entities: list[EheimDigitalClassicLEDControlLight] = []
+
+ 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)
+
+
+class EheimDigitalClassicLEDControlLight(
+ EheimDigitalEntity[EheimDigitalClassicLEDControl], LightEntity
+):
+ """Represent a EHEIM Digital classicLEDcontrol light."""
+
+ _attr_supported_color_modes = {ColorMode.BRIGHTNESS}
+ _attr_color_mode = ColorMode.BRIGHTNESS
+ _attr_effect_list = [EFFECT_DAYCL_MODE]
+ _attr_supported_features = LightEntityFeature.EFFECT
+ _attr_translation_key = "channel"
+
+ def __init__(
+ self,
+ coordinator: EheimDigitalUpdateCoordinator,
+ device: EheimDigitalClassicLEDControl,
+ channel: int,
+ ) -> None:
+ """Initialize an EHEIM Digital classicLEDcontrol light entity."""
+ super().__init__(coordinator, device)
+ self._channel = channel
+ self._attr_translation_placeholders = {"channel_id": str(channel)}
+ self._attr_unique_id = f"{self._device_address}_{channel}"
+ self._async_update_attrs()
+
+ @property
+ def available(self) -> bool:
+ """Return whether the entity is available."""
+ return super().available and self._device.light_level[self._channel] is not None
+
+ async def async_turn_on(self, **kwargs: Any) -> None:
+ """Turn on the light."""
+ if ATTR_EFFECT in kwargs:
+ await self._device.set_light_mode(EFFECT_TO_LIGHT_MODE[kwargs[ATTR_EFFECT]])
+ return
+ if ATTR_BRIGHTNESS in kwargs:
+ if self._device.light_mode == LightMode.DAYCL_MODE:
+ await self._device.set_light_mode(LightMode.MAN_MODE)
+ try:
+ await self._device.turn_on(
+ int(brightness_to_value(BRIGHTNESS_SCALE, kwargs[ATTR_BRIGHTNESS])),
+ self._channel,
+ )
+ except EheimDigitalClientError as err:
+ raise HomeAssistantError from err
+
+ async def async_turn_off(self, **kwargs: Any) -> None:
+ """Turn off the light."""
+ if self._device.light_mode == LightMode.DAYCL_MODE:
+ await self._device.set_light_mode(LightMode.MAN_MODE)
+ try:
+ await self._device.turn_off(self._channel)
+ except EheimDigitalClientError as err:
+ raise HomeAssistantError from err
+
+ def _async_update_attrs(self) -> None:
+ light_level = self._device.light_level[self._channel]
+
+ self._attr_is_on = light_level > 0 if light_level is not None else None
+ self._attr_brightness = (
+ value_to_brightness(BRIGHTNESS_SCALE, light_level)
+ if light_level is not None
+ else None
+ )
+ self._attr_effect = (
+ EFFECT_DAYCL_MODE
+ if self._device.light_mode == LightMode.DAYCL_MODE
+ else EFFECT_OFF
+ )
diff --git a/homeassistant/components/eheimdigital/manifest.json b/homeassistant/components/eheimdigital/manifest.json
new file mode 100644
index 00000000000..159aecd6b6c
--- /dev/null
+++ b/homeassistant/components/eheimdigital/manifest.json
@@ -0,0 +1,15 @@
+{
+ "domain": "eheimdigital",
+ "name": "EHEIM Digital",
+ "codeowners": ["@autinerd"],
+ "config_flow": true,
+ "documentation": "https://www.home-assistant.io/integrations/eheimdigital",
+ "integration_type": "hub",
+ "iot_class": "local_polling",
+ "loggers": ["eheimdigital"],
+ "quality_scale": "bronze",
+ "requirements": ["eheimdigital==1.0.3"],
+ "zeroconf": [
+ { "type": "_http._tcp.local.", "name": "eheimdigital._http._tcp.local." }
+ ]
+}
diff --git a/homeassistant/components/eheimdigital/quality_scale.yaml b/homeassistant/components/eheimdigital/quality_scale.yaml
new file mode 100644
index 00000000000..a56551a14f6
--- /dev/null
+++ b/homeassistant/components/eheimdigital/quality_scale.yaml
@@ -0,0 +1,70 @@
+rules:
+ # Bronze
+ action-setup:
+ status: exempt
+ comment: No service actions implemented.
+ appropriate-polling: done
+ brands: done
+ common-modules: done
+ config-flow-test-coverage: done
+ config-flow: done
+ dependency-transparency: done
+ docs-actions:
+ status: exempt
+ comment: No service actions implemented.
+ 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:
+ status: exempt
+ comment: No service actions implemented.
+ config-entry-unloading: done
+ docs-configuration-parameters:
+ status: exempt
+ comment: This integration doesn't have an options flow.
+ docs-installation-parameters: done
+ entity-unavailable: done
+ integration-owner: done
+ log-when-unavailable: done
+ parallel-updates: done
+ reauthentication-flow:
+ status: exempt
+ comment: This integration requires no authentication.
+ test-coverage: done
+
+ # 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: done
+ 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: done
+ exception-translations: todo
+ icon-translations: todo
+ reconfiguration-flow: todo
+ repair-issues: todo
+ stale-devices: done
+
+ # Platinum
+ async-dependency: done
+ inject-websession: done
+ strict-typing: done
diff --git a/homeassistant/components/eheimdigital/strings.json b/homeassistant/components/eheimdigital/strings.json
new file mode 100644
index 00000000000..0e6fa6a0814
--- /dev/null
+++ b/homeassistant/components/eheimdigital/strings.json
@@ -0,0 +1,39 @@
+{
+ "config": {
+ "step": {
+ "discovery_confirm": {
+ "description": "[%key:common::config_flow::description::confirm_setup%]"
+ },
+ "user": {
+ "data": {
+ "host": "[%key:common::config_flow::data::host%]"
+ },
+ "data_description": {
+ "host": "The host or IP address of your main device. Only needed to change if 'eheimdigital' doesn't work."
+ }
+ }
+ },
+ "abort": {
+ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
+ "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]"
+ },
+ "error": {
+ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
+ "unknown": "[%key:common::config_flow::error::unknown%]"
+ }
+ },
+ "entity": {
+ "light": {
+ "channel": {
+ "name": "Channel {channel_id}",
+ "state_attributes": {
+ "effect": {
+ "state": {
+ "daycl_mode": "Daycycle mode"
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/homeassistant/components/eight_sleep/manifest.json b/homeassistant/components/eight_sleep/manifest.json
index a4f7482c920..59de546824f 100644
--- a/homeassistant/components/eight_sleep/manifest.json
+++ b/homeassistant/components/eight_sleep/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/eight_sleep",
"integration_type": "system",
"iot_class": "cloud_polling",
+ "quality_scale": "legacy",
"requirements": []
}
diff --git a/homeassistant/components/electrasmart/climate.py b/homeassistant/components/electrasmart/climate.py
index 81a07545a30..04e4742554b 100644
--- a/homeassistant/components/electrasmart/climate.py
+++ b/homeassistant/components/electrasmart/climate.py
@@ -111,7 +111,6 @@ class ElectraClimateEntity(ClimateEntity):
_attr_hvac_modes = ELECTRA_MODES
_attr_has_entity_name = True
_attr_name = None
- _enable_turn_on_off_backwards_compatibility = False
def __init__(self, device: ElectraAirConditioner, api: ElectraAPI) -> None:
"""Initialize Electra climate entity."""
diff --git a/homeassistant/components/elevenlabs/__init__.py b/homeassistant/components/elevenlabs/__init__.py
index 7da4802e98a..e5807fec67c 100644
--- a/homeassistant/components/elevenlabs/__init__.py
+++ b/homeassistant/components/elevenlabs/__init__.py
@@ -4,14 +4,18 @@ from __future__ import annotations
from dataclasses import dataclass
-from elevenlabs import Model
-from elevenlabs.client import AsyncElevenLabs
+from elevenlabs import AsyncElevenLabs, Model
from elevenlabs.core import ApiError
+from httpx import ConnectError
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_KEY, Platform
from homeassistant.core import HomeAssistant
-from homeassistant.exceptions import ConfigEntryError
+from homeassistant.exceptions import (
+ ConfigEntryAuthFailed,
+ ConfigEntryError,
+ ConfigEntryNotReady,
+)
from homeassistant.helpers.httpx_client import get_async_client
from .const import CONF_MODEL
@@ -36,10 +40,10 @@ class ElevenLabsData:
model: Model
-type EleventLabsConfigEntry = ConfigEntry[ElevenLabsData]
+type ElevenLabsConfigEntry = ConfigEntry[ElevenLabsData]
-async def async_setup_entry(hass: HomeAssistant, entry: EleventLabsConfigEntry) -> bool:
+async def async_setup_entry(hass: HomeAssistant, entry: ElevenLabsConfigEntry) -> bool:
"""Set up ElevenLabs text-to-speech from a config entry."""
entry.add_update_listener(update_listener)
httpx_client = get_async_client(hass)
@@ -49,8 +53,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: EleventLabsConfigEntry)
model_id = entry.options[CONF_MODEL]
try:
model = await get_model_by_id(client, model_id)
+ except ConnectError as err:
+ raise ConfigEntryNotReady("Failed to connect") from err
except ApiError as err:
- raise ConfigEntryError("Auth failed") from err
+ raise ConfigEntryAuthFailed("Auth failed") from err
if model is None or (not model.languages):
raise ConfigEntryError("Model could not be resolved")
@@ -61,15 +67,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: EleventLabsConfigEntry)
return True
-async def async_unload_entry(
- hass: HomeAssistant, entry: EleventLabsConfigEntry
-) -> bool:
+async def async_unload_entry(hass: HomeAssistant, entry: ElevenLabsConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
async def update_listener(
- hass: HomeAssistant, config_entry: EleventLabsConfigEntry
+ hass: HomeAssistant, config_entry: ElevenLabsConfigEntry
) -> None:
"""Handle options update."""
await hass.config_entries.async_reload(config_entry.entry_id)
diff --git a/homeassistant/components/elevenlabs/config_flow.py b/homeassistant/components/elevenlabs/config_flow.py
index 227150a0f4e..227749bf82c 100644
--- a/homeassistant/components/elevenlabs/config_flow.py
+++ b/homeassistant/components/elevenlabs/config_flow.py
@@ -5,16 +5,11 @@ from __future__ import annotations
import logging
from typing import Any
-from elevenlabs.client import AsyncElevenLabs
+from elevenlabs import AsyncElevenLabs
from elevenlabs.core import ApiError
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
from homeassistant.core import HomeAssistant
from homeassistant.helpers.httpx_client import get_async_client
@@ -24,6 +19,7 @@ from homeassistant.helpers.selector import (
SelectSelectorConfig,
)
+from . import ElevenLabsConfigEntry
from .const import (
CONF_CONFIGURE_VOICE,
CONF_MODEL,
@@ -96,7 +92,7 @@ class ElevenLabsConfigFlow(ConfigFlow, domain=DOMAIN):
@staticmethod
def async_get_options_flow(
- config_entry: ConfigEntry,
+ config_entry: ElevenLabsConfigEntry,
) -> OptionsFlow:
"""Create the options flow."""
return ElevenLabsOptionsFlow(config_entry)
@@ -105,7 +101,7 @@ class ElevenLabsConfigFlow(ConfigFlow, domain=DOMAIN):
class ElevenLabsOptionsFlow(OptionsFlow):
"""ElevenLabs options flow."""
- def __init__(self, config_entry: ConfigEntry) -> None:
+ def __init__(self, config_entry: ElevenLabsConfigEntry) -> None:
"""Initialize options flow."""
self.api_key: str = config_entry.data[CONF_API_KEY]
# id -> name
diff --git a/homeassistant/components/elevenlabs/manifest.json b/homeassistant/components/elevenlabs/manifest.json
index 968ea7b688a..eb6df09149a 100644
--- a/homeassistant/components/elevenlabs/manifest.json
+++ b/homeassistant/components/elevenlabs/manifest.json
@@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["elevenlabs"],
- "requirements": ["elevenlabs==1.6.1"]
+ "requirements": ["elevenlabs==1.9.0"]
}
diff --git a/homeassistant/components/elevenlabs/quality_scale.yaml b/homeassistant/components/elevenlabs/quality_scale.yaml
new file mode 100644
index 00000000000..94c395310c5
--- /dev/null
+++ b/homeassistant/components/elevenlabs/quality_scale.yaml
@@ -0,0 +1,88 @@
+rules:
+ # Bronze
+ action-setup:
+ status: done
+ comment: >
+ Only entity services
+ 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: todo
+ runtime-data: done
+ test-before-configure: done
+ test-before-setup: done
+ unique-config-entry: todo
+ # Silver
+ config-entry-unloading: done
+ log-when-unavailable: todo
+ entity-unavailable:
+ status: exempt
+ comment: >
+ There is no state in the TTS platform and we can't check poll if the TTS service is available.
+ action-exceptions: done
+ reauthentication-flow: todo
+ parallel-updates: done
+ test-coverage: todo
+ integration-owner: done
+ docs-installation-parameters: todo
+ docs-configuration-parameters: todo
+
+ # Gold
+ entity-translations: todo
+ entity-device-class:
+ status: exempt
+ comment: There is no device class for Text To Speech entities.
+ devices: done
+ entity-category: done
+ entity-disabled-by-default: todo
+ discovery:
+ status: exempt
+ comment: >
+ This is not possible because there is no physical device.
+ stale-devices:
+ status: exempt
+ comment: >
+ This is not possible because there is no physical device.
+ diagnostics: todo
+ exception-translations: todo
+ icon-translations: todo
+ reconfiguration-flow:
+ status: todo
+ comment: >
+ I imagine this could be useful if the default voice is deleted from voice lab.
+ dynamic-devices:
+ status: exempt
+ comment: |
+ This is not possible because there is no physical device.
+ discovery-update-info:
+ status: exempt
+ comment: >
+ This is not needed because there are no physical devices.
+ repair-issues: todo
+ docs-use-cases: done
+ docs-supported-devices:
+ status: exempt
+ comment: >
+ This integration does not support any devices.
+ 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/elevenlabs/tts.py b/homeassistant/components/elevenlabs/tts.py
index efc2154882a..b89e966593f 100644
--- a/homeassistant/components/elevenlabs/tts.py
+++ b/homeassistant/components/elevenlabs/tts.py
@@ -6,7 +6,7 @@ import logging
from types import MappingProxyType
from typing import Any
-from elevenlabs.client import AsyncElevenLabs
+from elevenlabs import AsyncElevenLabs
from elevenlabs.core import ApiError
from elevenlabs.types import Model, Voice as ElevenLabsVoice, VoiceSettings
@@ -16,12 +16,13 @@ from homeassistant.components.tts import (
TtsAudioType,
Voice,
)
+from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from . import EleventLabsConfigEntry
+from . import ElevenLabsConfigEntry
from .const import (
CONF_OPTIMIZE_LATENCY,
CONF_SIMILARITY,
@@ -38,6 +39,7 @@ from .const import (
)
_LOGGER = logging.getLogger(__name__)
+PARALLEL_UPDATES = 0
def to_voice_settings(options: MappingProxyType[str, Any]) -> VoiceSettings:
@@ -54,7 +56,7 @@ def to_voice_settings(options: MappingProxyType[str, Any]) -> VoiceSettings:
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: EleventLabsConfigEntry,
+ config_entry: ElevenLabsConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up ElevenLabs tts platform via config entry."""
@@ -84,6 +86,7 @@ class ElevenLabsTTSEntity(TextToSpeechEntity):
"""The ElevenLabs API entity."""
_attr_supported_options = [ATTR_VOICE]
+ _attr_entity_category = EntityCategory.CONFIG
def __init__(
self,
diff --git a/homeassistant/components/elgato/__init__.py b/homeassistant/components/elgato/__init__.py
index 2d8446c3b76..1b1ff9948c9 100644
--- a/homeassistant/components/elgato/__init__.py
+++ b/homeassistant/components/elgato/__init__.py
@@ -1,17 +1,14 @@
"""Support for Elgato Lights."""
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
-from .coordinator import ElgatoDataUpdateCoordinator
+from .coordinator import ElgatoConfigEntry, ElgatoDataUpdateCoordinator
PLATFORMS = [Platform.BUTTON, Platform.LIGHT, Platform.SENSOR, Platform.SWITCH]
-type ElgatorConfigEntry = ConfigEntry[ElgatoDataUpdateCoordinator]
-
-async def async_setup_entry(hass: HomeAssistant, entry: ElgatorConfigEntry) -> bool:
+async def async_setup_entry(hass: HomeAssistant, entry: ElgatoConfigEntry) -> bool:
"""Set up Elgato Light from a config entry."""
coordinator = ElgatoDataUpdateCoordinator(hass, entry)
await coordinator.async_config_entry_first_refresh()
@@ -22,6 +19,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ElgatorConfigEntry) -> b
return True
-async def async_unload_entry(hass: HomeAssistant, entry: ElgatorConfigEntry) -> bool:
+async def async_unload_entry(hass: HomeAssistant, entry: ElgatoConfigEntry) -> bool:
"""Unload Elgato Light config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
diff --git a/homeassistant/components/elgato/button.py b/homeassistant/components/elgato/button.py
index aefff0b750b..505eff36b44 100644
--- a/homeassistant/components/elgato/button.py
+++ b/homeassistant/components/elgato/button.py
@@ -18,10 +18,11 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from . import ElgatorConfigEntry
-from .coordinator import ElgatoDataUpdateCoordinator
+from .coordinator import ElgatoConfigEntry, ElgatoDataUpdateCoordinator
from .entity import ElgatoEntity
+PARALLEL_UPDATES = 1
+
@dataclass(frozen=True, kw_only=True)
class ElgatoButtonEntityDescription(ButtonEntityDescription):
@@ -48,7 +49,7 @@ BUTTONS = [
async def async_setup_entry(
hass: HomeAssistant,
- entry: ElgatorConfigEntry,
+ entry: ElgatoConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Elgato button based on a config entry."""
diff --git a/homeassistant/components/elgato/config_flow.py b/homeassistant/components/elgato/config_flow.py
index 5329fcee90a..e20afc73a2d 100644
--- a/homeassistant/components/elgato/config_flow.py
+++ b/homeassistant/components/elgato/config_flow.py
@@ -9,7 +9,7 @@ import voluptuous as vol
from homeassistant.components import onboarding, zeroconf
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
-from homeassistant.const import CONF_HOST, CONF_MAC, CONF_PORT
+from homeassistant.const import CONF_HOST, CONF_MAC
from homeassistant.core import callback
from homeassistant.helpers.aiohttp_client import async_get_clientsession
@@ -34,7 +34,6 @@ class ElgatoFlowHandler(ConfigFlow, domain=DOMAIN):
return self._async_show_setup_form()
self.host = user_input[CONF_HOST]
- self.port = user_input[CONF_PORT]
try:
await self._get_elgato_serial_number(raise_on_progress=False)
@@ -49,7 +48,6 @@ class ElgatoFlowHandler(ConfigFlow, domain=DOMAIN):
"""Handle zeroconf discovery."""
self.host = discovery_info.host
self.mac = discovery_info.properties.get("id")
- self.port = discovery_info.port or 9123
try:
await self._get_elgato_serial_number()
@@ -81,7 +79,6 @@ class ElgatoFlowHandler(ConfigFlow, domain=DOMAIN):
data_schema=vol.Schema(
{
vol.Required(CONF_HOST): str,
- vol.Optional(CONF_PORT, default=9123): int,
}
),
errors=errors or {},
@@ -93,7 +90,6 @@ class ElgatoFlowHandler(ConfigFlow, domain=DOMAIN):
title=self.serial_number,
data={
CONF_HOST: self.host,
- CONF_PORT: self.port,
CONF_MAC: self.mac,
},
)
@@ -103,7 +99,6 @@ class ElgatoFlowHandler(ConfigFlow, domain=DOMAIN):
session = async_get_clientsession(self.hass)
elgato = Elgato(
host=self.host,
- port=self.port,
session=session,
)
info = await elgato.info()
@@ -113,7 +108,7 @@ class ElgatoFlowHandler(ConfigFlow, domain=DOMAIN):
info.serial_number, raise_on_progress=raise_on_progress
)
self._abort_if_unique_id_configured(
- updates={CONF_HOST: self.host, CONF_PORT: self.port, CONF_MAC: self.mac}
+ updates={CONF_HOST: self.host, CONF_MAC: self.mac}
)
self.serial_number = info.serial_number
diff --git a/homeassistant/components/elgato/coordinator.py b/homeassistant/components/elgato/coordinator.py
index c2bc79491a1..5e1ba0a6494 100644
--- a/homeassistant/components/elgato/coordinator.py
+++ b/homeassistant/components/elgato/coordinator.py
@@ -5,13 +5,15 @@ from dataclasses import dataclass
from elgato import BatteryInfo, Elgato, ElgatoConnectionError, Info, Settings, State
from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import CONF_HOST, CONF_PORT
+from homeassistant.const import CONF_HOST
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 ElgatoConfigEntry = ConfigEntry[ElgatoDataUpdateCoordinator]
+
@dataclass
class ElgatoData:
@@ -26,20 +28,20 @@ class ElgatoData:
class ElgatoDataUpdateCoordinator(DataUpdateCoordinator[ElgatoData]):
"""Class to manage fetching Elgato data."""
- config_entry: ConfigEntry
+ config_entry: ElgatoConfigEntry
has_battery: bool | None = None
- def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
+ def __init__(self, hass: HomeAssistant, entry: ElgatoConfigEntry) -> None:
"""Initialize the coordinator."""
self.config_entry = entry
self.client = Elgato(
entry.data[CONF_HOST],
- port=entry.data[CONF_PORT],
session=async_get_clientsession(hass),
)
super().__init__(
hass,
LOGGER,
+ config_entry=entry,
name=f"{DOMAIN}_{entry.data[CONF_HOST]}",
update_interval=SCAN_INTERVAL,
)
diff --git a/homeassistant/components/elgato/diagnostics.py b/homeassistant/components/elgato/diagnostics.py
index ac3ea0a155d..4e1b9d4cfdd 100644
--- a/homeassistant/components/elgato/diagnostics.py
+++ b/homeassistant/components/elgato/diagnostics.py
@@ -6,11 +6,11 @@ from typing import Any
from homeassistant.core import HomeAssistant
-from . import ElgatorConfigEntry
+from .coordinator import ElgatoConfigEntry
async def async_get_config_entry_diagnostics(
- hass: HomeAssistant, entry: ElgatorConfigEntry
+ hass: HomeAssistant, entry: ElgatoConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/elgato/light.py b/homeassistant/components/elgato/light.py
index a62a26f21d3..990a0606fce 100644
--- a/homeassistant/components/elgato/light.py
+++ b/homeassistant/components/elgato/light.py
@@ -8,7 +8,7 @@ from elgato import ElgatoError
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
- ATTR_COLOR_TEMP,
+ ATTR_COLOR_TEMP_KELVIN,
ATTR_HS_COLOR,
ColorMode,
LightEntity,
@@ -19,10 +19,10 @@ from homeassistant.helpers.entity_platform import (
AddEntitiesCallback,
async_get_current_platform,
)
+from homeassistant.util import color as color_util
-from . import ElgatorConfigEntry
from .const import SERVICE_IDENTIFY
-from .coordinator import ElgatoDataUpdateCoordinator
+from .coordinator import ElgatoConfigEntry, ElgatoDataUpdateCoordinator
from .entity import ElgatoEntity
PARALLEL_UPDATES = 1
@@ -30,7 +30,7 @@ PARALLEL_UPDATES = 1
async def async_setup_entry(
hass: HomeAssistant,
- entry: ElgatorConfigEntry,
+ entry: ElgatoConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Elgato Light based on a config entry."""
@@ -49,8 +49,8 @@ class ElgatoLight(ElgatoEntity, LightEntity):
"""Defines an Elgato Light."""
_attr_name = None
- _attr_min_mireds = 143
- _attr_max_mireds = 344
+ _attr_min_color_temp_kelvin = 2900 # 344 Mireds
+ _attr_max_color_temp_kelvin = 7000 # 143 Mireds
def __init__(self, coordinator: ElgatoDataUpdateCoordinator) -> None:
"""Initialize Elgato Light."""
@@ -69,8 +69,8 @@ class ElgatoLight(ElgatoEntity, LightEntity):
or self.coordinator.data.state.hue is not None
):
self._attr_supported_color_modes = {ColorMode.COLOR_TEMP, ColorMode.HS}
- self._attr_min_mireds = 153
- self._attr_max_mireds = 285
+ self._attr_min_color_temp_kelvin = 3500 # 285 Mireds
+ self._attr_max_color_temp_kelvin = 6500 # 153 Mireds
@property
def brightness(self) -> int | None:
@@ -78,9 +78,11 @@ class ElgatoLight(ElgatoEntity, LightEntity):
return round((self.coordinator.data.state.brightness * 255) / 100)
@property
- def color_temp(self) -> int | None:
- """Return the CT color value in mireds."""
- return self.coordinator.data.state.temperature
+ def color_temp_kelvin(self) -> int | None:
+ """Return the color temperature value in Kelvin."""
+ if (mired_temperature := self.coordinator.data.state.temperature) is None:
+ return None
+ return color_util.color_temperature_mired_to_kelvin(mired_temperature)
@property
def color_mode(self) -> str | None:
@@ -116,7 +118,7 @@ class ElgatoLight(ElgatoEntity, LightEntity):
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn on the light."""
- temperature = kwargs.get(ATTR_COLOR_TEMP)
+ temperature_kelvin = kwargs.get(ATTR_COLOR_TEMP_KELVIN)
hue = None
saturation = None
@@ -133,12 +135,18 @@ class ElgatoLight(ElgatoEntity, LightEntity):
if (
brightness
and ATTR_HS_COLOR not in kwargs
- and ATTR_COLOR_TEMP not in kwargs
+ and ATTR_COLOR_TEMP_KELVIN not in kwargs
and self.supported_color_modes
and ColorMode.HS in self.supported_color_modes
and self.color_mode == ColorMode.COLOR_TEMP
):
- temperature = self.color_temp
+ temperature_kelvin = self.color_temp_kelvin
+
+ temperature = (
+ None
+ if temperature_kelvin is None
+ else color_util.color_temperature_kelvin_to_mired(temperature_kelvin)
+ )
try:
await self.coordinator.client.light(
diff --git a/homeassistant/components/elgato/manifest.json b/homeassistant/components/elgato/manifest.json
index c68902560b9..734ad5ec930 100644
--- a/homeassistant/components/elgato/manifest.json
+++ b/homeassistant/components/elgato/manifest.json
@@ -6,7 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/elgato",
"integration_type": "device",
"iot_class": "local_polling",
- "quality_scale": "platinum",
"requirements": ["elgato==5.1.2"],
"zeroconf": ["_elg._tcp.local."]
}
diff --git a/homeassistant/components/elgato/quality_scale.yaml b/homeassistant/components/elgato/quality_scale.yaml
new file mode 100644
index 00000000000..531f0447f70
--- /dev/null
+++ b/homeassistant/components/elgato/quality_scale.yaml
@@ -0,0 +1,82 @@
+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: todo
+ 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: todo
+ docs-installation-parameters: todo
+ entity-unavailable: done
+ integration-owner: done
+ log-when-unavailable: done
+ parallel-updates: done
+ reauthentication-flow:
+ status: exempt
+ comment: |
+ This integration does not require authentication.
+ test-coverage: done
+ # Gold
+ devices: done
+ diagnostics: done
+ discovery-update-info:
+ status: todo
+ comment: |
+ The integration doesn't update the device info based on DHCP discovery
+ of known existing devices.
+ discovery: done
+ docs-data-update: todo
+ docs-examples: todo
+ docs-known-limitations: todo
+ docs-supported-devices:
+ status: todo
+ comment: |
+ Device are documented, but some are missing. For example, the their pro
+ strip is supported as well.
+ docs-supported-functions: done
+ docs-troubleshooting: todo
+ docs-use-cases: todo
+ dynamic-devices:
+ status: exempt
+ comment: |
+ This integration connects to a single 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: |
+ This integration does not raise any repairable issues.
+ stale-devices:
+ status: exempt
+ comment: |
+ This integration connects to a single device.
+
+ # Platinum
+ async-dependency: done
+ inject-websession: done
+ strict-typing: done
diff --git a/homeassistant/components/elgato/sensor.py b/homeassistant/components/elgato/sensor.py
index f794d26cf7f..529d2f7c76e 100644
--- a/homeassistant/components/elgato/sensor.py
+++ b/homeassistant/components/elgato/sensor.py
@@ -21,10 +21,12 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from . import ElgatorConfigEntry
-from .coordinator import ElgatoData, ElgatoDataUpdateCoordinator
+from .coordinator import ElgatoConfigEntry, ElgatoData, ElgatoDataUpdateCoordinator
from .entity import ElgatoEntity
+# Coordinator is used to centralize the data updates
+PARALLEL_UPDATES = 0
+
@dataclass(frozen=True, kw_only=True)
class ElgatoSensorEntityDescription(SensorEntityDescription):
@@ -101,7 +103,7 @@ SENSORS = [
async def async_setup_entry(
hass: HomeAssistant,
- entry: ElgatorConfigEntry,
+ entry: ElgatoConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Elgato sensor based on a config entry."""
diff --git a/homeassistant/components/elgato/strings.json b/homeassistant/components/elgato/strings.json
index 6e1031c8ddf..727b8ee7024 100644
--- a/homeassistant/components/elgato/strings.json
+++ b/homeassistant/components/elgato/strings.json
@@ -5,8 +5,7 @@
"user": {
"description": "Set up your Elgato Light to integrate with Home Assistant.",
"data": {
- "host": "[%key:common::config_flow::data::host%]",
- "port": "[%key:common::config_flow::data::port%]"
+ "host": "[%key:common::config_flow::data::host%]"
},
"data_description": {
"host": "The hostname or IP address of your Elgato device."
diff --git a/homeassistant/components/elgato/switch.py b/homeassistant/components/elgato/switch.py
index fe177616034..3b2420b0ace 100644
--- a/homeassistant/components/elgato/switch.py
+++ b/homeassistant/components/elgato/switch.py
@@ -14,10 +14,11 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from . import ElgatorConfigEntry
-from .coordinator import ElgatoData, ElgatoDataUpdateCoordinator
+from .coordinator import ElgatoConfigEntry, ElgatoData, ElgatoDataUpdateCoordinator
from .entity import ElgatoEntity
+PARALLEL_UPDATES = 1
+
@dataclass(frozen=True, kw_only=True)
class ElgatoSwitchEntityDescription(SwitchEntityDescription):
@@ -52,7 +53,7 @@ SWITCHES = [
async def async_setup_entry(
hass: HomeAssistant,
- entry: ElgatorConfigEntry,
+ entry: ElgatoConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Elgato switches based on a config entry."""
diff --git a/homeassistant/components/eliqonline/manifest.json b/homeassistant/components/eliqonline/manifest.json
index 78fd62fbd33..70f2cd8a675 100644
--- a/homeassistant/components/eliqonline/manifest.json
+++ b/homeassistant/components/eliqonline/manifest.json
@@ -4,5 +4,6 @@
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/eliqonline",
"iot_class": "cloud_polling",
+ "quality_scale": "legacy",
"requirements": ["eliqonline==1.2.2"]
}
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/climate.py b/homeassistant/components/elkm1/climate.py
index bf5650f237b..1448acc6079 100644
--- a/homeassistant/components/elkm1/climate.py
+++ b/homeassistant/components/elkm1/climate.py
@@ -90,7 +90,6 @@ class ElkThermostat(ElkEntity, ClimateEntity):
_attr_target_temperature_step = 1
_attr_fan_modes = [FAN_AUTO, FAN_ON]
_element: Thermostat
- _enable_turn_on_off_backwards_compatibility = False
@property
def temperature_unit(self) -> str:
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 6318231c281..bf02d727280 100644
--- a/homeassistant/components/elkm1/strings.json
+++ b/homeassistant/components/elkm1/strings.json
@@ -68,7 +68,7 @@
}
},
"alarm_arm_home_instant": {
- "name": "Alarm are home instant",
+ "name": "Alarm arm home instant",
"description": "Arms the ElkM1 in home instant mode.",
"fields": {
"code": {
diff --git a/homeassistant/components/elmax/common.py b/homeassistant/components/elmax/common.py
index 88e61e36a68..18350e45efe 100644
--- a/homeassistant/components/elmax/common.py
+++ b/homeassistant/components/elmax/common.py
@@ -35,7 +35,7 @@ def check_local_version_supported(api_version: str | None) -> bool:
class DirectPanel(PanelEntry):
"""Helper class for wrapping a directly accessed Elmax Panel."""
- def __init__(self, panel_uri):
+ def __init__(self, panel_uri) -> None:
"""Construct the object."""
super().__init__(panel_uri, True, {})
diff --git a/homeassistant/components/elmax/config_flow.py b/homeassistant/components/elmax/config_flow.py
index bf479e997ef..09e0bc0d260 100644
--- a/homeassistant/components/elmax/config_flow.py
+++ b/homeassistant/components/elmax/config_flow.py
@@ -151,7 +151,9 @@ class ElmaxConfigFlow(ConfigFlow, domain=DOMAIN):
port=self._panel_direct_port,
)
)
- ssl_context = build_direct_ssl_context(cadata=self._panel_direct_ssl_cert)
+ ssl_context = await self.hass.async_add_executor_job(
+ build_direct_ssl_context, self._panel_direct_ssl_cert
+ )
# Attempt the connection to make sure the pin works. Also, take the chance to retrieve the panel ID via APIs.
client_api_url = get_direct_api_url(
@@ -203,7 +205,7 @@ class ElmaxConfigFlow(ConfigFlow, domain=DOMAIN):
async def async_step_direct(self, user_input: dict[str, Any]) -> ConfigFlowResult:
"""Handle the direct setup step."""
- self._selected_mode = CONF_ELMAX_MODE_CLOUD
+ self._selected_mode = CONF_ELMAX_MODE_DIRECT
if user_input is None:
return self.async_show_form(
step_id=CONF_ELMAX_MODE_DIRECT,
diff --git a/homeassistant/components/elmax/cover.py b/homeassistant/components/elmax/cover.py
index a53c28c5f33..403bc51dbff 100644
--- a/homeassistant/components/elmax/cover.py
+++ b/homeassistant/components/elmax/cover.py
@@ -121,13 +121,13 @@ class ElmaxCover(ElmaxEntity, CoverEntity):
else:
_LOGGER.debug("Ignoring stop request as the cover is IDLE")
- async def async_open_cover(self, **kwargs):
+ async def async_open_cover(self, **kwargs: Any) -> None:
"""Open the cover."""
await self.coordinator.http_client.execute_command(
endpoint_id=self._device.endpoint_id, command=CoverCommand.UP
)
- async def async_close_cover(self, **kwargs):
+ async def async_close_cover(self, **kwargs: Any) -> None:
"""Close the cover."""
await self.coordinator.http_client.execute_command(
endpoint_id=self._device.endpoint_id, command=CoverCommand.DOWN
diff --git a/homeassistant/components/elmax/manifest.json b/homeassistant/components/elmax/manifest.json
index c57b707906b..f4b184c0475 100644
--- a/homeassistant/components/elmax/manifest.json
+++ b/homeassistant/components/elmax/manifest.json
@@ -6,7 +6,7 @@
"documentation": "https://www.home-assistant.io/integrations/elmax",
"iot_class": "cloud_polling",
"loggers": ["elmax_api"],
- "requirements": ["elmax-api==0.0.5"],
+ "requirements": ["elmax-api==0.0.6.4rc0"],
"zeroconf": [
{
"type": "_elmax-ssl._tcp.local."
diff --git a/homeassistant/components/elmax/strings.json b/homeassistant/components/elmax/strings.json
index daa502a7dac..2ba74f5fc8f 100644
--- a/homeassistant/components/elmax/strings.json
+++ b/homeassistant/components/elmax/strings.json
@@ -50,7 +50,7 @@
"data": {
"password": "[%key:common::config_flow::data::password%]",
"username": "[%key:common::config_flow::data::username%]",
- "panel_pin": "Panel Pin"
+ "panel_pin": "Panel PIN"
}
}
},
@@ -58,7 +58,7 @@
"no_panel_online": "No online Elmax control panel was found.",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"network_error": "A network error occurred",
- "invalid_pin": "The provided pin is invalid",
+ "invalid_pin": "The provided PIN is invalid",
"invalid_mode": "Invalid or unsupported mode",
"reauth_panel_disappeared": "The given panel is no longer associated to this user. Please log in using an account associated to this panel.",
"unknown": "[%key:common::config_flow::error::unknown%]"
diff --git a/homeassistant/components/elv/manifest.json b/homeassistant/components/elv/manifest.json
index 9b71595e58f..5757aeb5e52 100644
--- a/homeassistant/components/elv/manifest.json
+++ b/homeassistant/components/elv/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/elv",
"iot_class": "local_polling",
"loggers": ["pypca"],
+ "quality_scale": "legacy",
"requirements": ["pypca==0.0.7"]
}
diff --git a/homeassistant/components/emby/manifest.json b/homeassistant/components/emby/manifest.json
index 3f57f62eb0b..856cdaf189f 100644
--- a/homeassistant/components/emby/manifest.json
+++ b/homeassistant/components/emby/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/emby",
"iot_class": "local_push",
"loggers": ["pyemby"],
+ "quality_scale": "legacy",
"requirements": ["pyEmby==1.10"]
}
diff --git a/homeassistant/components/emoncms/sensor.py b/homeassistant/components/emoncms/sensor.py
index c696a569135..291ecad0bd3 100644
--- a/homeassistant/components/emoncms/sensor.py
+++ b/homeassistant/components/emoncms/sensor.py
@@ -10,16 +10,31 @@ from homeassistant.components.sensor import (
PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA,
SensorDeviceClass,
SensorEntity,
+ SensorEntityDescription,
SensorStateClass,
)
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import (
+ CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
+ CONCENTRATION_PARTS_PER_MILLION,
CONF_API_KEY,
CONF_ID,
CONF_UNIT_OF_MEASUREMENT,
CONF_URL,
CONF_VALUE_TEMPLATE,
+ PERCENTAGE,
+ UnitOfApparentPower,
+ UnitOfElectricCurrent,
+ UnitOfElectricPotential,
+ UnitOfEnergy,
+ UnitOfFrequency,
UnitOfPower,
+ UnitOfPressure,
+ UnitOfSoundPressure,
+ UnitOfSpeed,
+ UnitOfTemperature,
+ UnitOfVolume,
+ UnitOfVolumeFlowRate,
)
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback
from homeassistant.data_entry_flow import FlowResultType
@@ -41,6 +56,146 @@ from .const import (
)
from .coordinator import EmoncmsCoordinator
+SENSORS: dict[str | None, SensorEntityDescription] = {
+ "kWh": SensorEntityDescription(
+ key="energy|kWh",
+ translation_key="energy",
+ device_class=SensorDeviceClass.ENERGY,
+ native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
+ state_class=SensorStateClass.TOTAL_INCREASING,
+ ),
+ "Wh": SensorEntityDescription(
+ key="energy|Wh",
+ translation_key="energy",
+ device_class=SensorDeviceClass.ENERGY,
+ native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
+ state_class=SensorStateClass.TOTAL_INCREASING,
+ ),
+ "kW": SensorEntityDescription(
+ key="power|kW",
+ translation_key="power",
+ device_class=SensorDeviceClass.POWER,
+ native_unit_of_measurement=UnitOfPower.KILO_WATT,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ "W": SensorEntityDescription(
+ key="power|W",
+ translation_key="power",
+ device_class=SensorDeviceClass.POWER,
+ native_unit_of_measurement=UnitOfPower.WATT,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ "V": SensorEntityDescription(
+ key="voltage",
+ translation_key="voltage",
+ device_class=SensorDeviceClass.VOLTAGE,
+ native_unit_of_measurement=UnitOfElectricPotential.VOLT,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ "A": SensorEntityDescription(
+ key="current",
+ translation_key="current",
+ device_class=SensorDeviceClass.CURRENT,
+ native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ "VA": SensorEntityDescription(
+ key="apparent_power",
+ translation_key="apparent_power",
+ device_class=SensorDeviceClass.APPARENT_POWER,
+ native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ "°C": SensorEntityDescription(
+ key="temperature|celsius",
+ translation_key="temperature",
+ device_class=SensorDeviceClass.TEMPERATURE,
+ native_unit_of_measurement=UnitOfTemperature.CELSIUS,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ "°F": SensorEntityDescription(
+ key="temperature|fahrenheit",
+ translation_key="temperature",
+ device_class=SensorDeviceClass.TEMPERATURE,
+ native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ "K": SensorEntityDescription(
+ key="temperature|kelvin",
+ translation_key="temperature",
+ device_class=SensorDeviceClass.TEMPERATURE,
+ native_unit_of_measurement=UnitOfTemperature.KELVIN,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ "Hz": SensorEntityDescription(
+ key="frequency",
+ translation_key="frequency",
+ device_class=SensorDeviceClass.FREQUENCY,
+ native_unit_of_measurement=UnitOfFrequency.HERTZ,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ "hPa": SensorEntityDescription(
+ key="pressure",
+ translation_key="pressure",
+ device_class=SensorDeviceClass.PRESSURE,
+ native_unit_of_measurement=UnitOfPressure.HPA,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ "dB": SensorEntityDescription(
+ key="decibel",
+ translation_key="decibel",
+ device_class=SensorDeviceClass.SIGNAL_STRENGTH,
+ native_unit_of_measurement=UnitOfSoundPressure.DECIBEL,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ "m³": SensorEntityDescription(
+ key="volume|cubic_meter",
+ translation_key="volume",
+ device_class=SensorDeviceClass.VOLUME_STORAGE,
+ native_unit_of_measurement=UnitOfVolume.CUBIC_METERS,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ "m³/h": SensorEntityDescription(
+ key="flow|cubic_meters_per_hour",
+ translation_key="flow",
+ device_class=SensorDeviceClass.VOLUME_FLOW_RATE,
+ native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ "l/m": SensorEntityDescription(
+ key="flow|liters_per_minute",
+ translation_key="flow",
+ device_class=SensorDeviceClass.VOLUME_FLOW_RATE,
+ native_unit_of_measurement=UnitOfVolumeFlowRate.LITERS_PER_MINUTE,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ "m/s": SensorEntityDescription(
+ key="speed|meters_per_second",
+ translation_key="speed",
+ device_class=SensorDeviceClass.SPEED,
+ native_unit_of_measurement=UnitOfSpeed.METERS_PER_SECOND,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ "µg/m³": SensorEntityDescription(
+ key="concentration|microgram_per_cubic_meter",
+ translation_key="concentration",
+ native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ "ppm": SensorEntityDescription(
+ key="concentration|microgram_parts_per_million",
+ translation_key="concentration",
+ native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ "%": SensorEntityDescription(
+ key="percent",
+ translation_key="percent",
+ native_unit_of_measurement=PERCENTAGE,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+}
+
ATTR_FEEDID = "FeedId"
ATTR_FEEDNAME = "FeedName"
ATTR_LASTUPDATETIME = "LastUpdated"
@@ -162,7 +317,7 @@ async def async_setup_entry(
EmonCmsSensor(
coordinator,
unique_id,
- elem["unit"],
+ elem.get("unit"),
name,
idx,
)
@@ -173,6 +328,8 @@ async def async_setup_entry(
class EmonCmsSensor(CoordinatorEntity[EmoncmsCoordinator], SensorEntity):
"""Implementation of an Emoncms sensor."""
+ _attr_has_entity_name = True
+
def __init__(
self,
coordinator: EmoncmsCoordinator,
@@ -187,33 +344,16 @@ class EmonCmsSensor(CoordinatorEntity[EmoncmsCoordinator], SensorEntity):
elem = {}
if self.coordinator.data:
elem = self.coordinator.data[self.idx]
- self._attr_name = f"{name} {elem[FEED_NAME]}"
- self._attr_native_unit_of_measurement = unit_of_measurement
+ self._attr_translation_placeholders = {
+ "emoncms_details": f"{elem[FEED_TAG]} {elem[FEED_NAME]}",
+ }
self._attr_unique_id = f"{unique_id}-{elem[FEED_ID]}"
- if unit_of_measurement in ("kWh", "Wh"):
- self._attr_device_class = SensorDeviceClass.ENERGY
- self._attr_state_class = SensorStateClass.TOTAL_INCREASING
- elif unit_of_measurement == "W":
- self._attr_device_class = SensorDeviceClass.POWER
- self._attr_state_class = SensorStateClass.MEASUREMENT
- elif unit_of_measurement == "V":
- self._attr_device_class = SensorDeviceClass.VOLTAGE
- self._attr_state_class = SensorStateClass.MEASUREMENT
- elif unit_of_measurement == "A":
- self._attr_device_class = SensorDeviceClass.CURRENT
- self._attr_state_class = SensorStateClass.MEASUREMENT
- elif unit_of_measurement == "VA":
- self._attr_device_class = SensorDeviceClass.APPARENT_POWER
- self._attr_state_class = SensorStateClass.MEASUREMENT
- elif unit_of_measurement in ("°C", "°F", "K"):
- self._attr_device_class = SensorDeviceClass.TEMPERATURE
- self._attr_state_class = SensorStateClass.MEASUREMENT
- elif unit_of_measurement == "Hz":
- self._attr_device_class = SensorDeviceClass.FREQUENCY
- self._attr_state_class = SensorStateClass.MEASUREMENT
- elif unit_of_measurement == "hPa":
- self._attr_device_class = SensorDeviceClass.PRESSURE
- self._attr_state_class = SensorStateClass.MEASUREMENT
+ description = SENSORS.get(unit_of_measurement)
+ if description is not None:
+ self.entity_description = description
+ else:
+ self._attr_native_unit_of_measurement = unit_of_measurement
+ self._attr_name = f"{name} {elem[FEED_NAME]}"
self._update_attributes(elem)
def _update_attributes(self, elem: dict[str, Any]) -> None:
diff --git a/homeassistant/components/emoncms/strings.json b/homeassistant/components/emoncms/strings.json
index 0d841f2efb4..5769e825944 100644
--- a/homeassistant/components/emoncms/strings.json
+++ b/homeassistant/components/emoncms/strings.json
@@ -24,6 +24,52 @@
"already_configured": "This server is already configured"
}
},
+ "entity": {
+ "sensor": {
+ "energy": {
+ "name": "Energy {emoncms_details}"
+ },
+ "power": {
+ "name": "Power {emoncms_details}"
+ },
+ "percent": {
+ "name": "Percentage {emoncms_details}"
+ },
+ "voltage": {
+ "name": "Voltage {emoncms_details}"
+ },
+ "current": {
+ "name": "Current {emoncms_details}"
+ },
+ "apparent_power": {
+ "name": "Apparent power {emoncms_details}"
+ },
+ "temperature": {
+ "name": "Temperature {emoncms_details}"
+ },
+ "frequency": {
+ "name": "Frequency {emoncms_details}"
+ },
+ "pressure": {
+ "name": "Pressure {emoncms_details}"
+ },
+ "decibel": {
+ "name": "Decibel {emoncms_details}"
+ },
+ "volume": {
+ "name": "Volume {emoncms_details}"
+ },
+ "flow": {
+ "name": "Flow rate {emoncms_details}"
+ },
+ "speed": {
+ "name": "Speed {emoncms_details}"
+ },
+ "concentration": {
+ "name": "Concentration {emoncms_details}"
+ }
+ }
+ },
"options": {
"error": {
"api_error": "[%key:component::emoncms::config::error::api_error%]"
diff --git a/homeassistant/components/emoncms_history/manifest.json b/homeassistant/components/emoncms_history/manifest.json
index faa91e64017..e73f76f7528 100644
--- a/homeassistant/components/emoncms_history/manifest.json
+++ b/homeassistant/components/emoncms_history/manifest.json
@@ -3,5 +3,6 @@
"name": "Emoncms History",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/emoncms_history",
- "iot_class": "local_polling"
+ "iot_class": "local_polling",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/emonitor/config_flow.py b/homeassistant/components/emonitor/config_flow.py
index b924c7df522..833b80f9d47 100644
--- a/homeassistant/components/emonitor/config_flow.py
+++ b/homeassistant/components/emonitor/config_flow.py
@@ -92,6 +92,7 @@ class EmonitorConfigFlow(ConfigFlow, domain=DOMAIN):
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Attempt to confirm."""
+ assert self.discovered_ip is not None
if user_input is not None:
return self.async_create_entry(
title=self.discovered_info["title"],
diff --git a/homeassistant/components/emulated_hue/hue_api.py b/homeassistant/components/emulated_hue/hue_api.py
index 8194d31823d..e13112f20bb 100644
--- a/homeassistant/components/emulated_hue/hue_api.py
+++ b/homeassistant/components/emulated_hue/hue_api.py
@@ -39,7 +39,7 @@ from homeassistant.components.http import KEY_HASS, HomeAssistantView
from homeassistant.components.humidifier import ATTR_HUMIDITY, SERVICE_SET_HUMIDITY
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
- ATTR_COLOR_TEMP,
+ ATTR_COLOR_TEMP_KELVIN,
ATTR_HS_COLOR,
ATTR_TRANSITION,
ATTR_XY_COLOR,
@@ -67,6 +67,7 @@ from homeassistant.const import (
)
from homeassistant.core import Event, EventStateChangedData, State
from homeassistant.helpers.event import async_track_state_change_event
+from homeassistant.util import color as color_util
from homeassistant.util.json import json_loads
from homeassistant.util.network import is_local
@@ -500,7 +501,11 @@ class HueOneLightChangeView(HomeAssistantView):
light.color_temp_supported(color_modes)
and parsed[STATE_COLOR_TEMP] is not None
):
- data[ATTR_COLOR_TEMP] = parsed[STATE_COLOR_TEMP]
+ data[ATTR_COLOR_TEMP_KELVIN] = (
+ color_util.color_temperature_mired_to_kelvin(
+ parsed[STATE_COLOR_TEMP]
+ )
+ )
if (
entity_features & LightEntityFeature.TRANSITION
@@ -702,7 +707,12 @@ def _build_entity_state_dict(entity: State) -> dict[str, Any]:
else:
data[STATE_HUE] = HUE_API_STATE_HUE_MIN
data[STATE_SATURATION] = HUE_API_STATE_SAT_MIN
- data[STATE_COLOR_TEMP] = attributes.get(ATTR_COLOR_TEMP) or 0
+ kelvin = attributes.get(ATTR_COLOR_TEMP_KELVIN)
+ data[STATE_COLOR_TEMP] = (
+ color_util.color_temperature_kelvin_to_mired(kelvin)
+ if kelvin is not None
+ else 0
+ )
else:
data[STATE_BRIGHTNESS] = 0
diff --git a/homeassistant/components/emulated_kasa/manifest.json b/homeassistant/components/emulated_kasa/manifest.json
index d4889c0c5f5..da3912a9d25 100644
--- a/homeassistant/components/emulated_kasa/manifest.json
+++ b/homeassistant/components/emulated_kasa/manifest.json
@@ -6,5 +6,5 @@
"iot_class": "local_push",
"loggers": ["sense_energy"],
"quality_scale": "internal",
- "requirements": ["sense-energy==0.13.3"]
+ "requirements": ["sense-energy==0.13.4"]
}
diff --git a/homeassistant/components/energyzero/__init__.py b/homeassistant/components/energyzero/__init__.py
index 3e1bb830cce..fc2855374dd 100644
--- a/homeassistant/components/energyzero/__init__.py
+++ b/homeassistant/components/energyzero/__init__.py
@@ -2,7 +2,6 @@
from __future__ import annotations
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
@@ -10,10 +9,10 @@ from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
from .const import DOMAIN
-from .coordinator import EnergyZeroDataUpdateCoordinator
+from .coordinator import EnergyZeroConfigEntry, EnergyZeroDataUpdateCoordinator
from .services import async_setup_services
-PLATFORMS = [Platform.SENSOR]
+PLATFORMS: list[Platform] = [Platform.SENSOR]
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
@@ -25,25 +24,22 @@ 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: EnergyZeroConfigEntry) -> bool:
"""Set up EnergyZero from a config entry."""
- coordinator = EnergyZeroDataUpdateCoordinator(hass)
+ coordinator = EnergyZeroDataUpdateCoordinator(hass, entry)
try:
await coordinator.async_config_entry_first_refresh()
except ConfigEntryNotReady:
await coordinator.energyzero.close()
raise
- 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: EnergyZeroConfigEntry) -> bool:
"""Unload EnergyZero 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/energyzero/coordinator.py b/homeassistant/components/energyzero/coordinator.py
index 65955b2ebe6..35054f7b3b7 100644
--- a/homeassistant/components/energyzero/coordinator.py
+++ b/homeassistant/components/energyzero/coordinator.py
@@ -21,6 +21,8 @@ from homeassistant.util import dt as dt_util
from .const import DOMAIN, LOGGER, SCAN_INTERVAL, THRESHOLD_HOUR
+type EnergyZeroConfigEntry = ConfigEntry[EnergyZeroDataUpdateCoordinator]
+
class EnergyZeroData(NamedTuple):
"""Class for defining data in dict."""
@@ -35,13 +37,14 @@ class EnergyZeroDataUpdateCoordinator(DataUpdateCoordinator[EnergyZeroData]):
config_entry: ConfigEntry
- def __init__(self, hass: HomeAssistant) -> None:
+ def __init__(self, hass: HomeAssistant, entry: EnergyZeroConfigEntry) -> None:
"""Initialize global EnergyZero data updater."""
super().__init__(
hass,
LOGGER,
name=DOMAIN,
update_interval=SCAN_INTERVAL,
+ config_entry=entry,
)
self.energyzero = EnergyZero(session=async_get_clientsession(hass))
diff --git a/homeassistant/components/energyzero/diagnostics.py b/homeassistant/components/energyzero/diagnostics.py
index 35d20fee929..0a45d87fee5 100644
--- a/homeassistant/components/energyzero/diagnostics.py
+++ b/homeassistant/components/energyzero/diagnostics.py
@@ -5,12 +5,9 @@ from __future__ import annotations
from datetime import timedelta
from typing import Any
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from . import EnergyZeroDataUpdateCoordinator
-from .const import DOMAIN
-from .coordinator import EnergyZeroData
+from .coordinator import EnergyZeroConfigEntry, EnergyZeroData
def get_gas_price(data: EnergyZeroData, hours: int) -> float | None:
@@ -32,30 +29,31 @@ def get_gas_price(data: EnergyZeroData, hours: int) -> float | None:
async def async_get_config_entry_diagnostics(
- hass: HomeAssistant, entry: ConfigEntry
+ hass: HomeAssistant, entry: EnergyZeroConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
- coordinator: EnergyZeroDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
+ coordinator_data = entry.runtime_data.data
+ energy_today = coordinator_data.energy_today
return {
"entry": {
"title": entry.title,
},
"energy": {
- "current_hour_price": coordinator.data.energy_today.current_price,
- "next_hour_price": coordinator.data.energy_today.price_at_time(
- coordinator.data.energy_today.utcnow() + timedelta(hours=1)
+ "current_hour_price": energy_today.current_price,
+ "next_hour_price": energy_today.price_at_time(
+ energy_today.utcnow() + timedelta(hours=1)
),
- "average_price": coordinator.data.energy_today.average_price,
- "max_price": coordinator.data.energy_today.extreme_prices[1],
- "min_price": coordinator.data.energy_today.extreme_prices[0],
- "highest_price_time": coordinator.data.energy_today.highest_price_time,
- "lowest_price_time": coordinator.data.energy_today.lowest_price_time,
- "percentage_of_max": coordinator.data.energy_today.pct_of_max_price,
- "hours_priced_equal_or_lower": coordinator.data.energy_today.hours_priced_equal_or_lower,
+ "average_price": energy_today.average_price,
+ "max_price": energy_today.extreme_prices[1],
+ "min_price": energy_today.extreme_prices[0],
+ "highest_price_time": energy_today.highest_price_time,
+ "lowest_price_time": energy_today.lowest_price_time,
+ "percentage_of_max": energy_today.pct_of_max_price,
+ "hours_priced_equal_or_lower": energy_today.hours_priced_equal_or_lower,
},
"gas": {
- "current_hour_price": get_gas_price(coordinator.data, 0),
- "next_hour_price": get_gas_price(coordinator.data, 1),
+ "current_hour_price": get_gas_price(coordinator_data, 0),
+ "next_hour_price": get_gas_price(coordinator_data, 1),
},
}
diff --git a/homeassistant/components/energyzero/manifest.json b/homeassistant/components/energyzero/manifest.json
index 807a0419967..b647faebe1d 100644
--- a/homeassistant/components/energyzero/manifest.json
+++ b/homeassistant/components/energyzero/manifest.json
@@ -4,7 +4,8 @@
"codeowners": ["@klaasnicolaas"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/energyzero",
+ "integration_type": "service",
"iot_class": "cloud_polling",
- "quality_scale": "platinum",
- "requirements": ["energyzero==2.1.1"]
+ "requirements": ["energyzero==2.1.1"],
+ "single_config_entry": true
}
diff --git a/homeassistant/components/energyzero/sensor.py b/homeassistant/components/energyzero/sensor.py
index f65f7bd559c..141ac793fba 100644
--- a/homeassistant/components/energyzero/sensor.py
+++ b/homeassistant/components/energyzero/sensor.py
@@ -13,7 +13,6 @@ from homeassistant.components.sensor import (
SensorEntityDescription,
SensorStateClass,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CURRENCY_EURO,
PERCENTAGE,
@@ -27,7 +26,11 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, SERVICE_TYPE_DEVICE_NAMES
-from .coordinator import EnergyZeroData, EnergyZeroDataUpdateCoordinator
+from .coordinator import (
+ EnergyZeroConfigEntry,
+ EnergyZeroData,
+ EnergyZeroDataUpdateCoordinator,
+)
@dataclass(frozen=True, kw_only=True)
@@ -142,10 +145,12 @@ def get_gas_price(data: EnergyZeroData, hours: int) -> float | None:
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: EnergyZeroConfigEntry,
+ async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up EnergyZero Sensors based on a config entry."""
- coordinator: EnergyZeroDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
+ coordinator = entry.runtime_data
async_add_entities(
EnergyZeroSensorEntity(
coordinator=coordinator,
diff --git a/homeassistant/components/energyzero/services.py b/homeassistant/components/energyzero/services.py
index d98699c5c08..c47958b670f 100644
--- a/homeassistant/components/energyzero/services.py
+++ b/homeassistant/components/energyzero/services.py
@@ -10,7 +10,7 @@ from typing import Final
from energyzero import Electricity, Gas, VatOption
import voluptuous as vol
-from homeassistant.config_entries import ConfigEntry, ConfigEntryState
+from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import (
HomeAssistant,
ServiceCall,
@@ -23,7 +23,7 @@ from homeassistant.helpers import selector
from homeassistant.util import dt as dt_util
from .const import DOMAIN
-from .coordinator import EnergyZeroDataUpdateCoordinator
+from .coordinator import EnergyZeroConfigEntry, EnergyZeroDataUpdateCoordinator
ATTR_CONFIG_ENTRY: Final = "config_entry"
ATTR_START: Final = "start"
@@ -83,12 +83,12 @@ def __serialize_prices(prices: Electricity | Gas) -> ServiceResponse:
}
-def __get_coordinator(
- hass: HomeAssistant, call: ServiceCall
-) -> EnergyZeroDataUpdateCoordinator:
+def __get_coordinator(call: ServiceCall) -> EnergyZeroDataUpdateCoordinator:
"""Get the coordinator from the entry."""
entry_id: str = call.data[ATTR_CONFIG_ENTRY]
- entry: ConfigEntry | None = hass.config_entries.async_get_entry(entry_id)
+ entry: EnergyZeroConfigEntry | None = call.hass.config_entries.async_get_entry(
+ entry_id
+ )
if not entry:
raise ServiceValidationError(
@@ -107,17 +107,15 @@ def __get_coordinator(
},
)
- coordinator: EnergyZeroDataUpdateCoordinator = hass.data[DOMAIN][entry_id]
- return coordinator
+ return entry.runtime_data
async def __get_prices(
call: ServiceCall,
*,
- hass: HomeAssistant,
price_type: PriceType,
) -> ServiceResponse:
- coordinator = __get_coordinator(hass, call)
+ coordinator = __get_coordinator(call)
start = __get_date(call.data.get(ATTR_START))
end = __get_date(call.data.get(ATTR_END))
@@ -152,14 +150,14 @@ def async_setup_services(hass: HomeAssistant) -> None:
hass.services.async_register(
DOMAIN,
GAS_SERVICE_NAME,
- partial(__get_prices, hass=hass, price_type=PriceType.GAS),
+ partial(__get_prices, price_type=PriceType.GAS),
schema=SERVICE_SCHEMA,
supports_response=SupportsResponse.ONLY,
)
hass.services.async_register(
DOMAIN,
ENERGY_SERVICE_NAME,
- partial(__get_prices, hass=hass, price_type=PriceType.ENERGY),
+ partial(__get_prices, price_type=PriceType.ENERGY),
schema=SERVICE_SCHEMA,
supports_response=SupportsResponse.ONLY,
)
diff --git a/homeassistant/components/enigma2/config_flow.py b/homeassistant/components/enigma2/config_flow.py
index e9502a0f7cd..b0649a8368d 100644
--- a/homeassistant/components/enigma2/config_flow.py
+++ b/homeassistant/components/enigma2/config_flow.py
@@ -133,7 +133,8 @@ class Enigma2ConfigFlowHandler(ConfigFlow, domain=DOMAIN):
except Exception: # noqa: BLE001
errors = {"base": "unknown"}
else:
- await self.async_set_unique_id(about["info"]["ifaces"][0]["mac"])
+ unique_id = about["info"]["ifaces"][0]["mac"] or self.unique_id
+ await self.async_set_unique_id(unique_id)
self._abort_if_unique_id_configured()
return errors
diff --git a/homeassistant/components/enigma2/coordinator.py b/homeassistant/components/enigma2/coordinator.py
index a35e74f582f..d5bbf2c0ce5 100644
--- a/homeassistant/components/enigma2/coordinator.py
+++ b/homeassistant/components/enigma2/coordinator.py
@@ -35,6 +35,7 @@ class Enigma2UpdateCoordinator(DataUpdateCoordinator[OpenWebIfStatus]):
"""The Enigma2 data update coordinator."""
device: OpenWebIfDevice
+ unique_id: str | None
def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None:
"""Initialize the Enigma2 data update coordinator."""
@@ -64,6 +65,10 @@ class Enigma2UpdateCoordinator(DataUpdateCoordinator[OpenWebIfStatus]):
name=config_entry.data[CONF_HOST],
)
+ # set the unique ID for the entities to the config entry unique ID
+ # for devices that don't report a MAC address
+ self.unique_id = config_entry.unique_id
+
async def _async_setup(self) -> None:
"""Provide needed data to the device info."""
@@ -71,16 +76,20 @@ class Enigma2UpdateCoordinator(DataUpdateCoordinator[OpenWebIfStatus]):
self.device.mac_address = about["info"]["ifaces"][0]["mac"]
self.device_info["model"] = about["info"]["model"]
self.device_info["manufacturer"] = about["info"]["brand"]
- self.device_info[ATTR_IDENTIFIERS] = {
- (DOMAIN, format_mac(iface["mac"]))
- for iface in about["info"]["ifaces"]
- if "mac" in iface and iface["mac"] is not None
- }
- self.device_info[ATTR_CONNECTIONS] = {
- (CONNECTION_NETWORK_MAC, format_mac(iface["mac"]))
- for iface in about["info"]["ifaces"]
- if "mac" in iface and iface["mac"] is not None
- }
+ if self.device.mac_address is not None:
+ self.device_info[ATTR_IDENTIFIERS] = {
+ (DOMAIN, format_mac(iface["mac"]))
+ for iface in about["info"]["ifaces"]
+ if "mac" in iface and iface["mac"] is not None
+ }
+ self.device_info[ATTR_CONNECTIONS] = {
+ (CONNECTION_NETWORK_MAC, format_mac(iface["mac"]))
+ for iface in about["info"]["ifaces"]
+ if "mac" in iface and iface["mac"] is not None
+ }
+ self.unique_id = self.device.mac_address
+ elif self.unique_id is not None:
+ self.device_info[ATTR_IDENTIFIERS] = {(DOMAIN, self.unique_id)}
async def _async_update_data(self) -> OpenWebIfStatus:
await self.device.update()
diff --git a/homeassistant/components/enigma2/manifest.json b/homeassistant/components/enigma2/manifest.json
index 1a0875b04c0..2bb299722b7 100644
--- a/homeassistant/components/enigma2/manifest.json
+++ b/homeassistant/components/enigma2/manifest.json
@@ -7,5 +7,5 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["openwebif"],
- "requirements": ["openwebifpy==4.2.7"]
+ "requirements": ["openwebifpy==4.3.1"]
}
diff --git a/homeassistant/components/enigma2/media_player.py b/homeassistant/components/enigma2/media_player.py
index 8287e055814..ee0de15c3fb 100644
--- a/homeassistant/components/enigma2/media_player.py
+++ b/homeassistant/components/enigma2/media_player.py
@@ -4,7 +4,6 @@ from __future__ import annotations
import contextlib
from logging import getLogger
-from typing import cast
from aiohttp.client_exceptions import ServerDisconnectedError
from openwebif.enums import PowerState, RemoteControlCodes, SetVolumeOption
@@ -15,7 +14,6 @@ from homeassistant.components.media_player import (
MediaPlayerState,
MediaType,
)
-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 CoordinatorEntity
@@ -65,10 +63,7 @@ class Enigma2Device(CoordinatorEntity[Enigma2UpdateCoordinator], MediaPlayerEnti
super().__init__(coordinator)
- self._attr_unique_id = (
- coordinator.device.mac_address
- or cast(ConfigEntry, coordinator.config_entry).entry_id
- )
+ self._attr_unique_id = coordinator.unique_id
self._attr_device_info = coordinator.device_info
diff --git a/homeassistant/components/enphase_envoy/__init__.py b/homeassistant/components/enphase_envoy/__init__.py
index db36cab1288..cdbb7080674 100644
--- a/homeassistant/components/enphase_envoy/__init__.py
+++ b/homeassistant/components/enphase_envoy/__init__.py
@@ -51,8 +51,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: EnphaseConfigEntry) -> b
# 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}, "
- f"found {envoy.serial_number}"
+ translation_domain=DOMAIN,
+ translation_key="unexpected_device",
+ translation_placeholders={
+ "host": host,
+ "expected_serial": str(entry.unique_id),
+ "actual_serial": str(envoy.serial_number),
+ },
)
entry.runtime_data = coordinator
@@ -72,7 +77,7 @@ async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
async def async_unload_entry(hass: HomeAssistant, entry: EnphaseConfigEntry) -> bool:
"""Unload a config entry."""
- coordinator: EnphaseUpdateCoordinator = entry.runtime_data
+ coordinator = entry.runtime_data
coordinator.async_cancel_token_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 6be29d19ecb..1ad6f259de1 100644
--- a/homeassistant/components/enphase_envoy/binary_sensor.py
+++ b/homeassistant/components/enphase_envoy/binary_sensor.py
@@ -22,6 +22,8 @@ from .const import DOMAIN
from .coordinator import EnphaseConfigEntry, EnphaseUpdateCoordinator
from .entity import EnvoyBaseEntity
+PARALLEL_UPDATES = 0
+
@dataclass(frozen=True, kw_only=True)
class EnvoyEnchargeBinarySensorEntityDescription(BinarySensorEntityDescription):
diff --git a/homeassistant/components/enphase_envoy/config_flow.py b/homeassistant/components/enphase_envoy/config_flow.py
index 23c769293c8..1a2186d305e 100644
--- a/homeassistant/components/enphase_envoy/config_flow.py
+++ b/homeassistant/components/enphase_envoy/config_flow.py
@@ -31,6 +31,7 @@ from .const import (
OPTION_DISABLE_KEEP_ALIVE,
OPTION_DISABLE_KEEP_ALIVE_DEFAULT_VALUE,
)
+from .coordinator import EnphaseConfigEntry
_LOGGER = logging.getLogger(__name__)
@@ -67,7 +68,7 @@ class EnphaseConfigFlow(ConfigFlow, domain=DOMAIN):
@staticmethod
@callback
def async_get_options_flow(
- config_entry: ConfigEntry,
+ config_entry: EnphaseConfigEntry,
) -> EnvoyOptionsFlowHandler:
"""Options flow handler for Enphase_Envoy."""
return EnvoyOptionsFlowHandler()
@@ -140,9 +141,13 @@ class EnphaseConfigFlow(ConfigFlow, domain=DOMAIN):
and entry.data[CONF_HOST] == self.ip_address
):
_LOGGER.debug(
- "Zeroconf update envoy with this ip and blank serial in unique_id",
+ "Zeroconf update envoy with this ip and blank unique_id",
)
- title = f"{ENVOY} {serial}" if entry.title == ENVOY else ENVOY
+ # Found an entry with blank unique_id (prior deleted) with same ip
+ # If the title is still default shorthand 'Envoy' then append serial
+ # to differentiate multiple Envoy. Don't change the title if any other
+ # title is still present in the old entry.
+ title = f"{ENVOY} {serial}" if entry.title == ENVOY else entry.title
return self.async_update_reload_and_abort(
entry, title=title, unique_id=serial, reason="already_configured"
)
diff --git a/homeassistant/components/enphase_envoy/coordinator.py b/homeassistant/components/enphase_envoy/coordinator.py
index 00bc7666f78..67f43ca64a8 100644
--- a/homeassistant/components/enphase_envoy/coordinator.py
+++ b/homeassistant/components/enphase_envoy/coordinator.py
@@ -18,7 +18,7 @@ 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 .const import INVALID_AUTH_ERRORS
+from .const import DOMAIN, INVALID_AUTH_ERRORS
SCAN_INTERVAL = timedelta(seconds=60)
@@ -37,6 +37,7 @@ class EnphaseUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
envoy_serial_number: str
envoy_firmware: str
+ config_entry: EnphaseConfigEntry
def __init__(
self, hass: HomeAssistant, envoy: Envoy, entry: EnphaseConfigEntry
@@ -44,7 +45,6 @@ class EnphaseUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"""Initialize DataUpdateCoordinator for the envoy."""
self.envoy = envoy
entry_data = entry.data
- self.entry = entry
self.username = entry_data[CONF_USERNAME]
self.password = entry_data[CONF_PASSWORD]
self._setup_complete = False
@@ -107,7 +107,7 @@ class EnphaseUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
await envoy.setup()
assert envoy.serial_number is not None
self.envoy_serial_number = envoy.serial_number
- if token := self.entry.data.get(CONF_TOKEN):
+ if token := self.config_entry.data.get(CONF_TOKEN):
with contextlib.suppress(*INVALID_AUTH_ERRORS):
# Always set the username and password
# so we can refresh the token if needed
@@ -136,9 +136,9 @@ class EnphaseUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
# as long as the token is valid
_LOGGER.debug("%s: Updating token in config entry from auth", self.name)
self.hass.config_entries.async_update_entry(
- self.entry,
+ self.config_entry,
data={
- **self.entry.data,
+ **self.config_entry.data,
CONF_TOKEN: envoy.auth.token,
},
)
@@ -158,9 +158,23 @@ class EnphaseUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
# token likely expired or firmware changed, try to re-authenticate
self._setup_complete = False
continue
- raise ConfigEntryAuthFailed from err
+ raise ConfigEntryAuthFailed(
+ translation_domain=DOMAIN,
+ translation_key="authentication_error",
+ translation_placeholders={
+ "host": envoy.host,
+ "args": err.args[0],
+ },
+ ) from err
except EnvoyError as err:
- raise UpdateFailed(f"Error communicating with API: {err}") from err
+ raise UpdateFailed(
+ translation_domain=DOMAIN,
+ translation_key="envoy_error",
+ translation_placeholders={
+ "host": envoy.host,
+ "args": err.args[0],
+ },
+ ) from err
# if we have a firmware version from previous setup, compare to current one
# when envoy gets new firmware there will be an authentication failure
@@ -175,7 +189,7 @@ class EnphaseUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
)
# reload the integration to get all established again
self.hass.async_create_task(
- self.hass.config_entries.async_reload(self.entry.entry_id)
+ self.hass.config_entries.async_reload(self.config_entry.entry_id)
)
# remember firmware version for next time
self.envoy_firmware = envoy.firmware
diff --git a/homeassistant/components/enphase_envoy/manifest.json b/homeassistant/components/enphase_envoy/manifest.json
index aa06a1ff79f..bdc90e6c634 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.22.0"],
+ "requirements": ["pyenphase==1.23.0"],
"zeroconf": [
{
"type": "_enphase-envoy._tcp.local."
diff --git a/homeassistant/components/enphase_envoy/number.py b/homeassistant/components/enphase_envoy/number.py
index f27335b1f4c..a62913a4c0b 100644
--- a/homeassistant/components/enphase_envoy/number.py
+++ b/homeassistant/components/enphase_envoy/number.py
@@ -25,6 +25,8 @@ from .const import DOMAIN
from .coordinator import EnphaseConfigEntry, EnphaseUpdateCoordinator
from .entity import EnvoyBaseEntity
+PARALLEL_UPDATES = 1
+
@dataclass(frozen=True, kw_only=True)
class EnvoyRelayNumberEntityDescription(NumberEntityDescription):
diff --git a/homeassistant/components/enphase_envoy/quality_scale.yaml b/homeassistant/components/enphase_envoy/quality_scale.yaml
new file mode 100644
index 00000000000..a7038b4e0da
--- /dev/null
+++ b/homeassistant/components/enphase_envoy/quality_scale.yaml
@@ -0,0 +1,105 @@
+rules:
+ # Bronze
+ action-setup:
+ status: done
+ comment: only actions implemented are platform native ones.
+ appropriate-polling:
+ status: done
+ 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
+ dependency-transparency: done
+ docs-actions:
+ status: done
+ comment: https://www.home-assistant.io/integrations/enphase_envoy/#actions
+ docs-high-level-description:
+ status: done
+ comment: https://www.home-assistant.io/integrations/enphase_envoy
+ docs-installation-instructions:
+ status: done
+ comment: https://www.home-assistant.io/integrations/enphase_envoy#prerequisites
+ docs-removal-instructions:
+ status: done
+ comment: https://www.home-assistant.io/integrations/enphase_envoy#removing-the-integration
+ entity-event-setup:
+ status: done
+ comment: no events used.
+ 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: todo
+ comment: |
+ needs to raise appropriate error when exception occurs.
+ Pending https://github.com/pyenphase/pyenphase/pull/194
+ config-entry-unloading: done
+ docs-configuration-parameters:
+ status: done
+ comment: https://www.home-assistant.io/integrations/enphase_envoy#configuration
+ docs-installation-parameters:
+ status: done
+ comment: https://www.home-assistant.io/integrations/enphase_envoy#required-manual-input
+ entity-unavailable: done
+ integration-owner: done
+ log-when-unavailable: done
+ parallel-updates:
+ 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
+
+ # Gold
+ devices: done
+ diagnostics: done
+ discovery-update-info: done
+ discovery: done
+ docs-data-update:
+ status: done
+ comment: https://www.home-assistant.io/integrations/enphase_envoy#data-updates
+ docs-examples:
+ status: todo
+ comment: add blue-print examples, if any
+ docs-known-limitations: todo
+ docs-supported-devices:
+ status: done
+ comment: https://www.home-assistant.io/integrations/enphase_envoy#supported-devices
+ docs-supported-functions: todo
+ docs-troubleshooting:
+ status: done
+ comment: https://www.home-assistant.io/integrations/enphase_envoy#troubleshooting
+ docs-use-cases: todo
+ dynamic-devices: todo
+ entity-category: todo
+ entity-device-class: done
+ entity-disabled-by-default: done
+ entity-translations: done
+ exception-translations: done
+ icon-translations: todo
+ reconfiguration-flow: done
+ repair-issues:
+ status: exempt
+ comment: no general issues or repair.py
+ stale-devices: todo
+
+ # Platinum
+ async-dependency: done
+ inject-websession: done
+ strict-typing: done
diff --git a/homeassistant/components/enphase_envoy/select.py b/homeassistant/components/enphase_envoy/select.py
index 903c2c1edf6..d9729a16683 100644
--- a/homeassistant/components/enphase_envoy/select.py
+++ b/homeassistant/components/enphase_envoy/select.py
@@ -20,6 +20,8 @@ from .const import DOMAIN
from .coordinator import EnphaseConfigEntry, EnphaseUpdateCoordinator
from .entity import EnvoyBaseEntity
+PARALLEL_UPDATES = 1
+
@dataclass(frozen=True, kw_only=True)
class EnvoyRelaySelectEntityDescription(SelectEntityDescription):
diff --git a/homeassistant/components/enphase_envoy/sensor.py b/homeassistant/components/enphase_envoy/sensor.py
index 20d610e4b71..62ae5b621ac 100644
--- a/homeassistant/components/enphase_envoy/sensor.py
+++ b/homeassistant/components/enphase_envoy/sensor.py
@@ -10,6 +10,8 @@ from operator import attrgetter
from typing import TYPE_CHECKING
from pyenphase import (
+ EnvoyACBPower,
+ EnvoyBatteryAggregate,
EnvoyEncharge,
EnvoyEnchargeAggregate,
EnvoyEnchargePower,
@@ -59,6 +61,8 @@ _LOGGER = logging.getLogger(__name__)
INVERTERS_KEY = "inverters"
LAST_REPORTED_KEY = "last_reported"
+PARALLEL_UPDATES = 0
+
@dataclass(frozen=True, kw_only=True)
class EnvoyInverterSensorEntityDescription(SensorEntityDescription):
@@ -721,6 +725,78 @@ ENCHARGE_AGGREGATE_SENSORS = (
)
+@dataclass(frozen=True, kw_only=True)
+class EnvoyAcbBatterySensorEntityDescription(SensorEntityDescription):
+ """Describes an Envoy ACB Battery sensor entity."""
+
+ value_fn: Callable[[EnvoyACBPower], int | str]
+
+
+ACB_BATTERY_POWER_SENSORS = (
+ EnvoyAcbBatterySensorEntityDescription(
+ key="acb_power",
+ native_unit_of_measurement=UnitOfPower.WATT,
+ device_class=SensorDeviceClass.POWER,
+ value_fn=attrgetter("power"),
+ ),
+ EnvoyAcbBatterySensorEntityDescription(
+ key="acb_soc",
+ native_unit_of_measurement=PERCENTAGE,
+ device_class=SensorDeviceClass.BATTERY,
+ value_fn=attrgetter("state_of_charge"),
+ ),
+ EnvoyAcbBatterySensorEntityDescription(
+ key="acb_battery_state",
+ translation_key="acb_battery_state",
+ device_class=SensorDeviceClass.ENUM,
+ options=["discharging", "idle", "charging", "full"],
+ value_fn=attrgetter("state"),
+ ),
+)
+
+ACB_BATTERY_ENERGY_SENSORS = (
+ EnvoyAcbBatterySensorEntityDescription(
+ key="acb_available_energy",
+ translation_key="acb_available_energy",
+ native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
+ device_class=SensorDeviceClass.ENERGY_STORAGE,
+ value_fn=attrgetter("charge_wh"),
+ ),
+)
+
+
+@dataclass(frozen=True, kw_only=True)
+class EnvoyAggregateBatterySensorEntityDescription(SensorEntityDescription):
+ """Describes an Envoy aggregate Ensemble and ACB Battery sensor entity."""
+
+ value_fn: Callable[[EnvoyBatteryAggregate], int]
+
+
+AGGREGATE_BATTERY_SENSORS = (
+ EnvoyAggregateBatterySensorEntityDescription(
+ key="aggregated_soc",
+ translation_key="aggregated_soc",
+ native_unit_of_measurement=PERCENTAGE,
+ device_class=SensorDeviceClass.BATTERY,
+ value_fn=attrgetter("state_of_charge"),
+ ),
+ EnvoyAggregateBatterySensorEntityDescription(
+ key="aggregated_available_energy",
+ translation_key="aggregated_available_energy",
+ native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
+ device_class=SensorDeviceClass.ENERGY_STORAGE,
+ value_fn=attrgetter("available_energy"),
+ ),
+ EnvoyAggregateBatterySensorEntityDescription(
+ key="aggregated_max_battery_capacity",
+ translation_key="aggregated_max_capacity",
+ native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
+ device_class=SensorDeviceClass.ENERGY_STORAGE,
+ value_fn=attrgetter("max_available_capacity"),
+ ),
+)
+
+
async def async_setup_entry(
hass: HomeAssistant,
config_entry: EnphaseConfigEntry,
@@ -845,6 +921,20 @@ async def async_setup_entry(
EnvoyEnpowerEntity(coordinator, description)
for description in ENPOWER_SENSORS
)
+ if envoy_data.acb_power:
+ entities.extend(
+ EnvoyAcbBatteryPowerEntity(coordinator, description)
+ for description in ACB_BATTERY_POWER_SENSORS
+ )
+ entities.extend(
+ EnvoyAcbBatteryEnergyEntity(coordinator, description)
+ for description in ACB_BATTERY_ENERGY_SENSORS
+ )
+ if envoy_data.battery_aggregate:
+ entities.extend(
+ AggregateBatteryEntity(coordinator, description)
+ for description in AGGREGATE_BATTERY_SENSORS
+ )
async_add_entities(entities)
@@ -1226,3 +1316,60 @@ class EnvoyEnpowerEntity(EnvoySensorBaseEntity):
enpower = self.data.enpower
assert enpower is not None
return self.entity_description.value_fn(enpower)
+
+
+class EnvoyAcbBatteryPowerEntity(EnvoySensorBaseEntity):
+ """Envoy ACB Battery power sensor entity."""
+
+ entity_description: EnvoyAcbBatterySensorEntityDescription
+
+ def __init__(
+ self,
+ coordinator: EnphaseUpdateCoordinator,
+ description: EnvoyAcbBatterySensorEntityDescription,
+ ) -> None:
+ """Initialize ACB Battery entity."""
+ super().__init__(coordinator, description)
+ acb_data = self.data.acb_power
+ assert acb_data is not None
+ self._attr_unique_id = f"{self.envoy_serial_num}_{description.key}"
+ self._attr_device_info = DeviceInfo(
+ identifiers={(DOMAIN, f"{self.envoy_serial_num}_acb")},
+ manufacturer="Enphase",
+ model="ACB",
+ name=f"ACB {self.envoy_serial_num}",
+ via_device=(DOMAIN, self.envoy_serial_num),
+ )
+
+ @property
+ def native_value(self) -> int | str | None:
+ """Return the state of the ACB Battery power sensors."""
+ acb = self.data.acb_power
+ assert acb is not None
+ return self.entity_description.value_fn(acb)
+
+
+class EnvoyAcbBatteryEnergyEntity(EnvoySystemSensorEntity):
+ """Envoy combined ACB and Ensemble Battery Aggregate energy sensor entity."""
+
+ entity_description: EnvoyAcbBatterySensorEntityDescription
+
+ @property
+ def native_value(self) -> int | str:
+ """Return the state of the aggregate energy sensors."""
+ acb = self.data.acb_power
+ assert acb is not None
+ return self.entity_description.value_fn(acb)
+
+
+class AggregateBatteryEntity(EnvoySystemSensorEntity):
+ """Envoy combined ACB and Ensemble Battery Aggregate sensor entity."""
+
+ entity_description: EnvoyAggregateBatterySensorEntityDescription
+
+ @property
+ def native_value(self) -> int:
+ """Return the state of the aggregate sensors."""
+ battery_aggregate = self.data.battery_aggregate
+ assert battery_aggregate is not None
+ return self.entity_description.value_fn(battery_aggregate)
diff --git a/homeassistant/components/enphase_envoy/strings.json b/homeassistant/components/enphase_envoy/strings.json
index 2d91b3b0960..a78d0bc032a 100644
--- a/homeassistant/components/enphase_envoy/strings.json
+++ b/homeassistant/components/enphase_envoy/strings.json
@@ -337,6 +337,30 @@
},
"configured_reserve_soc": {
"name": "Configured reserve battery level"
+ },
+ "acb_battery_state": {
+ "name": "Battery state",
+ "state": {
+ "discharging": "Discharging",
+ "idle": "[%key:common::state::idle%]",
+ "charging": "Charging",
+ "full": "Full"
+ }
+ },
+ "acb_available_energy": {
+ "name": "Available ACB battery energy"
+ },
+ "acb_max_capacity": {
+ "name": "ACB Battery capacity"
+ },
+ "aggregated_available_energy": {
+ "name": "Aggregated available battery energy"
+ },
+ "aggregated_max_capacity": {
+ "name": "Aggregated Battery capacity"
+ },
+ "aggregated_soc": {
+ "name": "Aggregated battery soc"
}
},
"switch": {
@@ -347,5 +371,16 @@
"name": "Grid enabled"
}
}
+ },
+ "exceptions": {
+ "unexpected_device": {
+ "message": "Unexpected Envoy serial-number found at {host}; expected {expected_serial}, found {actual_serial}"
+ },
+ "authentication_error": {
+ "message": "Envoy authentication failure on {host}: {args}"
+ },
+ "envoy_error": {
+ "message": "Error communicating with Envoy API on {host}: {args}"
+ }
}
}
diff --git a/homeassistant/components/enphase_envoy/switch.py b/homeassistant/components/enphase_envoy/switch.py
index 14451aaf266..5170b694587 100644
--- a/homeassistant/components/enphase_envoy/switch.py
+++ b/homeassistant/components/enphase_envoy/switch.py
@@ -20,6 +20,8 @@ from .const import DOMAIN
from .coordinator import EnphaseConfigEntry, EnphaseUpdateCoordinator
from .entity import EnvoyBaseEntity
+PARALLEL_UPDATES = 1
+
@dataclass(frozen=True, kw_only=True)
class EnvoyEnpowerSwitchEntityDescription(SwitchEntityDescription):
diff --git a/homeassistant/components/entur_public_transport/manifest.json b/homeassistant/components/entur_public_transport/manifest.json
index f75099c2c27..5e25eb4b4a7 100644
--- a/homeassistant/components/entur_public_transport/manifest.json
+++ b/homeassistant/components/entur_public_transport/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/entur_public_transport",
"iot_class": "cloud_polling",
"loggers": ["enturclient"],
+ "quality_scale": "legacy",
"requirements": ["enturclient==0.2.4"]
}
diff --git a/homeassistant/components/envisalink/manifest.json b/homeassistant/components/envisalink/manifest.json
index 0cf9f165aa2..42587aa7c2f 100644
--- a/homeassistant/components/envisalink/manifest.json
+++ b/homeassistant/components/envisalink/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/envisalink",
"iot_class": "local_push",
"loggers": ["pyenvisalink"],
+ "quality_scale": "legacy",
"requirements": ["pyenvisalink==4.7"]
}
diff --git a/homeassistant/components/ephember/climate.py b/homeassistant/components/ephember/climate.py
index 44e5986970d..cedad8b76e2 100644
--- a/homeassistant/components/ephember/climate.py
+++ b/homeassistant/components/ephember/climate.py
@@ -84,7 +84,6 @@ class EphEmberThermostat(ClimateEntity):
_attr_hvac_modes = OPERATION_LIST
_attr_temperature_unit = UnitOfTemperature.CELSIUS
- _enable_turn_on_off_backwards_compatibility = False
def __init__(self, ember, zone):
"""Initialize the thermostat."""
diff --git a/homeassistant/components/ephember/manifest.json b/homeassistant/components/ephember/manifest.json
index dd7938ccbd2..547ab2918f5 100644
--- a/homeassistant/components/ephember/manifest.json
+++ b/homeassistant/components/ephember/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/ephember",
"iot_class": "local_polling",
"loggers": ["pyephember"],
+ "quality_scale": "legacy",
"requirements": ["pyephember==0.3.1"]
}
diff --git a/homeassistant/components/eq3btsmart/__init__.py b/homeassistant/components/eq3btsmart/__init__.py
index f63e627ea7d..4493f944db3 100644
--- a/homeassistant/components/eq3btsmart/__init__.py
+++ b/homeassistant/components/eq3btsmart/__init__.py
@@ -15,17 +15,24 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.dispatcher import async_dispatcher_send
-from .const import DOMAIN, SIGNAL_THERMOSTAT_CONNECTED, SIGNAL_THERMOSTAT_DISCONNECTED
+from .const import SIGNAL_THERMOSTAT_CONNECTED, SIGNAL_THERMOSTAT_DISCONNECTED
from .models import Eq3Config, Eq3ConfigEntryData
PLATFORMS = [
+ Platform.BINARY_SENSOR,
Platform.CLIMATE,
+ Platform.NUMBER,
+ Platform.SENSOR,
+ Platform.SWITCH,
]
_LOGGER = logging.getLogger(__name__)
-async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+type Eq3ConfigEntry = ConfigEntry[Eq3ConfigEntryData]
+
+
+async def async_setup_entry(hass: HomeAssistant, entry: Eq3ConfigEntry) -> bool:
"""Handle config entry setup."""
mac_address: str | None = entry.unique_id
@@ -53,12 +60,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
ble_device=device,
)
- eq3_config_entry = Eq3ConfigEntryData(eq3_config=eq3_config, thermostat=thermostat)
- hass.data.setdefault(DOMAIN, {})[entry.entry_id] = eq3_config_entry
-
+ entry.runtime_data = Eq3ConfigEntryData(
+ eq3_config=eq3_config, thermostat=thermostat
+ )
entry.async_on_unload(entry.add_update_listener(update_listener))
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
-
entry.async_create_background_task(
hass, _async_run_thermostat(hass, entry), entry.entry_id
)
@@ -66,29 +72,27 @@ 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: Eq3ConfigEntry) -> bool:
"""Handle config entry unload."""
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
- eq3_config_entry: Eq3ConfigEntryData = hass.data[DOMAIN].pop(entry.entry_id)
- await eq3_config_entry.thermostat.async_disconnect()
+ await entry.runtime_data.thermostat.async_disconnect()
return unload_ok
-async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
+async def update_listener(hass: HomeAssistant, entry: Eq3ConfigEntry) -> None:
"""Handle config entry update."""
await hass.config_entries.async_reload(entry.entry_id)
-async def _async_run_thermostat(hass: HomeAssistant, entry: ConfigEntry) -> None:
+async def _async_run_thermostat(hass: HomeAssistant, entry: Eq3ConfigEntry) -> None:
"""Run the thermostat."""
- eq3_config_entry: Eq3ConfigEntryData = hass.data[DOMAIN][entry.entry_id]
- thermostat = eq3_config_entry.thermostat
- mac_address = eq3_config_entry.eq3_config.mac_address
- scan_interval = eq3_config_entry.eq3_config.scan_interval
+ thermostat = entry.runtime_data.thermostat
+ mac_address = entry.runtime_data.eq3_config.mac_address
+ scan_interval = entry.runtime_data.eq3_config.scan_interval
await _async_reconnect_thermostat(hass, entry)
@@ -117,13 +121,14 @@ async def _async_run_thermostat(hass: HomeAssistant, entry: ConfigEntry) -> None
await asyncio.sleep(scan_interval)
-async def _async_reconnect_thermostat(hass: HomeAssistant, entry: ConfigEntry) -> None:
+async def _async_reconnect_thermostat(
+ hass: HomeAssistant, entry: Eq3ConfigEntry
+) -> None:
"""Reconnect the thermostat."""
- eq3_config_entry: Eq3ConfigEntryData = hass.data[DOMAIN][entry.entry_id]
- thermostat = eq3_config_entry.thermostat
- mac_address = eq3_config_entry.eq3_config.mac_address
- scan_interval = eq3_config_entry.eq3_config.scan_interval
+ thermostat = entry.runtime_data.thermostat
+ mac_address = entry.runtime_data.eq3_config.mac_address
+ scan_interval = entry.runtime_data.eq3_config.scan_interval
while True:
try:
diff --git a/homeassistant/components/eq3btsmart/binary_sensor.py b/homeassistant/components/eq3btsmart/binary_sensor.py
new file mode 100644
index 00000000000..27525d47972
--- /dev/null
+++ b/homeassistant/components/eq3btsmart/binary_sensor.py
@@ -0,0 +1,86 @@
+"""Platform for eq3 binary sensor entities."""
+
+from collections.abc import Callable
+from dataclasses import dataclass
+from typing import TYPE_CHECKING
+
+from eq3btsmart.models import Status
+
+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 Eq3ConfigEntry
+from .const import ENTITY_KEY_BATTERY, ENTITY_KEY_DST, ENTITY_KEY_WINDOW
+from .entity import Eq3Entity
+
+
+@dataclass(frozen=True, kw_only=True)
+class Eq3BinarySensorEntityDescription(BinarySensorEntityDescription):
+ """Entity description for eq3 binary sensors."""
+
+ value_func: Callable[[Status], bool]
+
+
+BINARY_SENSOR_ENTITY_DESCRIPTIONS = [
+ Eq3BinarySensorEntityDescription(
+ value_func=lambda status: status.is_low_battery,
+ key=ENTITY_KEY_BATTERY,
+ device_class=BinarySensorDeviceClass.BATTERY,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
+ Eq3BinarySensorEntityDescription(
+ value_func=lambda status: status.is_window_open,
+ key=ENTITY_KEY_WINDOW,
+ device_class=BinarySensorDeviceClass.WINDOW,
+ ),
+ Eq3BinarySensorEntityDescription(
+ value_func=lambda status: status.is_dst,
+ key=ENTITY_KEY_DST,
+ translation_key=ENTITY_KEY_DST,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
+]
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: Eq3ConfigEntry,
+ async_add_entities: AddEntitiesCallback,
+) -> None:
+ """Set up the entry."""
+
+ async_add_entities(
+ Eq3BinarySensorEntity(entry, entity_description)
+ for entity_description in BINARY_SENSOR_ENTITY_DESCRIPTIONS
+ )
+
+
+class Eq3BinarySensorEntity(Eq3Entity, BinarySensorEntity):
+ """Base class for eQ-3 binary sensor entities."""
+
+ entity_description: Eq3BinarySensorEntityDescription
+
+ def __init__(
+ self,
+ entry: Eq3ConfigEntry,
+ entity_description: Eq3BinarySensorEntityDescription,
+ ) -> None:
+ """Initialize the entity."""
+
+ super().__init__(entry, entity_description.key)
+ self.entity_description = entity_description
+
+ @property
+ def is_on(self) -> bool:
+ """Return the state of the binary sensor."""
+
+ if TYPE_CHECKING:
+ assert self._thermostat.status is not None
+
+ return self.entity_description.value_func(self._thermostat.status)
diff --git a/homeassistant/components/eq3btsmart/climate.py b/homeassistant/components/eq3btsmart/climate.py
index 7b8ccb6c990..ae01d0fc9a7 100644
--- a/homeassistant/components/eq3btsmart/climate.py
+++ b/homeassistant/components/eq3btsmart/climate.py
@@ -3,7 +3,6 @@
import logging
from typing import Any
-from eq3btsmart import Thermostat
from eq3btsmart.const import EQ3BT_MAX_TEMP, EQ3BT_OFF_TEMP, Eq3Preset, OperationMode
from eq3btsmart.exceptions import Eq3Exception
@@ -15,45 +14,35 @@ from homeassistant.components.climate import (
HVACAction,
HVACMode,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_TEMPERATURE, PRECISION_HALVES, UnitOfTemperature
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import device_registry as dr
-from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceInfo
-from homeassistant.helpers.dispatcher import async_dispatcher_connect
+from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from homeassistant.util import slugify
+from . import Eq3ConfigEntry
from .const import (
- DEVICE_MODEL,
- DOMAIN,
EQ_TO_HA_HVAC,
HA_TO_EQ_HVAC,
- MANUFACTURER,
- SIGNAL_THERMOSTAT_CONNECTED,
- SIGNAL_THERMOSTAT_DISCONNECTED,
CurrentTemperatureSelector,
Preset,
TargetTemperatureSelector,
)
from .entity import Eq3Entity
-from .models import Eq3Config, Eq3ConfigEntryData
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ entry: Eq3ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Handle config entry setup."""
- eq3_config_entry: Eq3ConfigEntryData = hass.data[DOMAIN][config_entry.entry_id]
-
async_add_entities(
- [Eq3Climate(eq3_config_entry.eq3_config, eq3_config_entry.thermostat)],
+ [Eq3Climate(entry)],
)
@@ -80,53 +69,6 @@ class Eq3Climate(Eq3Entity, ClimateEntity):
_attr_preset_mode: str | None = None
_target_temperature: float | None = None
- def __init__(self, eq3_config: Eq3Config, thermostat: Thermostat) -> None:
- """Initialize the climate entity."""
-
- super().__init__(eq3_config, thermostat)
- self._attr_unique_id = dr.format_mac(eq3_config.mac_address)
- self._attr_device_info = DeviceInfo(
- name=slugify(self._eq3_config.mac_address),
- manufacturer=MANUFACTURER,
- model=DEVICE_MODEL,
- connections={(CONNECTION_BLUETOOTH, self._eq3_config.mac_address)},
- )
-
- async def async_added_to_hass(self) -> None:
- """Run when entity about to be added to hass."""
-
- self._thermostat.register_update_callback(self._async_on_updated)
-
- self.async_on_remove(
- async_dispatcher_connect(
- self.hass,
- f"{SIGNAL_THERMOSTAT_DISCONNECTED}_{self._eq3_config.mac_address}",
- self._async_on_disconnected,
- )
- )
- self.async_on_remove(
- async_dispatcher_connect(
- self.hass,
- f"{SIGNAL_THERMOSTAT_CONNECTED}_{self._eq3_config.mac_address}",
- self._async_on_connected,
- )
- )
-
- async def async_will_remove_from_hass(self) -> None:
- """Run when entity will be removed from hass."""
-
- self._thermostat.unregister_update_callback(self._async_on_updated)
-
- @callback
- def _async_on_disconnected(self) -> None:
- self._attr_available = False
- self.async_write_ha_state()
-
- @callback
- def _async_on_connected(self) -> None:
- self._attr_available = True
- self.async_write_ha_state()
-
@callback
def _async_on_updated(self) -> None:
"""Handle updated data from the thermostat."""
@@ -137,12 +79,15 @@ class Eq3Climate(Eq3Entity, ClimateEntity):
if self._thermostat.device_data is not None:
self._async_on_device_updated()
- self.async_write_ha_state()
+ super()._async_on_updated()
@callback
def _async_on_status_updated(self) -> None:
"""Handle updated status from the thermostat."""
+ if self._thermostat.status is None:
+ return
+
self._target_temperature = self._thermostat.status.target_temperature.value
self._attr_hvac_mode = EQ_TO_HA_HVAC[self._thermostat.status.operation_mode]
self._attr_current_temperature = self._get_current_temperature()
@@ -154,13 +99,16 @@ class Eq3Climate(Eq3Entity, ClimateEntity):
def _async_on_device_updated(self) -> None:
"""Handle updated device data from the thermostat."""
+ if self._thermostat.device_data is None:
+ return
+
device_registry = dr.async_get(self.hass)
if device := device_registry.async_get_device(
connections={(CONNECTION_BLUETOOTH, self._eq3_config.mac_address)},
):
device_registry.async_update_device(
device.id,
- sw_version=self._thermostat.device_data.firmware_version,
+ sw_version=str(self._thermostat.device_data.firmware_version),
serial_number=self._thermostat.device_data.device_serial.value,
)
@@ -265,7 +213,7 @@ class Eq3Climate(Eq3Entity, ClimateEntity):
self.async_write_ha_state()
try:
- await self._thermostat.async_set_temperature(self._target_temperature)
+ await self._thermostat.async_set_temperature(temperature)
except Eq3Exception:
_LOGGER.error(
"[%s] Failed setting temperature", self._eq3_config.mac_address
diff --git a/homeassistant/components/eq3btsmart/const.py b/homeassistant/components/eq3btsmart/const.py
index 111c4d0eba4..a5f7ea2ff95 100644
--- a/homeassistant/components/eq3btsmart/const.py
+++ b/homeassistant/components/eq3btsmart/const.py
@@ -18,8 +18,21 @@ DOMAIN = "eq3btsmart"
MANUFACTURER = "eQ-3 AG"
DEVICE_MODEL = "CC-RT-BLE-EQ"
-GET_DEVICE_TIMEOUT = 5 # seconds
+ENTITY_KEY_DST = "dst"
+ENTITY_KEY_BATTERY = "battery"
+ENTITY_KEY_WINDOW = "window"
+ENTITY_KEY_LOCK = "lock"
+ENTITY_KEY_BOOST = "boost"
+ENTITY_KEY_AWAY = "away"
+ENTITY_KEY_COMFORT = "comfort"
+ENTITY_KEY_ECO = "eco"
+ENTITY_KEY_OFFSET = "offset"
+ENTITY_KEY_WINDOW_OPEN_TEMPERATURE = "window_open_temperature"
+ENTITY_KEY_WINDOW_OPEN_TIMEOUT = "window_open_timeout"
+ENTITY_KEY_VALVE = "valve"
+ENTITY_KEY_AWAY_UNTIL = "away_until"
+GET_DEVICE_TIMEOUT = 5 # seconds
EQ_TO_HA_HVAC: dict[OperationMode, HVACMode] = {
OperationMode.OFF: HVACMode.OFF,
@@ -71,3 +84,5 @@ DEFAULT_SCAN_INTERVAL = 10 # seconds
SIGNAL_THERMOSTAT_DISCONNECTED = f"{DOMAIN}.thermostat_disconnected"
SIGNAL_THERMOSTAT_CONNECTED = f"{DOMAIN}.thermostat_connected"
+
+EQ3BT_STEP = 0.5
diff --git a/homeassistant/components/eq3btsmart/entity.py b/homeassistant/components/eq3btsmart/entity.py
index e8c00d4e3cf..e68545c08c7 100644
--- a/homeassistant/components/eq3btsmart/entity.py
+++ b/homeassistant/components/eq3btsmart/entity.py
@@ -1,10 +1,22 @@
"""Base class for all eQ-3 entities."""
-from eq3btsmart.thermostat import Thermostat
-
+from homeassistant.core import callback
+from homeassistant.helpers.device_registry import (
+ CONNECTION_BLUETOOTH,
+ DeviceInfo,
+ format_mac,
+)
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import Entity
+from homeassistant.util import slugify
-from .models import Eq3Config
+from . import Eq3ConfigEntry
+from .const import (
+ DEVICE_MODEL,
+ MANUFACTURER,
+ SIGNAL_THERMOSTAT_CONNECTED,
+ SIGNAL_THERMOSTAT_DISCONNECTED,
+)
class Eq3Entity(Entity):
@@ -12,8 +24,70 @@ class Eq3Entity(Entity):
_attr_has_entity_name = True
- def __init__(self, eq3_config: Eq3Config, thermostat: Thermostat) -> None:
+ def __init__(
+ self,
+ entry: Eq3ConfigEntry,
+ unique_id_key: str | None = None,
+ ) -> None:
"""Initialize the eq3 entity."""
- self._eq3_config = eq3_config
- self._thermostat = thermostat
+ self._eq3_config = entry.runtime_data.eq3_config
+ self._thermostat = entry.runtime_data.thermostat
+ self._attr_device_info = DeviceInfo(
+ name=slugify(self._eq3_config.mac_address),
+ manufacturer=MANUFACTURER,
+ model=DEVICE_MODEL,
+ connections={(CONNECTION_BLUETOOTH, self._eq3_config.mac_address)},
+ )
+ suffix = f"_{unique_id_key}" if unique_id_key else ""
+ self._attr_unique_id = f"{format_mac(self._eq3_config.mac_address)}{suffix}"
+
+ async def async_added_to_hass(self) -> None:
+ """Run when entity about to be added to hass."""
+
+ self._thermostat.register_update_callback(self._async_on_updated)
+
+ self.async_on_remove(
+ async_dispatcher_connect(
+ self.hass,
+ f"{SIGNAL_THERMOSTAT_DISCONNECTED}_{self._eq3_config.mac_address}",
+ self._async_on_disconnected,
+ )
+ )
+ self.async_on_remove(
+ async_dispatcher_connect(
+ self.hass,
+ f"{SIGNAL_THERMOSTAT_CONNECTED}_{self._eq3_config.mac_address}",
+ self._async_on_connected,
+ )
+ )
+
+ async def async_will_remove_from_hass(self) -> None:
+ """Run when entity will be removed from hass."""
+
+ self._thermostat.unregister_update_callback(self._async_on_updated)
+
+ def _async_on_updated(self) -> None:
+ """Handle updated data from the thermostat."""
+
+ self.async_write_ha_state()
+
+ @callback
+ def _async_on_disconnected(self) -> None:
+ """Handle disconnection from the thermostat."""
+
+ self._attr_available = False
+ self.async_write_ha_state()
+
+ @callback
+ def _async_on_connected(self) -> None:
+ """Handle connection to the thermostat."""
+
+ self._attr_available = True
+ self.async_write_ha_state()
+
+ @property
+ def available(self) -> bool:
+ """Whether the entity is available."""
+
+ return self._thermostat.status is not None and self._attr_available
diff --git a/homeassistant/components/eq3btsmart/icons.json b/homeassistant/components/eq3btsmart/icons.json
new file mode 100644
index 00000000000..892352c2ea4
--- /dev/null
+++ b/homeassistant/components/eq3btsmart/icons.json
@@ -0,0 +1,57 @@
+{
+ "entity": {
+ "binary_sensor": {
+ "dst": {
+ "default": "mdi:sun-clock",
+ "state": {
+ "off": "mdi:sun-clock-outline"
+ }
+ }
+ },
+ "number": {
+ "comfort": {
+ "default": "mdi:sun-thermometer"
+ },
+ "eco": {
+ "default": "mdi:snowflake-thermometer"
+ },
+ "offset": {
+ "default": "mdi:thermometer-plus"
+ },
+ "window_open_temperature": {
+ "default": "mdi:window-open-variant"
+ },
+ "window_open_timeout": {
+ "default": "mdi:timer-refresh"
+ }
+ },
+ "sensor": {
+ "away_until": {
+ "default": "mdi:home-export-outline"
+ },
+ "valve": {
+ "default": "mdi:pipe-valve"
+ }
+ },
+ "switch": {
+ "away": {
+ "default": "mdi:home-account",
+ "state": {
+ "on": "mdi:home-export-outline"
+ }
+ },
+ "lock": {
+ "default": "mdi:lock",
+ "state": {
+ "off": "mdi:lock-off"
+ }
+ },
+ "boost": {
+ "default": "mdi:fire",
+ "state": {
+ "off": "mdi:fire-off"
+ }
+ }
+ }
+ }
+}
diff --git a/homeassistant/components/eq3btsmart/manifest.json b/homeassistant/components/eq3btsmart/manifest.json
index e25c675bf82..43f18d4fffc 100644
--- a/homeassistant/components/eq3btsmart/manifest.json
+++ b/homeassistant/components/eq3btsmart/manifest.json
@@ -22,6 +22,5 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["eq3btsmart"],
- "quality_scale": "silver",
- "requirements": ["eq3btsmart==1.2.0", "bleak-esphome==1.1.0"]
+ "requirements": ["eq3btsmart==1.4.1", "bleak-esphome==2.0.0"]
}
diff --git a/homeassistant/components/eq3btsmart/models.py b/homeassistant/components/eq3btsmart/models.py
index 8ea0955dbdd..858465effa8 100644
--- a/homeassistant/components/eq3btsmart/models.py
+++ b/homeassistant/components/eq3btsmart/models.py
@@ -2,7 +2,6 @@
from dataclasses import dataclass
-from eq3btsmart.const import DEFAULT_AWAY_HOURS, DEFAULT_AWAY_TEMP
from eq3btsmart.thermostat import Thermostat
from .const import (
@@ -23,8 +22,6 @@ class Eq3Config:
target_temp_selector: TargetTemperatureSelector = DEFAULT_TARGET_TEMP_SELECTOR
external_temp_sensor: str = ""
scan_interval: int = DEFAULT_SCAN_INTERVAL
- default_away_hours: float = DEFAULT_AWAY_HOURS
- default_away_temperature: float = DEFAULT_AWAY_TEMP
@dataclass(slots=True)
diff --git a/homeassistant/components/eq3btsmart/number.py b/homeassistant/components/eq3btsmart/number.py
new file mode 100644
index 00000000000..2e069180fa3
--- /dev/null
+++ b/homeassistant/components/eq3btsmart/number.py
@@ -0,0 +1,158 @@
+"""Platform for eq3 number entities."""
+
+from collections.abc import Awaitable, Callable
+from dataclasses import dataclass
+from typing import TYPE_CHECKING
+
+from eq3btsmart import Thermostat
+from eq3btsmart.const import (
+ EQ3BT_MAX_OFFSET,
+ EQ3BT_MAX_TEMP,
+ EQ3BT_MIN_OFFSET,
+ EQ3BT_MIN_TEMP,
+)
+from eq3btsmart.models import Presets
+
+from homeassistant.components.number import (
+ NumberDeviceClass,
+ NumberEntity,
+ NumberEntityDescription,
+ NumberMode,
+)
+from homeassistant.const import EntityCategory, UnitOfTemperature, UnitOfTime
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+
+from . import Eq3ConfigEntry
+from .const import (
+ ENTITY_KEY_COMFORT,
+ ENTITY_KEY_ECO,
+ ENTITY_KEY_OFFSET,
+ ENTITY_KEY_WINDOW_OPEN_TEMPERATURE,
+ ENTITY_KEY_WINDOW_OPEN_TIMEOUT,
+ EQ3BT_STEP,
+)
+from .entity import Eq3Entity
+
+
+@dataclass(frozen=True, kw_only=True)
+class Eq3NumberEntityDescription(NumberEntityDescription):
+ """Entity description for eq3 number entities."""
+
+ value_func: Callable[[Presets], float]
+ value_set_func: Callable[
+ [Thermostat],
+ Callable[[float], Awaitable[None]],
+ ]
+ mode: NumberMode = NumberMode.BOX
+ entity_category: EntityCategory | None = EntityCategory.CONFIG
+
+
+NUMBER_ENTITY_DESCRIPTIONS = [
+ Eq3NumberEntityDescription(
+ key=ENTITY_KEY_COMFORT,
+ value_func=lambda presets: presets.comfort_temperature.value,
+ value_set_func=lambda thermostat: thermostat.async_configure_comfort_temperature,
+ translation_key=ENTITY_KEY_COMFORT,
+ native_min_value=EQ3BT_MIN_TEMP,
+ native_max_value=EQ3BT_MAX_TEMP,
+ native_step=EQ3BT_STEP,
+ native_unit_of_measurement=UnitOfTemperature.CELSIUS,
+ device_class=NumberDeviceClass.TEMPERATURE,
+ ),
+ Eq3NumberEntityDescription(
+ key=ENTITY_KEY_ECO,
+ value_func=lambda presets: presets.eco_temperature.value,
+ value_set_func=lambda thermostat: thermostat.async_configure_eco_temperature,
+ translation_key=ENTITY_KEY_ECO,
+ native_min_value=EQ3BT_MIN_TEMP,
+ native_max_value=EQ3BT_MAX_TEMP,
+ native_step=EQ3BT_STEP,
+ native_unit_of_measurement=UnitOfTemperature.CELSIUS,
+ device_class=NumberDeviceClass.TEMPERATURE,
+ ),
+ Eq3NumberEntityDescription(
+ key=ENTITY_KEY_WINDOW_OPEN_TEMPERATURE,
+ value_func=lambda presets: presets.window_open_temperature.value,
+ value_set_func=lambda thermostat: thermostat.async_configure_window_open_temperature,
+ translation_key=ENTITY_KEY_WINDOW_OPEN_TEMPERATURE,
+ native_min_value=EQ3BT_MIN_TEMP,
+ native_max_value=EQ3BT_MAX_TEMP,
+ native_step=EQ3BT_STEP,
+ native_unit_of_measurement=UnitOfTemperature.CELSIUS,
+ device_class=NumberDeviceClass.TEMPERATURE,
+ ),
+ Eq3NumberEntityDescription(
+ key=ENTITY_KEY_OFFSET,
+ value_func=lambda presets: presets.offset_temperature.value,
+ value_set_func=lambda thermostat: thermostat.async_configure_temperature_offset,
+ translation_key=ENTITY_KEY_OFFSET,
+ native_min_value=EQ3BT_MIN_OFFSET,
+ native_max_value=EQ3BT_MAX_OFFSET,
+ native_step=EQ3BT_STEP,
+ native_unit_of_measurement=UnitOfTemperature.CELSIUS,
+ device_class=NumberDeviceClass.TEMPERATURE,
+ ),
+ Eq3NumberEntityDescription(
+ key=ENTITY_KEY_WINDOW_OPEN_TIMEOUT,
+ value_set_func=lambda thermostat: thermostat.async_configure_window_open_duration,
+ value_func=lambda presets: presets.window_open_time.value.total_seconds() / 60,
+ translation_key=ENTITY_KEY_WINDOW_OPEN_TIMEOUT,
+ native_min_value=0,
+ native_max_value=60,
+ native_step=5,
+ native_unit_of_measurement=UnitOfTime.MINUTES,
+ ),
+]
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: Eq3ConfigEntry,
+ async_add_entities: AddEntitiesCallback,
+) -> None:
+ """Set up the entry."""
+
+ async_add_entities(
+ Eq3NumberEntity(entry, entity_description)
+ for entity_description in NUMBER_ENTITY_DESCRIPTIONS
+ )
+
+
+class Eq3NumberEntity(Eq3Entity, NumberEntity):
+ """Base class for all eq3 number entities."""
+
+ entity_description: Eq3NumberEntityDescription
+
+ def __init__(
+ self, entry: Eq3ConfigEntry, entity_description: Eq3NumberEntityDescription
+ ) -> None:
+ """Initialize the entity."""
+
+ super().__init__(entry, entity_description.key)
+ self.entity_description = entity_description
+
+ @property
+ def native_value(self) -> float:
+ """Return the state of the entity."""
+
+ if TYPE_CHECKING:
+ assert self._thermostat.status is not None
+ assert self._thermostat.status.presets is not None
+
+ return self.entity_description.value_func(self._thermostat.status.presets)
+
+ async def async_set_native_value(self, value: float) -> None:
+ """Set the state of the entity."""
+
+ await self.entity_description.value_set_func(self._thermostat)(value)
+
+ @property
+ def available(self) -> bool:
+ """Return whether the entity is available."""
+
+ return (
+ self._thermostat.status is not None
+ and self._thermostat.status.presets is not None
+ and self._attr_available
+ )
diff --git a/homeassistant/components/eq3btsmart/sensor.py b/homeassistant/components/eq3btsmart/sensor.py
new file mode 100644
index 00000000000..bd2605042f4
--- /dev/null
+++ b/homeassistant/components/eq3btsmart/sensor.py
@@ -0,0 +1,84 @@
+"""Platform for eq3 sensor entities."""
+
+from collections.abc import Callable
+from dataclasses import dataclass
+from datetime import datetime
+from typing import TYPE_CHECKING
+
+from eq3btsmart.models import Status
+
+from homeassistant.components.sensor import (
+ SensorDeviceClass,
+ SensorEntity,
+ SensorEntityDescription,
+)
+from homeassistant.components.sensor.const import SensorStateClass
+from homeassistant.const import PERCENTAGE
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+
+from . import Eq3ConfigEntry
+from .const import ENTITY_KEY_AWAY_UNTIL, ENTITY_KEY_VALVE
+from .entity import Eq3Entity
+
+
+@dataclass(frozen=True, kw_only=True)
+class Eq3SensorEntityDescription(SensorEntityDescription):
+ """Entity description for eq3 sensor entities."""
+
+ value_func: Callable[[Status], int | datetime | None]
+
+
+SENSOR_ENTITY_DESCRIPTIONS = [
+ Eq3SensorEntityDescription(
+ key=ENTITY_KEY_VALVE,
+ translation_key=ENTITY_KEY_VALVE,
+ value_func=lambda status: status.valve,
+ native_unit_of_measurement=PERCENTAGE,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ Eq3SensorEntityDescription(
+ key=ENTITY_KEY_AWAY_UNTIL,
+ translation_key=ENTITY_KEY_AWAY_UNTIL,
+ value_func=lambda status: (
+ status.away_until.value if status.away_until else None
+ ),
+ device_class=SensorDeviceClass.DATE,
+ ),
+]
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: Eq3ConfigEntry,
+ async_add_entities: AddEntitiesCallback,
+) -> None:
+ """Set up the entry."""
+
+ async_add_entities(
+ Eq3SensorEntity(entry, entity_description)
+ for entity_description in SENSOR_ENTITY_DESCRIPTIONS
+ )
+
+
+class Eq3SensorEntity(Eq3Entity, SensorEntity):
+ """Base class for eq3 sensor entities."""
+
+ entity_description: Eq3SensorEntityDescription
+
+ def __init__(
+ self, entry: Eq3ConfigEntry, entity_description: Eq3SensorEntityDescription
+ ) -> None:
+ """Initialize the entity."""
+
+ super().__init__(entry, entity_description.key)
+ self.entity_description = entity_description
+
+ @property
+ def native_value(self) -> int | datetime | None:
+ """Return the value reported by the sensor."""
+
+ if TYPE_CHECKING:
+ assert self._thermostat.status is not None
+
+ return self.entity_description.value_func(self._thermostat.status)
diff --git a/homeassistant/components/eq3btsmart/strings.json b/homeassistant/components/eq3btsmart/strings.json
index 5108baa1bcf..ab363f4d752 100644
--- a/homeassistant/components/eq3btsmart/strings.json
+++ b/homeassistant/components/eq3btsmart/strings.json
@@ -18,5 +18,48 @@
"error": {
"invalid_mac_address": "Invalid MAC address"
}
+ },
+ "entity": {
+ "binary_sensor": {
+ "dst": {
+ "name": "Daylight saving time"
+ }
+ },
+ "number": {
+ "comfort": {
+ "name": "Comfort temperature"
+ },
+ "eco": {
+ "name": "Eco temperature"
+ },
+ "offset": {
+ "name": "Offset temperature"
+ },
+ "window_open_temperature": {
+ "name": "Window open temperature"
+ },
+ "window_open_timeout": {
+ "name": "Window open timeout"
+ }
+ },
+ "sensor": {
+ "away_until": {
+ "name": "Away until"
+ },
+ "valve": {
+ "name": "Valve"
+ }
+ },
+ "switch": {
+ "lock": {
+ "name": "Lock"
+ },
+ "boost": {
+ "name": "Boost"
+ },
+ "away": {
+ "name": "Away"
+ }
+ }
}
}
diff --git a/homeassistant/components/eq3btsmart/switch.py b/homeassistant/components/eq3btsmart/switch.py
new file mode 100644
index 00000000000..7525d8ca494
--- /dev/null
+++ b/homeassistant/components/eq3btsmart/switch.py
@@ -0,0 +1,94 @@
+"""Platform for eq3 switch entities."""
+
+from collections.abc import Awaitable, Callable
+from dataclasses import dataclass
+from typing import TYPE_CHECKING, Any
+
+from eq3btsmart import Thermostat
+from eq3btsmart.models import Status
+
+from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+
+from . import Eq3ConfigEntry
+from .const import ENTITY_KEY_AWAY, ENTITY_KEY_BOOST, ENTITY_KEY_LOCK
+from .entity import Eq3Entity
+
+
+@dataclass(frozen=True, kw_only=True)
+class Eq3SwitchEntityDescription(SwitchEntityDescription):
+ """Entity description for eq3 switch entities."""
+
+ toggle_func: Callable[[Thermostat], Callable[[bool], Awaitable[None]]]
+ value_func: Callable[[Status], bool]
+
+
+SWITCH_ENTITY_DESCRIPTIONS = [
+ Eq3SwitchEntityDescription(
+ key=ENTITY_KEY_LOCK,
+ translation_key=ENTITY_KEY_LOCK,
+ toggle_func=lambda thermostat: thermostat.async_set_locked,
+ value_func=lambda status: status.is_locked,
+ ),
+ Eq3SwitchEntityDescription(
+ key=ENTITY_KEY_BOOST,
+ translation_key=ENTITY_KEY_BOOST,
+ toggle_func=lambda thermostat: thermostat.async_set_boost,
+ value_func=lambda status: status.is_boost,
+ ),
+ Eq3SwitchEntityDescription(
+ key=ENTITY_KEY_AWAY,
+ translation_key=ENTITY_KEY_AWAY,
+ toggle_func=lambda thermostat: thermostat.async_set_away,
+ value_func=lambda status: status.is_away,
+ ),
+]
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: Eq3ConfigEntry,
+ async_add_entities: AddEntitiesCallback,
+) -> None:
+ """Set up the entry."""
+
+ async_add_entities(
+ Eq3SwitchEntity(entry, entity_description)
+ for entity_description in SWITCH_ENTITY_DESCRIPTIONS
+ )
+
+
+class Eq3SwitchEntity(Eq3Entity, SwitchEntity):
+ """Base class for eq3 switch entities."""
+
+ entity_description: Eq3SwitchEntityDescription
+
+ def __init__(
+ self,
+ entry: Eq3ConfigEntry,
+ entity_description: Eq3SwitchEntityDescription,
+ ) -> None:
+ """Initialize the entity."""
+
+ super().__init__(entry, entity_description.key)
+ self.entity_description = entity_description
+
+ async def async_turn_on(self, **kwargs: Any) -> None:
+ """Turn on the switch."""
+
+ await self.entity_description.toggle_func(self._thermostat)(True)
+
+ async def async_turn_off(self, **kwargs: Any) -> None:
+ """Turn off the switch."""
+
+ await self.entity_description.toggle_func(self._thermostat)(False)
+
+ @property
+ def is_on(self) -> bool:
+ """Return the state of the switch."""
+
+ if TYPE_CHECKING:
+ assert self._thermostat.status is not None
+
+ return self.entity_description.value_func(self._thermostat.status)
diff --git a/homeassistant/components/escea/climate.py b/homeassistant/components/escea/climate.py
index 555da1494d7..c3fb0015e68 100644
--- a/homeassistant/components/escea/climate.py
+++ b/homeassistant/components/escea/climate.py
@@ -89,7 +89,6 @@ class ControllerEntity(ClimateEntity):
)
_attr_target_temperature_step = PRECISION_WHOLE
_attr_temperature_unit = UnitOfTemperature.CELSIUS
- _enable_turn_on_off_backwards_compatibility = False
def __init__(self, controller: Controller) -> None:
"""Initialise ControllerDevice."""
diff --git a/homeassistant/components/esphome/assist_satellite.py b/homeassistant/components/esphome/assist_satellite.py
index dc513a03e02..f60668b0a06 100644
--- a/homeassistant/components/esphome/assist_satellite.py
+++ b/homeassistant/components/esphome/assist_satellite.py
@@ -95,11 +95,7 @@ async def async_setup_entry(
if entry_data.device_info.voice_assistant_feature_flags_compat(
entry_data.api_version
):
- async_add_entities(
- [
- EsphomeAssistSatellite(entry, entry_data),
- ]
- )
+ async_add_entities([EsphomeAssistSatellite(entry, entry_data)])
class EsphomeAssistSatellite(
@@ -198,6 +194,9 @@ class EsphomeAssistSatellite(
self._satellite_config.max_active_wake_words = config.max_active_wake_words
_LOGGER.debug("Received satellite configuration: %s", self._satellite_config)
+ # Inform listeners that config has been updated
+ self.entry_data.async_assist_satellite_config_updated(self._satellite_config)
+
async def async_added_to_hass(self) -> None:
"""Run when entity about to be added to hass."""
await super().async_added_to_hass()
@@ -254,6 +253,13 @@ class EsphomeAssistSatellite(
# Will use media player for TTS/announcements
self._update_tts_format()
+ # Update wake word select when config is updated
+ self.async_on_remove(
+ self.entry_data.async_register_assist_satellite_set_wake_word_callback(
+ self.async_set_wake_word
+ )
+ )
+
async def async_will_remove_from_hass(self) -> None:
"""Run when entity will be removed from hass."""
await super().async_will_remove_from_hass()
@@ -478,6 +484,17 @@ class EsphomeAssistSatellite(
"""Handle announcement finished message (also sent for TTS)."""
self.tts_response_finished()
+ @callback
+ def async_set_wake_word(self, wake_word_id: str) -> None:
+ """Set active wake word and update config on satellite."""
+ self._satellite_config.active_wake_words = [wake_word_id]
+ self.config_entry.async_create_background_task(
+ self.hass,
+ self.async_set_configuration(self._satellite_config),
+ "esphome_voice_assistant_set_config",
+ )
+ _LOGGER.debug("Setting active wake word: %s", wake_word_id)
+
def _update_tts_format(self) -> None:
"""Update the TTS format from the first media player."""
for supported_format in chain(*self.entry_data.media_player_formats.values()):
diff --git a/homeassistant/components/esphome/bluetooth.py b/homeassistant/components/esphome/bluetooth.py
index 37ae28df0ca..004bea1835d 100644
--- a/homeassistant/components/esphome/bluetooth.py
+++ b/homeassistant/components/esphome/bluetooth.py
@@ -7,7 +7,6 @@ from typing import TYPE_CHECKING
from aioesphomeapi import APIClient, DeviceInfo
from bleak_esphome import connect_scanner
-from bleak_esphome.backend.cache import ESPHomeBluetoothCache
from homeassistant.components.bluetooth import async_register_scanner
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback as hass_callback
@@ -28,10 +27,9 @@ def async_connect_scanner(
entry_data: RuntimeEntryData,
cli: APIClient,
device_info: DeviceInfo,
- cache: ESPHomeBluetoothCache,
) -> CALLBACK_TYPE:
"""Connect scanner."""
- client_data = connect_scanner(cli, device_info, cache, entry_data.available)
+ client_data = connect_scanner(cli, device_info, entry_data.available)
entry_data.bluetooth_device = client_data.bluetooth_device
client_data.disconnect_callbacks = entry_data.disconnect_callbacks
scanner = client_data.scanner
diff --git a/homeassistant/components/esphome/climate.py b/homeassistant/components/esphome/climate.py
index 1b9b53f24cd..478ce9bae2c 100644
--- a/homeassistant/components/esphome/climate.py
+++ b/homeassistant/components/esphome/climate.py
@@ -129,7 +129,6 @@ class EsphomeClimateEntity(EsphomeEntity[ClimateInfo, ClimateState], ClimateEnti
_attr_temperature_unit = UnitOfTemperature.CELSIUS
_attr_translation_key = "climate"
- _enable_turn_on_off_backwards_compatibility = False
@callback
def _on_static_info_update(self, static_info: EntityInfo) -> None:
@@ -231,6 +230,8 @@ class EsphomeClimateEntity(EsphomeEntity[ClimateInfo, ClimateState], ClimateEnti
@esphome_float_state_property
def current_temperature(self) -> float | None:
"""Return the current temperature."""
+ if not self._static_info.supports_current_temperature:
+ return None
return self._state.current_temperature
@property
diff --git a/homeassistant/components/esphome/domain_data.py b/homeassistant/components/esphome/domain_data.py
index aa46469c40e..ed307b46fd6 100644
--- a/homeassistant/components/esphome/domain_data.py
+++ b/homeassistant/components/esphome/domain_data.py
@@ -6,8 +6,6 @@ from dataclasses import dataclass, field
from functools import cache
from typing import Self
-from bleak_esphome.backend.cache import ESPHomeBluetoothCache
-
from homeassistant.core import HomeAssistant
from homeassistant.helpers.json import JSONEncoder
@@ -22,9 +20,6 @@ class DomainData:
"""Define a class that stores global esphome data in hass.data[DOMAIN]."""
_stores: dict[str, ESPHomeStorage] = field(default_factory=dict)
- bluetooth_cache: ESPHomeBluetoothCache = field(
- default_factory=ESPHomeBluetoothCache
- )
def get_entry_data(self, entry: ESPHomeConfigEntry) -> RuntimeEntryData:
"""Return the runtime entry data associated with this config entry.
diff --git a/homeassistant/components/esphome/entry_data.py b/homeassistant/components/esphome/entry_data.py
index f1b5218eec7..fc41ee99a00 100644
--- a/homeassistant/components/esphome/entry_data.py
+++ b/homeassistant/components/esphome/entry_data.py
@@ -48,6 +48,7 @@ from aioesphomeapi import (
from aioesphomeapi.model import ButtonInfo
from bleak_esphome.backend.device import ESPHomeBluetoothDevice
+from homeassistant.components.assist_satellite import AssistSatelliteConfiguration
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
@@ -152,6 +153,12 @@ class RuntimeEntryData:
media_player_formats: dict[str, list[MediaPlayerSupportedFormat]] = field(
default_factory=lambda: defaultdict(list)
)
+ assist_satellite_config_update_callbacks: list[
+ Callable[[AssistSatelliteConfiguration], None]
+ ] = field(default_factory=list)
+ assist_satellite_set_wake_word_callbacks: list[Callable[[str], None]] = field(
+ default_factory=list
+ )
@property
def name(self) -> str:
@@ -504,3 +511,35 @@ class RuntimeEntryData:
# We use this to determine if a deep sleep device should
# be marked as unavailable or not.
self.expected_disconnect = True
+
+ @callback
+ def async_register_assist_satellite_config_updated_callback(
+ self,
+ callback_: Callable[[AssistSatelliteConfiguration], None],
+ ) -> CALLBACK_TYPE:
+ """Register to receive callbacks when the Assist satellite's configuration is updated."""
+ self.assist_satellite_config_update_callbacks.append(callback_)
+ return lambda: self.assist_satellite_config_update_callbacks.remove(callback_)
+
+ @callback
+ def async_assist_satellite_config_updated(
+ self, config: AssistSatelliteConfiguration
+ ) -> None:
+ """Notify listeners that the Assist satellite configuration has been updated."""
+ for callback_ in self.assist_satellite_config_update_callbacks.copy():
+ callback_(config)
+
+ @callback
+ def async_register_assist_satellite_set_wake_word_callback(
+ self,
+ callback_: Callable[[str], None],
+ ) -> CALLBACK_TYPE:
+ """Register to receive callbacks when the Assist satellite's wake word is set."""
+ self.assist_satellite_set_wake_word_callbacks.append(callback_)
+ return lambda: self.assist_satellite_set_wake_word_callbacks.remove(callback_)
+
+ @callback
+ def async_assist_satellite_set_wake_word(self, wake_word_id: str) -> None:
+ """Notify listeners that the Assist satellite wake word has been set."""
+ for callback_ in self.assist_satellite_set_wake_word_callbacks.copy():
+ callback_(wake_word_id)
diff --git a/homeassistant/components/esphome/fan.py b/homeassistant/components/esphome/fan.py
index 454c5edf030..c09145c17b5 100644
--- a/homeassistant/components/esphome/fan.py
+++ b/homeassistant/components/esphome/fan.py
@@ -45,7 +45,6 @@ class EsphomeFan(EsphomeEntity[FanInfo, FanState], FanEntity):
"""A fan implementation for ESPHome."""
_supports_speed_levels: bool = True
- _enable_turn_on_off_backwards_compatibility = False
async def async_set_percentage(self, percentage: int) -> None:
"""Set the speed percentage of the fan."""
diff --git a/homeassistant/components/esphome/ffmpeg_proxy.py b/homeassistant/components/esphome/ffmpeg_proxy.py
index cefe87f49ba..9484d1e7593 100644
--- a/homeassistant/components/esphome/ffmpeg_proxy.py
+++ b/homeassistant/components/esphome/ffmpeg_proxy.py
@@ -179,6 +179,9 @@ class FFmpegConvertResponse(web.StreamResponse):
# Remove metadata and cover art
command_args.extend(["-map_metadata", "-1", "-vn"])
+ # disable progress stats on stderr
+ command_args.append("-nostats")
+
# Output to stdout
command_args.append("pipe:")
@@ -209,6 +212,10 @@ class FFmpegConvertResponse(web.StreamResponse):
assert proc.stdout is not None
assert proc.stderr is not None
+ stderr_task = self.hass.async_create_background_task(
+ self._dump_ffmpeg_stderr(proc), "ESPHome media proxy dump stderr"
+ )
+
try:
# Pull audio chunks from ffmpeg and pass them to the HTTP client
while (
@@ -227,18 +234,14 @@ class FFmpegConvertResponse(web.StreamResponse):
raise # don't log error
except:
_LOGGER.exception("Unexpected error during ffmpeg conversion")
-
- # Process did not exit successfully
- stderr_text = ""
- while line := await proc.stderr.readline():
- stderr_text += line.decode()
- _LOGGER.error("FFmpeg output: %s", stderr_text)
-
raise
finally:
# Allow conversion info to be removed
self.convert_info.is_finished = True
+ # stop dumping ffmpeg stderr task
+ stderr_task.cancel()
+
# Terminate hangs, so kill is used
if proc.returncode is None:
proc.kill()
@@ -247,6 +250,16 @@ class FFmpegConvertResponse(web.StreamResponse):
if request.transport and not request.transport.is_closing():
await writer.write_eof()
+ async def _dump_ffmpeg_stderr(
+ self,
+ proc: asyncio.subprocess.Process,
+ ) -> None:
+ assert proc.stdout is not None
+ assert proc.stderr is not None
+
+ while self.hass.is_running and (chunk := await proc.stderr.readline()):
+ _LOGGER.debug("ffmpeg[%s] output: %s", proc.pid, chunk.decode().rstrip())
+
class FFmpegProxyView(HomeAssistantView):
"""FFmpeg web view to convert audio and stream back to client."""
diff --git a/homeassistant/components/esphome/light.py b/homeassistant/components/esphome/light.py
index 52f999afe4f..8fecf34862b 100644
--- a/homeassistant/components/esphome/light.py
+++ b/homeassistant/components/esphome/light.py
@@ -414,11 +414,8 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity):
self._attr_supported_color_modes = supported
self._attr_effect_list = static_info.effects
- self._attr_min_mireds = round(static_info.min_mireds)
- self._attr_max_mireds = round(static_info.max_mireds)
- if ColorMode.COLOR_TEMP in supported:
- self._attr_min_color_temp_kelvin = _mired_to_kelvin(static_info.max_mireds)
- self._attr_max_color_temp_kelvin = _mired_to_kelvin(static_info.min_mireds)
+ self._attr_min_color_temp_kelvin = _mired_to_kelvin(static_info.max_mireds)
+ self._attr_max_color_temp_kelvin = _mired_to_kelvin(static_info.min_mireds)
async_setup_entry = partial(
diff --git a/homeassistant/components/esphome/manager.py b/homeassistant/components/esphome/manager.py
index 007b4e791e1..dfd318c0c74 100644
--- a/homeassistant/components/esphome/manager.py
+++ b/homeassistant/components/esphome/manager.py
@@ -423,9 +423,7 @@ 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, self.domain_data.bluetooth_cache
- )
+ async_connect_scanner(hass, entry_data, cli, device_info)
)
if device_info.voice_assistant_feature_flags_compat(api_version) and (
diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json
index b9b6a98dcd1..b04fa4db428 100644
--- a/homeassistant/components/esphome/manifest.json
+++ b/homeassistant/components/esphome/manifest.json
@@ -15,11 +15,10 @@
"iot_class": "local_push",
"loggers": ["aioesphomeapi", "noiseprotocol", "bleak_esphome"],
"mqtt": ["esphome/discover/#"],
- "quality_scale": "platinum",
"requirements": [
- "aioesphomeapi==27.0.1",
+ "aioesphomeapi==28.0.0",
"esphome-dashboard-api==1.2.3",
- "bleak-esphome==1.1.0"
+ "bleak-esphome==2.0.0"
],
"zeroconf": ["_esphomelib._tcp.local."]
}
diff --git a/homeassistant/components/esphome/media_player.py b/homeassistant/components/esphome/media_player.py
index 3930b71d106..8a30814aa2c 100644
--- a/homeassistant/components/esphome/media_player.py
+++ b/homeassistant/components/esphome/media_player.py
@@ -20,6 +20,7 @@ from aioesphomeapi import (
from homeassistant.components import media_source
from homeassistant.components.media_player import (
ATTR_MEDIA_ANNOUNCE,
+ ATTR_MEDIA_EXTRA,
BrowseMedia,
MediaPlayerDeviceClass,
MediaPlayerEntity,
@@ -50,6 +51,8 @@ _STATES: EsphomeEnumMapper[EspMediaPlayerState, MediaPlayerState] = EsphomeEnumM
}
)
+ATTR_BYPASS_PROXY = "bypass_proxy"
+
class EsphomeMediaPlayer(
EsphomeEntity[MediaPlayerInfo, MediaPlayerEntityState], MediaPlayerEntity
@@ -108,13 +111,15 @@ class EsphomeMediaPlayer(
media_id = async_process_play_media_url(self.hass, media_id)
announcement = kwargs.get(ATTR_MEDIA_ANNOUNCE)
+ bypass_proxy = kwargs.get(ATTR_MEDIA_EXTRA, {}).get(ATTR_BYPASS_PROXY)
supported_formats: list[MediaPlayerSupportedFormat] | None = (
self._entry_data.media_player_formats.get(self._static_info.unique_id)
)
if (
- supported_formats
+ not bypass_proxy
+ and supported_formats
and _is_url(media_id)
and (
proxy_url := self._get_proxy_url(
diff --git a/homeassistant/components/esphome/select.py b/homeassistant/components/esphome/select.py
index 623946503eb..71a21186d3d 100644
--- a/homeassistant/components/esphome/select.py
+++ b/homeassistant/components/esphome/select.py
@@ -8,8 +8,11 @@ from homeassistant.components.assist_pipeline.select import (
AssistPipelineSelect,
VadSensitivitySelect,
)
-from homeassistant.components.select import SelectEntity
+from homeassistant.components.assist_satellite import AssistSatelliteConfiguration
+from homeassistant.components.select import SelectEntity, SelectEntityDescription
+from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers import restore_state
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN
@@ -47,6 +50,7 @@ async def async_setup_entry(
[
EsphomeAssistPipelineSelect(hass, entry_data),
EsphomeVadSensitivitySelect(hass, entry_data),
+ EsphomeAssistSatelliteWakeWordSelect(hass, entry_data),
]
)
@@ -89,3 +93,77 @@ class EsphomeVadSensitivitySelect(EsphomeAssistEntity, VadSensitivitySelect):
"""Initialize a VAD sensitivity selector."""
EsphomeAssistEntity.__init__(self, entry_data)
VadSensitivitySelect.__init__(self, hass, self._device_info.mac_address)
+
+
+class EsphomeAssistSatelliteWakeWordSelect(
+ EsphomeAssistEntity, SelectEntity, restore_state.RestoreEntity
+):
+ """Wake word selector for esphome devices."""
+
+ entity_description = SelectEntityDescription(
+ key="wake_word",
+ translation_key="wake_word",
+ entity_category=EntityCategory.CONFIG,
+ )
+ _attr_should_poll = False
+ _attr_current_option: str | None = None
+ _attr_options: list[str] = []
+
+ def __init__(self, hass: HomeAssistant, entry_data: RuntimeEntryData) -> None:
+ """Initialize a wake word selector."""
+ EsphomeAssistEntity.__init__(self, entry_data)
+
+ unique_id_prefix = self._device_info.mac_address
+ self._attr_unique_id = f"{unique_id_prefix}-wake_word"
+
+ # name -> id
+ self._wake_words: dict[str, str] = {}
+
+ @property
+ def available(self) -> bool:
+ """Return if entity is available."""
+ return bool(self._attr_options)
+
+ async def async_added_to_hass(self) -> None:
+ """Run when entity about to be added to hass."""
+ await super().async_added_to_hass()
+
+ # Update options when config is updated
+ self.async_on_remove(
+ self._entry_data.async_register_assist_satellite_config_updated_callback(
+ self.async_satellite_config_updated
+ )
+ )
+
+ async def async_select_option(self, option: str) -> None:
+ """Select an option."""
+ if wake_word_id := self._wake_words.get(option):
+ # _attr_current_option will be updated on
+ # async_satellite_config_updated after the device sets the wake
+ # word.
+ self._entry_data.async_assist_satellite_set_wake_word(wake_word_id)
+
+ def async_satellite_config_updated(
+ self, config: AssistSatelliteConfiguration
+ ) -> None:
+ """Update options with available wake words."""
+ if (not config.available_wake_words) or (config.max_active_wake_words < 1):
+ self._attr_current_option = None
+ self._wake_words.clear()
+ self.async_write_ha_state()
+ return
+
+ self._wake_words = {w.wake_word: w.id for w in config.available_wake_words}
+ self._attr_options = sorted(self._wake_words)
+
+ if config.active_wake_words:
+ # Select first active wake word
+ wake_word_id = config.active_wake_words[0]
+ for wake_word in config.available_wake_words:
+ if wake_word.id == wake_word_id:
+ self._attr_current_option = wake_word.wake_word
+ else:
+ # Select first available wake word
+ self._attr_current_option = config.available_wake_words[0].wake_word
+
+ self.async_write_ha_state()
diff --git a/homeassistant/components/esphome/strings.json b/homeassistant/components/esphome/strings.json
index 18a54772e30..81b58de8df2 100644
--- a/homeassistant/components/esphome/strings.json
+++ b/homeassistant/components/esphome/strings.json
@@ -84,6 +84,12 @@
"aggressive": "[%key:component::assist_pipeline::entity::select::vad_sensitivity::state::aggressive%]",
"relaxed": "[%key:component::assist_pipeline::entity::select::vad_sensitivity::state::relaxed%]"
}
+ },
+ "wake_word": {
+ "name": "Wake word",
+ "state": {
+ "okay_nabu": "Okay Nabu"
+ }
}
},
"climate": {
@@ -119,7 +125,7 @@
},
"service_calls_not_allowed": {
"title": "{name} is not permitted to perform Home Assistant actions",
- "description": "The ESPHome device attempted to perform a Home Assistant action, but this functionality is not enabled.\n\nIf you trust this device and want to allow it to perfom Home Assistant action, you can enable this functionality in the options flow."
+ "description": "The ESPHome device attempted to perform a Home Assistant action, but this functionality is not enabled.\n\nIf you trust this device and want to allow it to perform Home Assistant action, you can enable this functionality in the options flow."
}
}
}
diff --git a/homeassistant/components/esphome/update.py b/homeassistant/components/esphome/update.py
index 5e571399ecb..2b593051742 100644
--- a/homeassistant/components/esphome/update.py
+++ b/homeassistant/components/esphome/update.py
@@ -61,6 +61,8 @@ async def async_setup_entry(
if (dashboard := async_get_dashboard(hass)) is None:
return
entry_data = DomainData.get(hass).get_entry_data(entry)
+ assert entry_data.device_info is not None
+ device_name = entry_data.device_info.name
unsubs: list[CALLBACK_TYPE] = []
@callback
@@ -72,13 +74,22 @@ async def async_setup_entry(
if not entry_data.available or not dashboard.last_update_success:
return
+ # Do not add Dashboard Entity if this device is not known to the ESPHome dashboard.
+ if dashboard.data is None or dashboard.data.get(device_name) is None:
+ return
+
for unsub in unsubs:
unsub()
unsubs.clear()
async_add_entities([ESPHomeDashboardUpdateEntity(entry_data, dashboard)])
- if entry_data.available and dashboard.last_update_success:
+ if (
+ entry_data.available
+ and dashboard.last_update_success
+ and dashboard.data is not None
+ and dashboard.data.get(device_name)
+ ):
_async_setup_update_entity()
return
@@ -133,10 +144,8 @@ class ESPHomeDashboardUpdateEntity(
self._attr_supported_features = NO_FEATURES
self._attr_installed_version = device_info.esphome_version
device = coordinator.data.get(device_info.name)
- if device is None:
- self._attr_latest_version = None
- else:
- self._attr_latest_version = device["current_version"]
+ assert device is not None
+ self._attr_latest_version = device["current_version"]
@callback
def _handle_coordinator_update(self) -> None:
diff --git a/homeassistant/components/etherscan/manifest.json b/homeassistant/components/etherscan/manifest.json
index 1b296e4e4be..e5099ffaf9c 100644
--- a/homeassistant/components/etherscan/manifest.json
+++ b/homeassistant/components/etherscan/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/etherscan",
"iot_class": "cloud_polling",
"loggers": ["pyetherscan"],
+ "quality_scale": "legacy",
"requirements": ["python-etherscan-api==0.0.3"]
}
diff --git a/homeassistant/components/eufy/light.py b/homeassistant/components/eufy/light.py
index c1506c00cdc..95ad8a15d1c 100644
--- a/homeassistant/components/eufy/light.py
+++ b/homeassistant/components/eufy/light.py
@@ -8,7 +8,7 @@ import lakeside
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
- ATTR_COLOR_TEMP,
+ ATTR_COLOR_TEMP_KELVIN,
ATTR_HS_COLOR,
ColorMode,
LightEntity,
@@ -17,10 +17,6 @@ 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.color import (
- color_temperature_kelvin_to_mired as kelvin_to_mired,
- color_temperature_mired_to_kelvin as mired_to_kelvin,
-)
EUFYHOME_MAX_KELVIN = 6500
EUFYHOME_MIN_KELVIN = 2700
@@ -41,6 +37,9 @@ def setup_platform(
class EufyHomeLight(LightEntity):
"""Representation of a EufyHome light."""
+ _attr_min_color_temp_kelvin = EUFYHOME_MIN_KELVIN
+ _attr_max_color_temp_kelvin = EUFYHOME_MAX_KELVIN
+
def __init__(self, device):
"""Initialize the light."""
@@ -96,23 +95,12 @@ class EufyHomeLight(LightEntity):
return int(self._brightness * 255 / 100)
@property
- def min_mireds(self) -> int:
- """Return minimum supported color temperature."""
- return kelvin_to_mired(EUFYHOME_MAX_KELVIN)
-
- @property
- def max_mireds(self) -> int:
- """Return maximum supported color temperature."""
- return kelvin_to_mired(EUFYHOME_MIN_KELVIN)
-
- @property
- def color_temp(self):
- """Return the color temperature of this light."""
- temp_in_k = int(
+ def color_temp_kelvin(self) -> int:
+ """Return the color temperature value in Kelvin."""
+ return int(
EUFYHOME_MIN_KELVIN
+ (self._temp * (EUFYHOME_MAX_KELVIN - EUFYHOME_MIN_KELVIN) / 100)
)
- return kelvin_to_mired(temp_in_k)
@property
def hs_color(self):
@@ -134,7 +122,7 @@ class EufyHomeLight(LightEntity):
def turn_on(self, **kwargs: Any) -> None:
"""Turn the specified light on."""
brightness = kwargs.get(ATTR_BRIGHTNESS)
- colortemp = kwargs.get(ATTR_COLOR_TEMP)
+ color_temp_kelvin = kwargs.get(ATTR_COLOR_TEMP_KELVIN)
hs = kwargs.get(ATTR_HS_COLOR)
if brightness is not None:
@@ -144,10 +132,9 @@ class EufyHomeLight(LightEntity):
self._brightness = 100
brightness = self._brightness
- if colortemp is not None:
+ if color_temp_kelvin is not None:
self._colormode = False
- temp_in_k = mired_to_kelvin(colortemp)
- relative_temp = temp_in_k - EUFYHOME_MIN_KELVIN
+ relative_temp = color_temp_kelvin - EUFYHOME_MIN_KELVIN
temp = int(
relative_temp * 100 / (EUFYHOME_MAX_KELVIN - EUFYHOME_MIN_KELVIN)
)
diff --git a/homeassistant/components/eufy/manifest.json b/homeassistant/components/eufy/manifest.json
index ccf15144f9e..6ad1b7de81b 100644
--- a/homeassistant/components/eufy/manifest.json
+++ b/homeassistant/components/eufy/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/eufy",
"iot_class": "local_polling",
"loggers": ["lakeside"],
+ "quality_scale": "legacy",
"requirements": ["lakeside==0.13"]
}
diff --git a/homeassistant/components/everlights/manifest.json b/homeassistant/components/everlights/manifest.json
index 6f856b26087..a2deeab2666 100644
--- a/homeassistant/components/everlights/manifest.json
+++ b/homeassistant/components/everlights/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/everlights",
"iot_class": "local_polling",
"loggers": ["pyeverlights"],
+ "quality_scale": "legacy",
"requirements": ["pyeverlights==0.1.0"]
}
diff --git a/homeassistant/components/evohome/climate.py b/homeassistant/components/evohome/climate.py
index 1388585bc17..c71831fa4bc 100644
--- a/homeassistant/components/evohome/climate.py
+++ b/homeassistant/components/evohome/climate.py
@@ -150,7 +150,6 @@ class EvoClimateEntity(EvoDevice, ClimateEntity):
_attr_hvac_modes = [HVACMode.OFF, HVACMode.HEAT]
_attr_temperature_unit = UnitOfTemperature.CELSIUS
- _enable_turn_on_off_backwards_compatibility = False
class EvoZone(EvoChild, EvoClimateEntity):
diff --git a/homeassistant/components/evohome/manifest.json b/homeassistant/components/evohome/manifest.json
index e81e71c5b07..22edadad7f4 100644
--- a/homeassistant/components/evohome/manifest.json
+++ b/homeassistant/components/evohome/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/evohome",
"iot_class": "cloud_polling",
"loggers": ["evohomeasync", "evohomeasync2"],
- "requirements": ["evohome-async==0.4.20"]
+ "quality_scale": "legacy",
+ "requirements": ["evohome-async==0.4.21"]
}
diff --git a/homeassistant/components/ezviz/update.py b/homeassistant/components/ezviz/update.py
index 05735d152cf..25a506a0052 100644
--- a/homeassistant/components/ezviz/update.py
+++ b/homeassistant/components/ezviz/update.py
@@ -73,11 +73,9 @@ class EzvizUpdateEntity(EzvizEntity, UpdateEntity):
return self.data["version"]
@property
- def in_progress(self) -> bool | int | None:
+ def in_progress(self) -> bool:
"""Update installation progress."""
- if self.data["upgrade_in_progress"]:
- return self.data["upgrade_percent"]
- return False
+ return bool(self.data["upgrade_in_progress"])
@property
def latest_version(self) -> str | None:
@@ -93,6 +91,13 @@ class EzvizUpdateEntity(EzvizEntity, UpdateEntity):
return self.data["latest_firmware_info"].get("desc")
return None
+ @property
+ def update_percentage(self) -> int | None:
+ """Update installation progress."""
+ if self.data["upgrade_in_progress"]:
+ return self.data["upgrade_percent"]
+ return None
+
async def async_install(
self, version: str | None, backup: bool, **kwargs: Any
) -> None:
diff --git a/homeassistant/components/facebook/manifest.json b/homeassistant/components/facebook/manifest.json
index 5074489852e..5a7eb216ccc 100644
--- a/homeassistant/components/facebook/manifest.json
+++ b/homeassistant/components/facebook/manifest.json
@@ -3,5 +3,6 @@
"name": "Facebook Messenger",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/facebook",
- "iot_class": "cloud_push"
+ "iot_class": "cloud_push",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/fail2ban/manifest.json b/homeassistant/components/fail2ban/manifest.json
index e348db1c695..1570afda6eb 100644
--- a/homeassistant/components/fail2ban/manifest.json
+++ b/homeassistant/components/fail2ban/manifest.json
@@ -3,5 +3,6 @@
"name": "Fail2Ban",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/fail2ban",
- "iot_class": "local_polling"
+ "iot_class": "local_polling",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/familyhub/manifest.json b/homeassistant/components/familyhub/manifest.json
index f57030efb27..cf4bf0ba68f 100644
--- a/homeassistant/components/familyhub/manifest.json
+++ b/homeassistant/components/familyhub/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/familyhub",
"iot_class": "local_polling",
"loggers": ["pyfamilyhublocal"],
+ "quality_scale": "legacy",
"requirements": ["python-family-hub-local==0.0.2"]
}
diff --git a/homeassistant/components/fan/__init__.py b/homeassistant/components/fan/__init__.py
index b1c2b748520..863ae705603 100644
--- a/homeassistant/components/fan/__init__.py
+++ b/homeassistant/components/fan/__init__.py
@@ -2,7 +2,6 @@
from __future__ import annotations
-import asyncio
from datetime import timedelta
from enum import IntFlag
import functools as ft
@@ -23,15 +22,8 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import config_validation as cv
-from homeassistant.helpers.deprecation import (
- DeprecatedConstantEnum,
- all_with_deprecated_constants,
- check_if_deprecated_constant,
- dir_with_deprecated_constants,
-)
from homeassistant.helpers.entity import ToggleEntity, ToggleEntityDescription
from homeassistant.helpers.entity_component import EntityComponent
-from homeassistant.helpers.entity_platform import EntityPlatform
from homeassistant.helpers.typing import ConfigType
from homeassistant.loader import bind_hass
from homeassistant.util.hass_dict import HassKey
@@ -61,21 +53,6 @@ class FanEntityFeature(IntFlag):
TURN_ON = 32
-# These SUPPORT_* constants are deprecated as of Home Assistant 2022.5.
-# Please use the FanEntityFeature enum instead.
-_DEPRECATED_SUPPORT_SET_SPEED = DeprecatedConstantEnum(
- FanEntityFeature.SET_SPEED, "2025.1"
-)
-_DEPRECATED_SUPPORT_OSCILLATE = DeprecatedConstantEnum(
- FanEntityFeature.OSCILLATE, "2025.1"
-)
-_DEPRECATED_SUPPORT_DIRECTION = DeprecatedConstantEnum(
- FanEntityFeature.DIRECTION, "2025.1"
-)
-_DEPRECATED_SUPPORT_PRESET_MODE = DeprecatedConstantEnum(
- FanEntityFeature.PRESET_MODE, "2025.1"
-)
-
SERVICE_INCREASE_SPEED = "increase_speed"
SERVICE_DECREASE_SPEED = "decrease_speed"
SERVICE_OSCILLATE = "oscillate"
@@ -234,105 +211,12 @@ class FanEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
entity_description: FanEntityDescription
_attr_current_direction: str | None = None
_attr_oscillating: bool | None = None
- _attr_percentage: int | None
- _attr_preset_mode: str | None
- _attr_preset_modes: list[str] | None
- _attr_speed_count: int
+ _attr_percentage: int | None = 0
+ _attr_preset_mode: str | None = None
+ _attr_preset_modes: list[str] | None = None
+ _attr_speed_count: int = 100
_attr_supported_features: FanEntityFeature = FanEntityFeature(0)
- __mod_supported_features: FanEntityFeature = FanEntityFeature(0)
- # Integrations should set `_enable_turn_on_off_backwards_compatibility` to False
- # once migrated and set the feature flags TURN_ON/TURN_OFF as needed.
- _enable_turn_on_off_backwards_compatibility: bool = True
-
- def __getattribute__(self, __name: str) -> Any:
- """Get attribute.
-
- Modify return of `supported_features` to
- include `_mod_supported_features` if attribute is set.
- """
- if __name != "supported_features":
- return super().__getattribute__(__name)
-
- # Convert the supported features to ClimateEntityFeature.
- # Remove this compatibility shim in 2025.1 or later.
- _supported_features: FanEntityFeature = super().__getattribute__(
- "supported_features"
- )
- _mod_supported_features: FanEntityFeature = super().__getattribute__(
- "_FanEntity__mod_supported_features"
- )
- if type(_supported_features) is int: # noqa: E721
- _features = FanEntityFeature(_supported_features)
- self._report_deprecated_supported_features_values(_features)
- else:
- _features = _supported_features
-
- if not _mod_supported_features:
- return _features
-
- # Add automatically calculated FanEntityFeature.TURN_OFF/TURN_ON to
- # supported features and return it
- return _features | _mod_supported_features
-
- @callback
- def add_to_platform_start(
- self,
- hass: HomeAssistant,
- platform: EntityPlatform,
- parallel_updates: asyncio.Semaphore | None,
- ) -> None:
- """Start adding an entity to a platform."""
- super().add_to_platform_start(hass, platform, parallel_updates)
-
- def _report_turn_on_off(feature: str, method: str) -> None:
- """Log warning not implemented turn on/off feature."""
- report_issue = self._suggest_report_issue()
- message = (
- "Entity %s (%s) does not set FanEntityFeature.%s"
- " but implements the %s method. Please %s"
- )
- _LOGGER.warning(
- message,
- self.entity_id,
- type(self),
- feature,
- method,
- report_issue,
- )
-
- # Adds FanEntityFeature.TURN_OFF/TURN_ON depending on service calls implemented
- # This should be removed in 2025.2.
- if self._enable_turn_on_off_backwards_compatibility is False:
- # Return if integration has migrated already
- return
-
- supported_features = self.supported_features
- if supported_features & (FanEntityFeature.TURN_ON | FanEntityFeature.TURN_OFF):
- # The entity supports both turn_on and turn_off, the backwards compatibility
- # checks are not needed
- return
-
- if not supported_features & FanEntityFeature.TURN_OFF and (
- type(self).async_turn_off is not ToggleEntity.async_turn_off
- or type(self).turn_off is not ToggleEntity.turn_off
- ):
- # turn_off implicitly supported by implementing turn_off method
- _report_turn_on_off("TURN_OFF", "turn_off")
- self.__mod_supported_features |= ( # pylint: disable=unused-private-member
- FanEntityFeature.TURN_OFF
- )
-
- if not supported_features & FanEntityFeature.TURN_ON and (
- type(self).async_turn_on is not FanEntity.async_turn_on
- or type(self).turn_on is not FanEntity.turn_on
- ):
- # turn_on implicitly supported by implementing turn_on method
- _report_turn_on_off("TURN_ON", "turn_on")
- self.__mod_supported_features |= ( # pylint: disable=unused-private-member
- FanEntityFeature.TURN_ON
- )
-
def set_percentage(self, percentage: int) -> None:
"""Set the speed of the fan, as a percentage."""
raise NotImplementedError
@@ -463,16 +347,12 @@ class FanEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
@cached_property
def percentage(self) -> int | None:
"""Return the current speed as a percentage."""
- if hasattr(self, "_attr_percentage"):
- return self._attr_percentage
- return 0
+ return self._attr_percentage
@cached_property
def speed_count(self) -> int:
"""Return the number of speeds the fan supports."""
- if hasattr(self, "_attr_speed_count"):
- return self._attr_speed_count
- return 100
+ return self._attr_speed_count
@property
def percentage_step(self) -> float:
@@ -538,9 +418,7 @@ class FanEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
Requires FanEntityFeature.SET_SPEED.
"""
- if hasattr(self, "_attr_preset_mode"):
- return self._attr_preset_mode
- return None
+ return self._attr_preset_mode
@cached_property
def preset_modes(self) -> list[str] | None:
@@ -548,14 +426,4 @@ class FanEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
Requires FanEntityFeature.SET_SPEED.
"""
- if hasattr(self, "_attr_preset_modes"):
- return self._attr_preset_modes
- return None
-
-
-# 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())
+ return self._attr_preset_modes
diff --git a/homeassistant/components/fan/strings.json b/homeassistant/components/fan/strings.json
index aab714d3e07..c4951e88c91 100644
--- a/homeassistant/components/fan/strings.json
+++ b/homeassistant/components/fan/strings.json
@@ -56,17 +56,17 @@
"services": {
"set_preset_mode": {
"name": "Set preset mode",
- "description": "Sets preset mode.",
+ "description": "Sets preset fan mode.",
"fields": {
"preset_mode": {
"name": "Preset mode",
- "description": "Preset mode."
+ "description": "Preset fan mode."
}
}
},
"set_percentage": {
"name": "Set speed",
- "description": "Sets the fan speed.",
+ "description": "Sets the speed of a fan.",
"fields": {
"percentage": {
"name": "Percentage",
@@ -94,45 +94,45 @@
},
"oscillate": {
"name": "Oscillate",
- "description": "Controls oscillatation of the fan.",
+ "description": "Controls the oscillation of a fan.",
"fields": {
"oscillating": {
"name": "Oscillating",
- "description": "Turn on/off oscillation."
+ "description": "Turns oscillation on/off."
}
}
},
"toggle": {
"name": "[%key:common::action::toggle%]",
- "description": "Toggles the fan on/off."
+ "description": "Toggles a fan on/off."
},
"set_direction": {
"name": "Set direction",
- "description": "Sets the fan rotation direction.",
+ "description": "Sets a fan's rotation direction.",
"fields": {
"direction": {
"name": "Direction",
- "description": "Direction to rotate."
+ "description": "Direction of the fan rotation."
}
}
},
"increase_speed": {
"name": "Increase speed",
- "description": "Increases the speed of the fan.",
+ "description": "Increases the speed of a fan.",
"fields": {
"percentage_step": {
"name": "Increment",
- "description": "Increases the speed by a percentage step."
+ "description": "Percentage step by which the speed should be increased."
}
}
},
"decrease_speed": {
"name": "Decrease speed",
- "description": "Decreases the speed of the fan.",
+ "description": "Decreases the speed of a fan.",
"fields": {
"percentage_step": {
"name": "Decrement",
- "description": "Decreases the speed by a percentage step."
+ "description": "Percentage step by which the speed should be decreased."
}
}
}
diff --git a/homeassistant/components/fastdotcom/manifest.json b/homeassistant/components/fastdotcom/manifest.json
index 9e2e077858c..10b6fdb5b5d 100644
--- a/homeassistant/components/fastdotcom/manifest.json
+++ b/homeassistant/components/fastdotcom/manifest.json
@@ -6,7 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/fastdotcom",
"iot_class": "cloud_polling",
"loggers": ["fastdotcom"],
- "quality_scale": "gold",
"requirements": ["fastdotcom==0.0.3"],
"single_config_entry": true
}
diff --git a/homeassistant/components/feedreader/__init__.py b/homeassistant/components/feedreader/__init__.py
index b9f0b006e2a..9faed54c041 100644
--- a/homeassistant/components/feedreader/__init__.py
+++ b/homeassistant/components/feedreader/__init__.py
@@ -2,17 +2,12 @@
from __future__ import annotations
-import voluptuous as vol
-
-from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
-from homeassistant.const import CONF_SCAN_INTERVAL, CONF_URL, Platform
-from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant
-import homeassistant.helpers.config_validation as cv
-from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
-from homeassistant.helpers.typing import ConfigType
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import CONF_URL, Platform
+from homeassistant.core import HomeAssistant
from homeassistant.util.hass_dict import HassKey
-from .const import CONF_MAX_ENTRIES, DEFAULT_MAX_ENTRIES, DEFAULT_SCAN_INTERVAL, DOMAIN
+from .const import CONF_MAX_ENTRIES, DOMAIN
from .coordinator import FeedReaderCoordinator, StoredData
type FeedReaderConfigEntry = ConfigEntry[FeedReaderCoordinator]
@@ -21,60 +16,6 @@ CONF_URLS = "urls"
MY_KEY: HassKey[StoredData] = HassKey(DOMAIN)
-CONFIG_SCHEMA = vol.Schema(
- vol.All(
- cv.deprecated(DOMAIN),
- {
- DOMAIN: vol.Schema(
- {
- vol.Required(CONF_URLS): vol.All(cv.ensure_list, [cv.url]),
- vol.Optional(
- CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL
- ): cv.time_period,
- vol.Optional(
- CONF_MAX_ENTRIES, default=DEFAULT_MAX_ENTRIES
- ): cv.positive_int,
- }
- )
- },
- ),
- extra=vol.ALLOW_EXTRA,
-)
-
-
-async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
- """Set up the Feedreader component."""
- if DOMAIN in config:
- for url in config[DOMAIN][CONF_URLS]:
- hass.async_create_task(
- hass.config_entries.flow.async_init(
- DOMAIN,
- context={"source": SOURCE_IMPORT},
- data={
- CONF_URL: url,
- CONF_MAX_ENTRIES: config[DOMAIN][CONF_MAX_ENTRIES],
- },
- )
- )
-
- async_create_issue(
- hass,
- HOMEASSISTANT_DOMAIN,
- f"deprecated_yaml_{DOMAIN}",
- breaks_in_ha_version="2025.1.0",
- is_fixable=False,
- is_persistent=False,
- issue_domain=DOMAIN,
- severity=IssueSeverity.WARNING,
- translation_key="deprecated_yaml",
- translation_placeholders={
- "domain": DOMAIN,
- "integration_title": "Feedreader",
- },
- )
-
- return True
-
async def async_setup_entry(hass: HomeAssistant, entry: FeedReaderConfigEntry) -> bool:
"""Set up Feedreader from a config entry."""
diff --git a/homeassistant/components/feedreader/config_flow.py b/homeassistant/components/feedreader/config_flow.py
index b902d48a1c8..f3e56ad1778 100644
--- a/homeassistant/components/feedreader/config_flow.py
+++ b/homeassistant/components/feedreader/config_flow.py
@@ -2,6 +2,7 @@
from __future__ import annotations
+import html
import logging
from typing import Any
import urllib.error
@@ -10,7 +11,6 @@ import feedparser
import voluptuous as vol
from homeassistant.config_entries import (
- SOURCE_IMPORT,
ConfigEntry,
ConfigFlow,
ConfigFlowResult,
@@ -19,13 +19,11 @@ 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.issue_registry import IssueSeverity, async_create_issue
from homeassistant.helpers.selector import (
TextSelector,
TextSelectorConfig,
TextSelectorType,
)
-from homeassistant.util import slugify
from .const import CONF_MAX_ENTRIES, DEFAULT_MAX_ENTRIES, DOMAIN
@@ -41,7 +39,6 @@ class FeedReaderConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow."""
VERSION = 1
- _max_entries: int | None = None
@staticmethod
@callback
@@ -74,21 +71,6 @@ class FeedReaderConfigFlow(ConfigFlow, domain=DOMAIN):
errors=errors,
)
- def abort_on_import_error(self, url: str, error: str) -> ConfigFlowResult:
- """Abort import flow on error."""
- async_create_issue(
- self.hass,
- DOMAIN,
- f"import_yaml_error_{DOMAIN}_{error}_{slugify(url)}",
- breaks_in_ha_version="2025.1.0",
- is_fixable=False,
- issue_domain=DOMAIN,
- severity=IssueSeverity.WARNING,
- translation_key=f"import_yaml_error_{error}",
- translation_placeholders={"url": url},
- )
- return self.async_abort(reason=error)
-
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
@@ -103,23 +85,16 @@ class FeedReaderConfigFlow(ConfigFlow, domain=DOMAIN):
if feed.bozo:
LOGGER.debug("feed bozo_exception: %s", feed.bozo_exception)
if isinstance(feed.bozo_exception, urllib.error.URLError):
- if self.context["source"] == SOURCE_IMPORT:
- return self.abort_on_import_error(user_input[CONF_URL], "url_error")
return self.show_user_form(user_input, {"base": "url_error"})
- feed_title = feed["feed"]["title"]
+ feed_title = html.unescape(feed["feed"]["title"])
return self.async_create_entry(
title=feed_title,
data=user_input,
- options={CONF_MAX_ENTRIES: self._max_entries or DEFAULT_MAX_ENTRIES},
+ options={CONF_MAX_ENTRIES: DEFAULT_MAX_ENTRIES},
)
- async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult:
- """Handle an import flow."""
- self._max_entries = import_data[CONF_MAX_ENTRIES]
- return await self.async_step_user({CONF_URL: import_data[CONF_URL]})
-
async def async_step_reconfigure(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
diff --git a/homeassistant/components/feedreader/coordinator.py b/homeassistant/components/feedreader/coordinator.py
index 6608c4312fe..fc338d63268 100644
--- a/homeassistant/components/feedreader/coordinator.py
+++ b/homeassistant/components/feedreader/coordinator.py
@@ -4,6 +4,7 @@ from __future__ import annotations
from calendar import timegm
from datetime import datetime
+import html
from logging import getLogger
from time import gmtime, struct_time
from typing import TYPE_CHECKING
@@ -13,6 +14,7 @@ import feedparser
from homeassistant.config_entries import ConfigEntry
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
@@ -100,9 +102,14 @@ class FeedReaderCoordinator(
async def async_setup(self) -> None:
"""Set up the feed manager."""
- feed = await self._async_fetch_feed()
+ try:
+ feed = await self._async_fetch_feed()
+ except UpdateFailed as err:
+ raise ConfigEntryNotReady from err
+
self.logger.debug("Feed data fetched from %s : %s", self.url, feed["feed"])
- self.feed_author = feed["feed"].get("author")
+ if feed_author := feed["feed"].get("author"):
+ self.feed_author = html.unescape(feed_author)
self.feed_version = feedparser.api.SUPPORTED_VERSIONS.get(feed["version"])
self._feed = feed
diff --git a/homeassistant/components/feedreader/event.py b/homeassistant/components/feedreader/event.py
index 4b3fb2e2524..ad6aed0fc76 100644
--- a/homeassistant/components/feedreader/event.py
+++ b/homeassistant/components/feedreader/event.py
@@ -2,6 +2,7 @@
from __future__ import annotations
+import html
import logging
from feedparser import FeedParserDict
@@ -76,15 +77,22 @@ class FeedReaderEvent(CoordinatorEntity[FeedReaderCoordinator], EventEntity):
# so we always take the first entry in list, since we only care about the latest entry
feed_data: FeedParserDict = data[0]
+ if description := feed_data.get("description"):
+ description = html.unescape(description)
+
+ if title := feed_data.get("title"):
+ title = html.unescape(title)
+
if content := feed_data.get("content"):
if isinstance(content, list) and isinstance(content[0], dict):
content = content[0].get("value")
+ content = html.unescape(content)
self._trigger_event(
EVENT_FEEDREADER,
{
- ATTR_DESCRIPTION: feed_data.get("description"),
- ATTR_TITLE: feed_data.get("title"),
+ ATTR_DESCRIPTION: description,
+ ATTR_TITLE: title,
ATTR_LINK: feed_data.get("link"),
ATTR_CONTENT: content,
},
diff --git a/homeassistant/components/ffmpeg/__init__.py b/homeassistant/components/ffmpeg/__init__.py
index 9a88317027e..99803e9636c 100644
--- a/homeassistant/components/ffmpeg/__init__.py
+++ b/homeassistant/components/ffmpeg/__init__.py
@@ -23,10 +23,10 @@ from homeassistant.helpers.dispatcher import (
async_dispatcher_send,
)
from homeassistant.helpers.entity import Entity
-from homeassistant.helpers.system_info import is_official_image
from homeassistant.helpers.typing import ConfigType
from homeassistant.loader import bind_hass
from homeassistant.util.signal_type import SignalType
+from homeassistant.util.system_info import is_official_image
DOMAIN = "ffmpeg"
diff --git a/homeassistant/components/ffmpeg_motion/manifest.json b/homeassistant/components/ffmpeg_motion/manifest.json
index 0115ed712e3..f51a6206e2b 100644
--- a/homeassistant/components/ffmpeg_motion/manifest.json
+++ b/homeassistant/components/ffmpeg_motion/manifest.json
@@ -4,5 +4,6 @@
"codeowners": [],
"dependencies": ["ffmpeg"],
"documentation": "https://www.home-assistant.io/integrations/ffmpeg_motion",
- "iot_class": "calculated"
+ "iot_class": "calculated",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/ffmpeg_noise/manifest.json b/homeassistant/components/ffmpeg_noise/manifest.json
index 6352fed88c4..f1c0cc9f673 100644
--- a/homeassistant/components/ffmpeg_noise/manifest.json
+++ b/homeassistant/components/ffmpeg_noise/manifest.json
@@ -4,5 +4,6 @@
"codeowners": [],
"dependencies": ["ffmpeg"],
"documentation": "https://www.home-assistant.io/integrations/ffmpeg_noise",
- "iot_class": "calculated"
+ "iot_class": "calculated",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/fibaro/__init__.py b/homeassistant/components/fibaro/__init__.py
index 18b9f46eb20..8ede0169482 100644
--- a/homeassistant/components/fibaro/__init__.py
+++ b/homeassistant/components/fibaro/__init__.py
@@ -28,8 +28,9 @@ from homeassistant.util import slugify
from .const import CONF_IMPORT_PLUGINS, DOMAIN
-_LOGGER = logging.getLogger(__name__)
+type FibaroConfigEntry = ConfigEntry[FibaroController]
+_LOGGER = logging.getLogger(__name__)
PLATFORMS = [
Platform.BINARY_SENSOR,
@@ -381,7 +382,7 @@ def init_controller(data: Mapping[str, Any]) -> FibaroController:
return controller
-async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_setup_entry(hass: HomeAssistant, entry: FibaroConfigEntry) -> bool:
"""Set up the Fibaro Component.
The unique id of the config entry is the serial number of the home center.
@@ -395,7 +396,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
except FibaroAuthFailed as auth_ex:
raise ConfigEntryAuthFailed from auth_ex
- hass.data.setdefault(DOMAIN, {})[entry.entry_id] = controller
+ entry.runtime_data = controller
# register the hub device info separately as the hub has sometimes no entities
device_registry = dr.async_get(hass)
@@ -417,25 +418,24 @@ 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: FibaroConfigEntry) -> bool:
"""Unload a config entry."""
_LOGGER.debug("Shutting down Fibaro connection")
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
- hass.data[DOMAIN][entry.entry_id].disable_state_handler()
- hass.data[DOMAIN].pop(entry.entry_id)
+ entry.runtime_data.disable_state_handler()
return unload_ok
async def async_remove_config_entry_device(
- hass: HomeAssistant, config_entry: ConfigEntry, device_entry: DeviceEntry
+ hass: HomeAssistant, config_entry: FibaroConfigEntry, device_entry: DeviceEntry
) -> bool:
"""Remove a device entry from fibaro integration.
Only removing devices which are not present anymore are eligible to be removed.
"""
- controller: FibaroController = hass.data[DOMAIN][config_entry.entry_id]
+ controller = config_entry.runtime_data
for identifiers in controller.get_all_device_identifiers():
if device_entry.identifiers == identifiers:
# Fibaro device is still served by the controller,
diff --git a/homeassistant/components/fibaro/binary_sensor.py b/homeassistant/components/fibaro/binary_sensor.py
index 9f3efbfb514..16e79c0c1d0 100644
--- a/homeassistant/components/fibaro/binary_sensor.py
+++ b/homeassistant/components/fibaro/binary_sensor.py
@@ -12,13 +12,11 @@ from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from . import FibaroController
-from .const import DOMAIN
+from . import FibaroConfigEntry
from .entity import FibaroEntity
SENSOR_TYPES = {
@@ -43,11 +41,11 @@ SENSOR_TYPES = {
async def async_setup_entry(
hass: HomeAssistant,
- entry: ConfigEntry,
+ entry: FibaroConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Perform the setup for Fibaro controller devices."""
- controller: FibaroController = hass.data[DOMAIN][entry.entry_id]
+ controller = entry.runtime_data
async_add_entities(
[
FibaroBinarySensor(device)
diff --git a/homeassistant/components/fibaro/climate.py b/homeassistant/components/fibaro/climate.py
index 0bfc2223317..45f700026a0 100644
--- a/homeassistant/components/fibaro/climate.py
+++ b/homeassistant/components/fibaro/climate.py
@@ -17,13 +17,11 @@ from homeassistant.components.climate import (
HVACAction,
HVACMode,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_TEMPERATURE, Platform, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from . import FibaroController
-from .const import DOMAIN
+from . import FibaroConfigEntry
from .entity import FibaroEntity
PRESET_RESUME = "resume"
@@ -111,11 +109,11 @@ OP_MODE_ACTIONS = ("setMode", "setOperatingMode", "setThermostatMode")
async def async_setup_entry(
hass: HomeAssistant,
- entry: ConfigEntry,
+ entry: FibaroConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Perform the setup for Fibaro controller devices."""
- controller: FibaroController = hass.data[DOMAIN][entry.entry_id]
+ controller = entry.runtime_data
async_add_entities(
[
FibaroThermostat(device)
@@ -128,8 +126,6 @@ async def async_setup_entry(
class FibaroThermostat(FibaroEntity, ClimateEntity):
"""Representation of a Fibaro Thermostat."""
- _enable_turn_on_off_backwards_compatibility = False
-
def __init__(self, fibaro_device: DeviceModel) -> None:
"""Initialize the Fibaro device."""
super().__init__(fibaro_device)
@@ -274,7 +270,9 @@ class FibaroThermostat(FibaroEntity, ClimateEntity):
if isinstance(fibaro_operation_mode, str):
with suppress(ValueError):
return HVACMode(fibaro_operation_mode.lower())
- elif fibaro_operation_mode in OPMODES_HVAC:
+ # when the mode cannot be instantiated a preset_mode is selected
+ return HVACMode.AUTO
+ if fibaro_operation_mode in OPMODES_HVAC:
return OPMODES_HVAC[fibaro_operation_mode]
return None
@@ -282,8 +280,6 @@ class FibaroThermostat(FibaroEntity, ClimateEntity):
"""Set new target operation mode."""
if not self._op_mode_device:
return
- if self.preset_mode:
- return
if "setOperatingMode" in self._op_mode_device.fibaro_device.actions:
self._op_mode_device.action("setOperatingMode", HA_OPMODES_HVAC[hvac_mode])
diff --git a/homeassistant/components/fibaro/cover.py b/homeassistant/components/fibaro/cover.py
index c787ca70272..bfebbf87bd2 100644
--- a/homeassistant/components/fibaro/cover.py
+++ b/homeassistant/components/fibaro/cover.py
@@ -13,23 +13,21 @@ from homeassistant.components.cover import (
CoverEntity,
CoverEntityFeature,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from . import FibaroController
-from .const import DOMAIN
+from . import FibaroConfigEntry
from .entity import FibaroEntity
async def async_setup_entry(
hass: HomeAssistant,
- entry: ConfigEntry,
+ entry: FibaroConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Fibaro covers."""
- controller: FibaroController = hass.data[DOMAIN][entry.entry_id]
+ controller = entry.runtime_data
async_add_entities(
[FibaroCover(device) for device in controller.fibaro_devices[Platform.COVER]],
True,
@@ -69,37 +67,29 @@ class FibaroCover(FibaroEntity, CoverEntity):
# so if it is missing we have a device which supports open / close only
return not self.fibaro_device.value.has_value
- @property
- def current_cover_position(self) -> int | None:
- """Return current position of cover. 0 is closed, 100 is open."""
- return self.bound(self.level)
+ def update(self) -> None:
+ """Update the state."""
+ super().update()
- @property
- def current_cover_tilt_position(self) -> int | None:
- """Return the current tilt position for venetian blinds."""
- return self.bound(self.level2)
+ self._attr_current_cover_position = self.bound(self.level)
+ self._attr_current_cover_tilt_position = self.bound(self.level2)
- @property
- def is_opening(self) -> bool | None:
- """Return if the cover is opening or not.
+ device_state = self.fibaro_device.state
- Be aware that this property is only available for some modern devices.
- For example the Fibaro Roller Shutter 4 reports this correctly.
- """
- if self.fibaro_device.state.has_value:
- return self.fibaro_device.state.str_value().lower() == "opening"
- return None
+ # Be aware that opening and closing is only available for some modern
+ # devices.
+ # For example the Fibaro Roller Shutter 4 reports this correctly.
+ if device_state.has_value:
+ self._attr_is_opening = device_state.str_value().lower() == "opening"
+ self._attr_is_closing = device_state.str_value().lower() == "closing"
- @property
- def is_closing(self) -> bool | None:
- """Return if the cover is closing or not.
-
- Be aware that this property is only available for some modern devices.
- For example the Fibaro Roller Shutter 4 reports this correctly.
- """
- if self.fibaro_device.state.has_value:
- return self.fibaro_device.state.str_value().lower() == "closing"
- return None
+ closed: bool | None = None
+ if self._is_open_close_only():
+ if device_state.has_value and device_state.str_value().lower() != "unknown":
+ closed = device_state.str_value().lower() == "closed"
+ elif self.current_cover_position is not None:
+ closed = self.current_cover_position == 0
+ self._attr_is_closed = closed
def set_cover_position(self, **kwargs: Any) -> None:
"""Move the cover to a specific position."""
@@ -109,19 +99,6 @@ class FibaroCover(FibaroEntity, CoverEntity):
"""Move the cover to a specific position."""
self.set_level2(cast(int, kwargs.get(ATTR_TILT_POSITION)))
- @property
- def is_closed(self) -> bool | None:
- """Return if the cover is closed."""
- if self._is_open_close_only():
- state = self.fibaro_device.state
- if not state.has_value or state.str_value().lower() == "unknown":
- return None
- return state.str_value().lower() == "closed"
-
- if self.current_cover_position is None:
- return None
- return self.current_cover_position == 0
-
def open_cover(self, **kwargs: Any) -> None:
"""Open the cover."""
self.action("open")
diff --git a/homeassistant/components/fibaro/event.py b/homeassistant/components/fibaro/event.py
index c964ab283c1..a2d5da7f877 100644
--- a/homeassistant/components/fibaro/event.py
+++ b/homeassistant/components/fibaro/event.py
@@ -10,23 +10,21 @@ from homeassistant.components.event import (
EventDeviceClass,
EventEntity,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from . import FibaroController
-from .const import DOMAIN
+from . import FibaroConfigEntry
from .entity import FibaroEntity
async def async_setup_entry(
hass: HomeAssistant,
- entry: ConfigEntry,
+ entry: FibaroConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Fibaro event entities."""
- controller: FibaroController = hass.data[DOMAIN][entry.entry_id]
+ controller = entry.runtime_data
# Each scene event represents a button on a device
async_add_entities(
diff --git a/homeassistant/components/fibaro/light.py b/homeassistant/components/fibaro/light.py
index 17831a36a4a..d40e26244f3 100644
--- a/homeassistant/components/fibaro/light.py
+++ b/homeassistant/components/fibaro/light.py
@@ -17,13 +17,11 @@ from homeassistant.components.light import (
brightness_supported,
color_supported,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from . import FibaroController
-from .const import DOMAIN
+from . import FibaroConfigEntry
from .entity import FibaroEntity
PARALLEL_UPDATES = 2
@@ -52,11 +50,11 @@ def scaleto99(value: int | None) -> int:
async def async_setup_entry(
hass: HomeAssistant,
- entry: ConfigEntry,
+ entry: FibaroConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Perform the setup for Fibaro controller devices."""
- controller: FibaroController = hass.data[DOMAIN][entry.entry_id]
+ controller = entry.runtime_data
async_add_entities(
[FibaroLight(device) for device in controller.fibaro_devices[Platform.LIGHT]],
True,
@@ -132,32 +130,25 @@ class FibaroLight(FibaroEntity, LightEntity):
"""Turn the light off."""
self.call_turn_off()
- @property
- def is_on(self) -> bool | None:
- """Return true if device is on.
-
- Dimmable and RGB lights can be on based on different
- properties, so we need to check here several values.
-
- JSON for HC2 uses always string, HC3 uses int for integers.
- """
- if self.current_binary_state:
- return True
- with suppress(TypeError):
- if self.fibaro_device.brightness != 0:
- return True
- with suppress(TypeError):
- if self.fibaro_device.current_program != 0:
- return True
- with suppress(TypeError):
- if self.fibaro_device.current_program_id != 0:
- return True
-
- return False
-
def update(self) -> None:
"""Update the state."""
super().update()
+
+ # Dimmable and RGB lights can be on based on different
+ # properties, so we need to check here several values
+ # to see if the light is on.
+ light_is_on = self.current_binary_state
+ with suppress(TypeError):
+ if self.fibaro_device.brightness != 0:
+ light_is_on = True
+ with suppress(TypeError):
+ if self.fibaro_device.current_program != 0:
+ light_is_on = True
+ with suppress(TypeError):
+ if self.fibaro_device.current_program_id != 0:
+ light_is_on = True
+ self._attr_is_on = light_is_on
+
# Brightness handling
if brightness_supported(self.supported_color_modes):
self._attr_brightness = scaleto255(self.fibaro_device.value.int_value())
@@ -172,7 +163,7 @@ class FibaroLight(FibaroEntity, LightEntity):
if rgbw == (0, 0, 0, 0) and self.fibaro_device.last_color_set.has_color:
rgbw = self.fibaro_device.last_color_set.rgbw_color
- if self._attr_color_mode == ColorMode.RGB:
+ if self.color_mode == ColorMode.RGB:
self._attr_rgb_color = rgbw[:3]
else:
self._attr_rgbw_color = rgbw
diff --git a/homeassistant/components/fibaro/lock.py b/homeassistant/components/fibaro/lock.py
index 55583d2a967..62a9dfa43b1 100644
--- a/homeassistant/components/fibaro/lock.py
+++ b/homeassistant/components/fibaro/lock.py
@@ -7,23 +7,21 @@ from typing import Any
from pyfibaro.fibaro_device import DeviceModel
from homeassistant.components.lock import ENTITY_ID_FORMAT, LockEntity
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from . import FibaroController
-from .const import DOMAIN
+from . import FibaroConfigEntry
from .entity import FibaroEntity
async def async_setup_entry(
hass: HomeAssistant,
- entry: ConfigEntry,
+ entry: FibaroConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Fibaro locks."""
- controller: FibaroController = hass.data[DOMAIN][entry.entry_id]
+ controller = entry.runtime_data
async_add_entities(
[FibaroLock(device) for device in controller.fibaro_devices[Platform.LOCK]],
True,
diff --git a/homeassistant/components/fibaro/scene.py b/homeassistant/components/fibaro/scene.py
index a40a1ef5b57..a4c0f1bd7f1 100644
--- a/homeassistant/components/fibaro/scene.py
+++ b/homeassistant/components/fibaro/scene.py
@@ -7,23 +7,22 @@ from typing import Any
from pyfibaro.fibaro_scene import SceneModel
from homeassistant.components.scene import Scene
-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 homeassistant.util import slugify
-from . import FibaroController
+from . import FibaroConfigEntry, FibaroController
from .const import DOMAIN
async def async_setup_entry(
hass: HomeAssistant,
- entry: ConfigEntry,
+ entry: FibaroConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Perform the setup for Fibaro scenes."""
- controller: FibaroController = hass.data[DOMAIN][entry.entry_id]
+ controller = entry.runtime_data
async_add_entities(
[FibaroScene(scene, controller) for scene in controller.read_scenes()],
True,
diff --git a/homeassistant/components/fibaro/sensor.py b/homeassistant/components/fibaro/sensor.py
index da94cde9ead..245a0d087d8 100644
--- a/homeassistant/components/fibaro/sensor.py
+++ b/homeassistant/components/fibaro/sensor.py
@@ -13,7 +13,6 @@ from homeassistant.components.sensor import (
SensorEntityDescription,
SensorStateClass,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONCENTRATION_PARTS_PER_MILLION,
LIGHT_LUX,
@@ -27,8 +26,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util import convert
-from . import FibaroController
-from .const import DOMAIN
+from . import FibaroConfigEntry
from .entity import FibaroEntity
# List of known sensors which represents a fibaro device
@@ -103,12 +101,12 @@ FIBARO_TO_HASS_UNIT: dict[str, str] = {
async def async_setup_entry(
hass: HomeAssistant,
- entry: ConfigEntry,
+ entry: FibaroConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Fibaro controller devices."""
- controller: FibaroController = hass.data[DOMAIN][entry.entry_id]
+ controller = entry.runtime_data
entities: list[SensorEntity] = [
FibaroSensor(device, MAIN_SENSOR_TYPES.get(device.type))
for device in controller.fibaro_devices[Platform.SENSOR]
diff --git a/homeassistant/components/fibaro/strings.json b/homeassistant/components/fibaro/strings.json
index de875176cdb..99f718d545c 100644
--- a/homeassistant/components/fibaro/strings.json
+++ b/homeassistant/components/fibaro/strings.json
@@ -3,16 +3,25 @@
"step": {
"user": {
"data": {
- "url": "URL in the format http://HOST/api/",
+ "url": "[%key:common::config_flow::data::url%]",
"username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]",
- "import_plugins": "Import entities from fibaro plugins?"
+ "import_plugins": "Import entities from fibaro plugins / quickapps"
+ },
+ "data_description": {
+ "url": "The URL of the Fibaro hub in the format `http(s)://IP`.",
+ "username": "The username of the Fibaro hub user.",
+ "password": "The password of the Fibaro hub user.",
+ "import_plugins": "Select if entities from Fibaro plugins / quickapps should be imported."
}
},
"reauth_confirm": {
"data": {
"password": "[%key:common::config_flow::data::password%]"
},
+ "data_description": {
+ "password": "[%key:component::fibaro::config::step::user::data_description::password%]"
+ },
"title": "[%key:common::config_flow::title::reauth%]",
"description": "Please update your password for {username}"
}
diff --git a/homeassistant/components/fibaro/switch.py b/homeassistant/components/fibaro/switch.py
index 1ad933f5d20..f67683dff6a 100644
--- a/homeassistant/components/fibaro/switch.py
+++ b/homeassistant/components/fibaro/switch.py
@@ -7,23 +7,21 @@ from typing import Any
from pyfibaro.fibaro_device import DeviceModel
from homeassistant.components.switch import ENTITY_ID_FORMAT, SwitchEntity
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from . import FibaroController
-from .const import DOMAIN
+from . import FibaroConfigEntry
from .entity import FibaroEntity
async def async_setup_entry(
hass: HomeAssistant,
- entry: ConfigEntry,
+ entry: FibaroConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Fibaro switches."""
- controller: FibaroController = hass.data[DOMAIN][entry.entry_id]
+ controller = entry.runtime_data
async_add_entities(
[FibaroSwitch(device) for device in controller.fibaro_devices[Platform.SWITCH]],
True,
diff --git a/homeassistant/components/fido/manifest.json b/homeassistant/components/fido/manifest.json
index dc440304646..23949a56ee2 100644
--- a/homeassistant/components/fido/manifest.json
+++ b/homeassistant/components/fido/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/fido",
"iot_class": "cloud_polling",
"loggers": ["pyfido"],
+ "quality_scale": "legacy",
"requirements": ["pyfido==2.1.2"]
}
diff --git a/homeassistant/components/file/__init__.py b/homeassistant/components/file/__init__.py
index 0c9cfee5f4d..7bc206057c8 100644
--- a/homeassistant/components/file/__init__.py
+++ b/homeassistant/components/file/__init__.py
@@ -3,88 +3,16 @@
from copy import deepcopy
from typing import Any
-from homeassistant.components.notify import migrate_notify_issue
-from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
-from homeassistant.const import (
- CONF_FILE_PATH,
- CONF_NAME,
- CONF_PLATFORM,
- CONF_SCAN_INTERVAL,
- Platform,
-)
-from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import CONF_FILE_PATH, CONF_NAME, CONF_PLATFORM, Platform
+from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
-from homeassistant.helpers import (
- config_validation as cv,
- discovery,
- issue_registry as ir,
-)
-from homeassistant.helpers.typing import ConfigType
from .const import DOMAIN
-from .notify import PLATFORM_SCHEMA as NOTIFY_PLATFORM_SCHEMA
-from .sensor import PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA
-
-IMPORT_SCHEMA = {
- Platform.SENSOR: SENSOR_PLATFORM_SCHEMA,
- Platform.NOTIFY: NOTIFY_PLATFORM_SCHEMA,
-}
-
-CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
PLATFORMS = [Platform.NOTIFY, Platform.SENSOR]
-async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
- """Set up the file integration."""
-
- hass.data[DOMAIN] = config
- if hass.config_entries.async_entries(DOMAIN):
- # We skip import in case we already have config entries
- return True
- # The use of the legacy notify service was deprecated with HA Core 2024.6.0
- # and will be removed with HA Core 2024.12
- migrate_notify_issue(hass, DOMAIN, "File", "2024.12.0")
- # The YAML config was imported with HA Core 2024.6.0 and will be removed with
- # HA Core 2024.12
- ir.async_create_issue(
- hass,
- HOMEASSISTANT_DOMAIN,
- f"deprecated_yaml_{DOMAIN}",
- breaks_in_ha_version="2024.12.0",
- is_fixable=False,
- issue_domain=DOMAIN,
- learn_more_url="https://www.home-assistant.io/integrations/file/",
- severity=ir.IssueSeverity.WARNING,
- translation_key="deprecated_yaml",
- translation_placeholders={
- "domain": DOMAIN,
- "integration_title": "File",
- },
- )
-
- # Import the YAML config into separate config entries
- platforms_config: dict[Platform, list[ConfigType]] = {
- domain: config[domain] for domain in PLATFORMS if domain in config
- }
- for domain, items in platforms_config.items():
- for item in items:
- if item[CONF_PLATFORM] == DOMAIN:
- file_config_item = IMPORT_SCHEMA[domain](item)
- file_config_item[CONF_PLATFORM] = domain
- if CONF_SCAN_INTERVAL in file_config_item:
- del file_config_item[CONF_SCAN_INTERVAL]
- hass.async_create_task(
- hass.config_entries.flow.async_init(
- DOMAIN,
- context={"source": SOURCE_IMPORT},
- data=file_config_item,
- )
- )
-
- return True
-
-
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up a file component entry."""
config = {**entry.data, **entry.options}
@@ -102,20 +30,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
entry, [Platform(entry.data[CONF_PLATFORM])]
)
entry.async_on_unload(entry.add_update_listener(update_listener))
- if entry.data[CONF_PLATFORM] == Platform.NOTIFY and CONF_NAME in entry.data:
- # New notify entities are being setup through the config entry,
- # but during the deprecation period we want to keep the legacy notify platform,
- # so we forward the setup config through discovery.
- # Only the entities from yaml will still be available as legacy service.
- hass.async_create_task(
- discovery.async_load_platform(
- hass,
- Platform.NOTIFY,
- DOMAIN,
- config,
- hass.data[DOMAIN],
- )
- )
return True
diff --git a/homeassistant/components/file/config_flow.py b/homeassistant/components/file/config_flow.py
index 2b8a9bde749..1c4fdbe5c84 100644
--- a/homeassistant/components/file/config_flow.py
+++ b/homeassistant/components/file/config_flow.py
@@ -3,7 +3,6 @@
from __future__ import annotations
from copy import deepcopy
-import os
from typing import Any
import voluptuous as vol
@@ -16,7 +15,6 @@ from homeassistant.config_entries import (
)
from homeassistant.const import (
CONF_FILE_PATH,
- CONF_FILENAME,
CONF_NAME,
CONF_PLATFORM,
CONF_UNIT_OF_MEASUREMENT,
@@ -34,7 +32,7 @@ from homeassistant.helpers.selector import (
TextSelectorType,
)
-from .const import CONF_TIMESTAMP, DEFAULT_NAME, DOMAIN
+from .const import CONF_TIMESTAMP, DOMAIN
BOOLEAN_SELECTOR = BooleanSelector(BooleanSelectorConfig())
TEMPLATE_SELECTOR = TemplateSelector(TemplateSelectorConfig())
@@ -107,7 +105,7 @@ class FileConfigFlowHandler(ConfigFlow, domain=DOMAIN):
if not await self.validate_file_path(user_input[CONF_FILE_PATH]):
errors[CONF_FILE_PATH] = "not_allowed"
else:
- title = f"{DEFAULT_NAME} [{user_input[CONF_FILE_PATH]}]"
+ title = f"{platform.capitalize()} [{user_input[CONF_FILE_PATH]}]"
data = deepcopy(user_input)
options = {}
for key, value in user_input.items():
@@ -132,27 +130,6 @@ class FileConfigFlowHandler(ConfigFlow, domain=DOMAIN):
"""Handle file sensor config flow."""
return await self._async_handle_step(Platform.SENSOR.value, user_input)
- async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult:
- """Import `file`` config from configuration.yaml."""
- self._async_abort_entries_match(import_data)
- platform = import_data[CONF_PLATFORM]
- name: str = import_data.get(CONF_NAME, DEFAULT_NAME)
- file_name: str
- if platform == Platform.NOTIFY:
- file_name = import_data.pop(CONF_FILENAME)
- file_path: str = os.path.join(self.hass.config.config_dir, file_name)
- import_data[CONF_FILE_PATH] = file_path
- else:
- file_path = import_data[CONF_FILE_PATH]
- title = f"{name} [{file_path}]"
- data = deepcopy(import_data)
- options = {}
- for key, value in import_data.items():
- if key not in (CONF_FILE_PATH, CONF_PLATFORM, CONF_NAME):
- data.pop(key)
- options[key] = value
- return self.async_create_entry(title=title, data=data, options=options)
-
class FileOptionsFlowHandler(OptionsFlow):
"""Handle File options."""
diff --git a/homeassistant/components/file/notify.py b/homeassistant/components/file/notify.py
index 9411b7cf1a8..10e3d4a4ac6 100644
--- a/homeassistant/components/file/notify.py
+++ b/homeassistant/components/file/notify.py
@@ -2,104 +2,23 @@
from __future__ import annotations
-from functools import partial
-import logging
import os
from typing import Any, TextIO
-import voluptuous as vol
-
from homeassistant.components.notify import (
- ATTR_TITLE,
ATTR_TITLE_DEFAULT,
- PLATFORM_SCHEMA as NOTIFY_PLATFORM_SCHEMA,
- BaseNotificationService,
NotifyEntity,
NotifyEntityFeature,
- migrate_notify_issue,
)
from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import CONF_FILE_PATH, CONF_FILENAME, CONF_NAME
+from homeassistant.const import CONF_FILE_PATH, CONF_NAME
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceValidationError
-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 .const import CONF_TIMESTAMP, DEFAULT_NAME, DOMAIN, FILE_ICON
-_LOGGER = logging.getLogger(__name__)
-
-# The legacy platform schema uses a filename, after import
-# The full file path is stored in the config entry
-PLATFORM_SCHEMA = NOTIFY_PLATFORM_SCHEMA.extend(
- {
- vol.Required(CONF_FILENAME): cv.string,
- vol.Optional(CONF_TIMESTAMP, default=False): cv.boolean,
- }
-)
-
-
-async def async_get_service(
- hass: HomeAssistant,
- config: ConfigType,
- discovery_info: DiscoveryInfoType | None = None,
-) -> FileNotificationService | None:
- """Get the file notification service."""
- if discovery_info is None:
- # We only set up through discovery
- return None
- file_path: str = discovery_info[CONF_FILE_PATH]
- timestamp: bool = discovery_info[CONF_TIMESTAMP]
-
- return FileNotificationService(file_path, timestamp)
-
-
-class FileNotificationService(BaseNotificationService):
- """Implement the notification service for the File service."""
-
- def __init__(self, file_path: str, add_timestamp: bool) -> None:
- """Initialize the service."""
- self._file_path = file_path
- self.add_timestamp = add_timestamp
-
- async def async_send_message(self, message: str = "", **kwargs: Any) -> None:
- """Send a message to a file."""
- # The use of the legacy notify service was deprecated with HA Core 2024.6.0
- # and will be removed with HA Core 2024.12
- migrate_notify_issue(
- self.hass, DOMAIN, "File", "2024.12.0", service_name=self._service_name
- )
- await self.hass.async_add_executor_job(
- partial(self.send_message, message, **kwargs)
- )
-
- def send_message(self, message: str = "", **kwargs: Any) -> None:
- """Send a message to a file."""
- file: TextIO
- filepath = self._file_path
- try:
- with open(filepath, "a", encoding="utf8") as file:
- if os.stat(filepath).st_size == 0:
- title = (
- f"{kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT)} notifications (Log"
- f" started: {dt_util.utcnow().isoformat()})\n{'-' * 80}\n"
- )
- file.write(title)
-
- if self.add_timestamp:
- text = f"{dt_util.utcnow().isoformat()} {message}\n"
- else:
- text = f"{message}\n"
- file.write(text)
- except OSError as exc:
- raise ServiceValidationError(
- translation_domain=DOMAIN,
- translation_key="write_access_failed",
- translation_placeholders={"filename": filepath, "exc": f"{exc!r}"},
- ) from exc
-
async def async_setup_entry(
hass: HomeAssistant,
diff --git a/homeassistant/components/file/sensor.py b/homeassistant/components/file/sensor.py
index e37a3df86a6..879c06e29f3 100644
--- a/homeassistant/components/file/sensor.py
+++ b/homeassistant/components/file/sensor.py
@@ -6,12 +6,8 @@ import logging
import os
from file_read_backwards import FileReadBackwards
-import voluptuous as vol
-from homeassistant.components.sensor import (
- PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA,
- SensorEntity,
-)
+from homeassistant.components.sensor import SensorEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONF_FILE_PATH,
@@ -20,38 +16,13 @@ from homeassistant.const import (
CONF_VALUE_TEMPLATE,
)
from homeassistant.core import HomeAssistant
-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
from .const import DEFAULT_NAME, FILE_ICON
_LOGGER = logging.getLogger(__name__)
-PLATFORM_SCHEMA = SENSOR_PLATFORM_SCHEMA.extend(
- {
- vol.Required(CONF_FILE_PATH): cv.isfile,
- vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
- vol.Optional(CONF_VALUE_TEMPLATE): cv.string,
- vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string,
- }
-)
-
-
-async def async_setup_platform(
- hass: HomeAssistant,
- config: ConfigType,
- async_add_entities: AddEntitiesCallback,
- discovery_info: DiscoveryInfoType | None = None,
-) -> None:
- """Set up the file sensor from YAML.
-
- The YAML platform config is automatically
- imported to a config entry, this method can be removed
- when YAML support is removed.
- """
-
async def async_setup_entry(
hass: HomeAssistant,
diff --git a/homeassistant/components/file/strings.json b/homeassistant/components/file/strings.json
index 60ebf451f78..bd8f23602e3 100644
--- a/homeassistant/components/file/strings.json
+++ b/homeassistant/components/file/strings.json
@@ -18,7 +18,7 @@
},
"data_description": {
"file_path": "The local file path to retrieve the sensor value from",
- "value_template": "A template to render the the sensors value based on the file content",
+ "value_template": "A template to render the sensor's value based on the file content",
"unit_of_measurement": "Unit of measurement for the sensor"
}
},
diff --git a/homeassistant/components/filesize/config_flow.py b/homeassistant/components/filesize/config_flow.py
index 51eff46bdb3..8ffe3f94353 100644
--- a/homeassistant/components/filesize/config_flow.py
+++ b/homeassistant/components/filesize/config_flow.py
@@ -11,7 +11,6 @@ import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_FILE_PATH
from homeassistant.core import HomeAssistant
-from homeassistant.exceptions import HomeAssistantError
from .const import DOMAIN
@@ -20,20 +19,20 @@ DATA_SCHEMA = vol.Schema({vol.Required(CONF_FILE_PATH): str})
_LOGGER = logging.getLogger(__name__)
-def validate_path(hass: HomeAssistant, path: str) -> str:
+def validate_path(hass: HomeAssistant, path: str) -> tuple[str | None, dict[str, str]]:
"""Validate path."""
get_path = pathlib.Path(path)
if not get_path.exists() or not get_path.is_file():
_LOGGER.error("Can not access file %s", path)
- raise NotValidError
+ return (None, {"base": "not_valid"})
if not hass.config.is_allowed_path(path):
_LOGGER.error("Filepath %s is not allowed", path)
- raise NotAllowedError
+ return (None, {"base": "not_allowed"})
full_path = get_path.absolute()
- return str(full_path)
+ return (str(full_path), {})
class FilesizeConfigFlow(ConfigFlow, domain=DOMAIN):
@@ -45,18 +44,13 @@ class FilesizeConfigFlow(ConfigFlow, domain=DOMAIN):
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle a flow initialized by the user."""
- errors: dict[str, Any] = {}
+ errors: dict[str, str] = {}
if user_input is not None:
- try:
- full_path = await self.hass.async_add_executor_job(
- validate_path, self.hass, user_input[CONF_FILE_PATH]
- )
- except NotValidError:
- errors["base"] = "not_valid"
- except NotAllowedError:
- errors["base"] = "not_allowed"
- else:
+ full_path, errors = await self.hass.async_add_executor_job(
+ validate_path, self.hass, user_input[CONF_FILE_PATH]
+ )
+ if not errors:
await self.async_set_unique_id(full_path)
self._abort_if_unique_id_configured()
@@ -70,10 +64,29 @@ class FilesizeConfigFlow(ConfigFlow, domain=DOMAIN):
step_id="user", data_schema=DATA_SCHEMA, errors=errors
)
+ async def async_step_reconfigure(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Handle a reconfigure flow initialized by the user."""
+ errors: dict[str, str] = {}
-class NotValidError(HomeAssistantError):
- """Path is not valid error."""
+ if user_input is not None:
+ reconfigure_entry = self._get_reconfigure_entry()
+ full_path, errors = await self.hass.async_add_executor_job(
+ validate_path, self.hass, user_input[CONF_FILE_PATH]
+ )
+ if not errors:
+ await self.async_set_unique_id(full_path)
+ self._abort_if_unique_id_configured()
+ name = str(user_input[CONF_FILE_PATH]).rsplit("/", maxsplit=1)[-1]
+ return self.async_update_reload_and_abort(
+ reconfigure_entry,
+ title=name,
+ unique_id=self.unique_id,
+ data_updates={CONF_FILE_PATH: user_input[CONF_FILE_PATH]},
+ )
-class NotAllowedError(HomeAssistantError):
- """Path is not allowed error."""
+ return self.async_show_form(
+ step_id="reconfigure", data_schema=DATA_SCHEMA, errors=errors
+ )
diff --git a/homeassistant/components/filesize/coordinator.py b/homeassistant/components/filesize/coordinator.py
index c0dbb14555e..8350cee91bf 100644
--- a/homeassistant/components/filesize/coordinator.py
+++ b/homeassistant/components/filesize/coordinator.py
@@ -60,12 +60,14 @@ class FileSizeCoordinator(DataUpdateCoordinator[dict[str, int | float | datetime
statinfo = await self.hass.async_add_executor_job(self._update)
size = statinfo.st_size
last_updated = dt_util.utc_from_timestamp(statinfo.st_mtime)
+ created = dt_util.utc_from_timestamp(statinfo.st_ctime)
_LOGGER.debug("size %s, last updated %s", size, last_updated)
data: dict[str, int | float | datetime] = {
"file": round(size / 1e6, 2),
"bytes": size,
"last_updated": last_updated,
+ "created": created,
}
return data
diff --git a/homeassistant/components/filesize/icons.json b/homeassistant/components/filesize/icons.json
index 15829589853..059a51a9e34 100644
--- a/homeassistant/components/filesize/icons.json
+++ b/homeassistant/components/filesize/icons.json
@@ -9,6 +9,9 @@
},
"last_updated": {
"default": "mdi:file"
+ },
+ "created": {
+ "default": "mdi:file"
}
}
}
diff --git a/homeassistant/components/filesize/sensor.py b/homeassistant/components/filesize/sensor.py
index 71a4e50edfe..2eb170af99d 100644
--- a/homeassistant/components/filesize/sensor.py
+++ b/homeassistant/components/filesize/sensor.py
@@ -47,6 +47,13 @@ SENSOR_TYPES = (
device_class=SensorDeviceClass.TIMESTAMP,
entity_category=EntityCategory.DIAGNOSTIC,
),
+ SensorEntityDescription(
+ key="created",
+ translation_key="created",
+ entity_registry_enabled_default=False,
+ device_class=SensorDeviceClass.TIMESTAMP,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
)
@@ -75,7 +82,6 @@ class FilesizeEntity(CoordinatorEntity[FileSizeCoordinator], SensorEntity):
) -> None:
"""Initialize the Filesize sensor."""
super().__init__(coordinator)
- base_name = str(coordinator.path.absolute()).rsplit("/", maxsplit=1)[-1]
self._attr_unique_id = (
entry_id if description.key == "file" else f"{entry_id}-{description.key}"
)
@@ -83,7 +89,6 @@ class FilesizeEntity(CoordinatorEntity[FileSizeCoordinator], SensorEntity):
self._attr_device_info = DeviceInfo(
entry_type=DeviceEntryType.SERVICE,
identifiers={(DOMAIN, entry_id)},
- name=base_name,
)
@property
diff --git a/homeassistant/components/filesize/strings.json b/homeassistant/components/filesize/strings.json
index 3323c3411b2..6623cf9c375 100644
--- a/homeassistant/components/filesize/strings.json
+++ b/homeassistant/components/filesize/strings.json
@@ -5,6 +5,11 @@
"data": {
"file_path": "Path to file"
}
+ },
+ "reconfigure": {
+ "data": {
+ "file_path": "[%key:component::filesize::config::step::user::data::file_path%]"
+ }
}
},
"error": {
@@ -12,7 +17,8 @@
"not_allowed": "Path is not allowed"
},
"abort": {
- "already_configured": "[%key:common::config_flow::abort::already_configured_service%]"
+ "already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
+ "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
}
},
"title": "Filesize",
@@ -26,6 +32,9 @@
},
"last_updated": {
"name": "Last updated"
+ },
+ "created": {
+ "name": "Created"
}
}
}
diff --git a/homeassistant/components/filter/strings.json b/homeassistant/components/filter/strings.json
index 461eed9aefa..2a83a05bb96 100644
--- a/homeassistant/components/filter/strings.json
+++ b/homeassistant/components/filter/strings.json
@@ -1,4 +1,5 @@
{
+ "title": "Filter",
"services": {
"reload": {
"name": "[%key:common::action::reload%]",
diff --git a/homeassistant/components/fints/manifest.json b/homeassistant/components/fints/manifest.json
index 063e612d35d..0a9c5389cd9 100644
--- a/homeassistant/components/fints/manifest.json
+++ b/homeassistant/components/fints/manifest.json
@@ -6,5 +6,6 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["fints", "mt_940", "sepaxml"],
+ "quality_scale": "legacy",
"requirements": ["fints==3.1.0"]
}
diff --git a/homeassistant/components/firmata/manifest.json b/homeassistant/components/firmata/manifest.json
index a35b6f179ce..363b5bd60c6 100644
--- a/homeassistant/components/firmata/manifest.json
+++ b/homeassistant/components/firmata/manifest.json
@@ -6,5 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/firmata",
"iot_class": "local_push",
"loggers": ["pymata_express"],
+ "quality_scale": "legacy",
"requirements": ["pymata-express==1.19"]
}
diff --git a/homeassistant/components/fitbit/config_flow.py b/homeassistant/components/fitbit/config_flow.py
index cb4e3fb4ea3..d5b33a731e3 100644
--- a/homeassistant/components/fitbit/config_flow.py
+++ b/homeassistant/components/fitbit/config_flow.py
@@ -86,7 +86,3 @@ class OAuth2FlowHandler(
self._abort_if_unique_id_configured()
return self.async_create_entry(title=profile.display_name, data=data)
-
- async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult:
- """Handle import from YAML."""
- return await self.async_oauth_create_entry(import_data)
diff --git a/homeassistant/components/fitbit/quality_scale.yaml b/homeassistant/components/fitbit/quality_scale.yaml
new file mode 100644
index 00000000000..abf127cdb98
--- /dev/null
+++ b/homeassistant/components/fitbit/quality_scale.yaml
@@ -0,0 +1,70 @@
+rules:
+ # Bronze
+ action-setup:
+ status: exempt
+ comment: The integration has no actions.
+ appropriate-polling: done
+ brands: done
+ common-modules: done
+ config-flow: done
+ config-flow-test-coverage: done
+ dependency-transparency: todo
+ docs-actions:
+ status: exempt
+ comment: There are no actions in Fitbit integration.
+ docs-high-level-description: done
+ docs-installation-instructions: done
+ docs-removal-instructions: todo
+ entity-event-setup:
+ status: exempt
+ comment: Fitbit is a polling integration that does use async events.
+ entity-unique-id: done
+ has-entity-name: done
+ runtime-data:
+ status: todo
+ comment: |
+ The integration uses `hass.data` for data associated with a configuration
+ entry and needs to be updated to use `runtime_data`.
+ test-before-configure: done
+ test-before-setup: done
+ unique-config-entry: done
+
+ # Silver
+ action-exceptions: todo
+ config-entry-unloading: todo
+ docs-configuration-parameters: todo
+ docs-installation-parameters: todo
+ entity-unavailable: todo
+ integration-owner: todo
+ log-when-unavailable: todo
+ parallel-updates: todo
+ reauthentication-flow: todo
+ test-coverage: todo
+
+ # Gold
+ devices: todo
+ diagnostics: todo
+ discovery: todo
+ discovery-update-info: 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: todo
+ stale-devices: todo
+
+ # Platinum
+ async-dependency: todo
+ inject-websession: todo
+ strict-typing: todo
diff --git a/homeassistant/components/fitbit/sensor.py b/homeassistant/components/fitbit/sensor.py
index ab9a593e195..d58dad4ca67 100644
--- a/homeassistant/components/fitbit/sensor.py
+++ b/homeassistant/components/fitbit/sensor.py
@@ -6,30 +6,16 @@ from collections.abc import Callable
from dataclasses import dataclass
import datetime
import logging
-import os
from typing import Any, Final, cast
-from fitbit import Fitbit
-from oauthlib.oauth2.rfc6749.errors import OAuth2Error
-import voluptuous as vol
-
-from homeassistant.components.application_credentials import (
- ClientCredential,
- async_import_client_credential,
-)
from homeassistant.components.sensor import (
- PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA,
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
-from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
+from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
- CONF_CLIENT_ID,
- CONF_CLIENT_SECRET,
- CONF_TOKEN,
- CONF_UNIT_SYSTEM,
PERCENTAGE,
EntityCategory,
UnitOfLength,
@@ -38,33 +24,13 @@ from homeassistant.const import (
UnitOfVolume,
)
from homeassistant.core import HomeAssistant, callback
-from homeassistant.data_entry_flow import FlowResultType
-import homeassistant.helpers.config_validation as cv
-from homeassistant.helpers.device_registry import DeviceInfo
+from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.icon import icon_for_battery_level
-from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
-from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
-from homeassistant.util.json import load_json_object
from .api import FitbitApi
-from .const import (
- ATTR_ACCESS_TOKEN,
- ATTR_LAST_SAVED_AT,
- ATTR_REFRESH_TOKEN,
- ATTRIBUTION,
- BATTERY_LEVELS,
- CONF_CLOCK_FORMAT,
- CONF_MONITORED_RESOURCES,
- DEFAULT_CLOCK_FORMAT,
- DEFAULT_CONFIG,
- DOMAIN,
- FITBIT_CONFIG_FILE,
- FITBIT_DEFAULT_RESOURCES,
- FitbitScope,
- FitbitUnitSystem,
-)
+from .const import ATTRIBUTION, BATTERY_LEVELS, DOMAIN, FitbitScope, FitbitUnitSystem
from .coordinator import FitbitData, FitbitDeviceCoordinator
from .exceptions import FitbitApiException, FitbitAuthException
from .model import FitbitDevice, config_from_entry_data
@@ -75,6 +41,8 @@ _CONFIGURING: dict[str, str] = {}
SCAN_INTERVAL: Final = datetime.timedelta(minutes=30)
+FITBIT_TRACKER_SUBSTRING = "/tracker/"
+
def _default_value_fn(result: dict[str, Any]) -> str:
"""Parse a Fitbit timeseries API responses."""
@@ -156,11 +124,34 @@ class FitbitSensorEntityDescription(SensorEntityDescription):
unit_fn: Callable[[FitbitUnitSystem], str | None] = lambda x: None
scope: FitbitScope | None = None
+ @property
+ def is_tracker(self) -> bool:
+ """Return if the entity is a tracker."""
+ return FITBIT_TRACKER_SUBSTRING in self.key
+
+
+def _build_device_info(
+ config_entry: ConfigEntry, entity_description: FitbitSensorEntityDescription
+) -> DeviceInfo:
+ """Build device info for sensor entities info across devices."""
+ unique_id = cast(str, config_entry.unique_id)
+ if entity_description.is_tracker:
+ return DeviceInfo(
+ entry_type=DeviceEntryType.SERVICE,
+ identifiers={(DOMAIN, f"{unique_id}_tracker")},
+ translation_key="tracker",
+ translation_placeholders={"display_name": config_entry.title},
+ )
+ return DeviceInfo(
+ entry_type=DeviceEntryType.SERVICE,
+ identifiers={(DOMAIN, unique_id)},
+ )
+
FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
FitbitSensorEntityDescription(
key="activities/activityCalories",
- name="Activity Calories",
+ translation_key="activity_calories",
native_unit_of_measurement="cal",
icon="mdi:fire",
scope=FitbitScope.ACTIVITY,
@@ -169,7 +160,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
),
FitbitSensorEntityDescription(
key="activities/calories",
- name="Calories",
+ translation_key="calories",
native_unit_of_measurement="cal",
icon="mdi:fire",
scope=FitbitScope.ACTIVITY,
@@ -177,7 +168,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
),
FitbitSensorEntityDescription(
key="activities/caloriesBMR",
- name="Calories BMR",
+ translation_key="calories_bmr",
native_unit_of_measurement="cal",
icon="mdi:fire",
scope=FitbitScope.ACTIVITY,
@@ -187,7 +178,6 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
),
FitbitSensorEntityDescription(
key="activities/distance",
- name="Distance",
icon="mdi:map-marker",
device_class=SensorDeviceClass.DISTANCE,
value_fn=_distance_value_fn,
@@ -197,7 +187,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
),
FitbitSensorEntityDescription(
key="activities/elevation",
- name="Elevation",
+ translation_key="elevation",
icon="mdi:walk",
device_class=SensorDeviceClass.DISTANCE,
unit_fn=_elevation_unit,
@@ -207,7 +197,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
),
FitbitSensorEntityDescription(
key="activities/floors",
- name="Floors",
+ translation_key="floors",
native_unit_of_measurement="floors",
icon="mdi:walk",
scope=FitbitScope.ACTIVITY,
@@ -216,7 +206,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
),
FitbitSensorEntityDescription(
key="activities/heart",
- name="Resting Heart Rate",
+ translation_key="resting_heart_rate",
native_unit_of_measurement="bpm",
icon="mdi:heart-pulse",
value_fn=_int_value_or_none("restingHeartRate"),
@@ -225,7 +215,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
),
FitbitSensorEntityDescription(
key="activities/minutesFairlyActive",
- name="Minutes Fairly Active",
+ translation_key="minutes_fairly_active",
native_unit_of_measurement=UnitOfTime.MINUTES,
icon="mdi:walk",
device_class=SensorDeviceClass.DURATION,
@@ -235,7 +225,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
),
FitbitSensorEntityDescription(
key="activities/minutesLightlyActive",
- name="Minutes Lightly Active",
+ translation_key="minutes_lightly_active",
native_unit_of_measurement=UnitOfTime.MINUTES,
icon="mdi:walk",
device_class=SensorDeviceClass.DURATION,
@@ -245,7 +235,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
),
FitbitSensorEntityDescription(
key="activities/minutesSedentary",
- name="Minutes Sedentary",
+ translation_key="minutes_sedentary",
native_unit_of_measurement=UnitOfTime.MINUTES,
icon="mdi:seat-recline-normal",
device_class=SensorDeviceClass.DURATION,
@@ -255,7 +245,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
),
FitbitSensorEntityDescription(
key="activities/minutesVeryActive",
- name="Minutes Very Active",
+ translation_key="minutes_very_active",
native_unit_of_measurement=UnitOfTime.MINUTES,
icon="mdi:run",
device_class=SensorDeviceClass.DURATION,
@@ -265,7 +255,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
),
FitbitSensorEntityDescription(
key="activities/steps",
- name="Steps",
+ translation_key="steps",
native_unit_of_measurement="steps",
icon="mdi:walk",
scope=FitbitScope.ACTIVITY,
@@ -273,7 +263,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
),
FitbitSensorEntityDescription(
key="activities/tracker/activityCalories",
- name="Tracker Activity Calories",
+ translation_key="activity_calories",
native_unit_of_measurement="cal",
icon="mdi:fire",
scope=FitbitScope.ACTIVITY,
@@ -283,7 +273,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
),
FitbitSensorEntityDescription(
key="activities/tracker/calories",
- name="Tracker Calories",
+ translation_key="calories",
native_unit_of_measurement="cal",
icon="mdi:fire",
scope=FitbitScope.ACTIVITY,
@@ -293,7 +283,6 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
),
FitbitSensorEntityDescription(
key="activities/tracker/distance",
- name="Tracker Distance",
icon="mdi:map-marker",
device_class=SensorDeviceClass.DISTANCE,
value_fn=_distance_value_fn,
@@ -305,7 +294,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
),
FitbitSensorEntityDescription(
key="activities/tracker/elevation",
- name="Tracker Elevation",
+ translation_key="elevation",
icon="mdi:walk",
device_class=SensorDeviceClass.DISTANCE,
unit_fn=_elevation_unit,
@@ -316,7 +305,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
),
FitbitSensorEntityDescription(
key="activities/tracker/floors",
- name="Tracker Floors",
+ translation_key="floors",
native_unit_of_measurement="floors",
icon="mdi:walk",
scope=FitbitScope.ACTIVITY,
@@ -326,7 +315,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
),
FitbitSensorEntityDescription(
key="activities/tracker/minutesFairlyActive",
- name="Tracker Minutes Fairly Active",
+ translation_key="minutes_fairly_active",
native_unit_of_measurement=UnitOfTime.MINUTES,
icon="mdi:walk",
device_class=SensorDeviceClass.DURATION,
@@ -337,7 +326,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
),
FitbitSensorEntityDescription(
key="activities/tracker/minutesLightlyActive",
- name="Tracker Minutes Lightly Active",
+ translation_key="minutes_lightly_active",
native_unit_of_measurement=UnitOfTime.MINUTES,
icon="mdi:walk",
device_class=SensorDeviceClass.DURATION,
@@ -348,7 +337,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
),
FitbitSensorEntityDescription(
key="activities/tracker/minutesSedentary",
- name="Tracker Minutes Sedentary",
+ translation_key="minutes_sedentary",
native_unit_of_measurement=UnitOfTime.MINUTES,
icon="mdi:seat-recline-normal",
device_class=SensorDeviceClass.DURATION,
@@ -359,7 +348,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
),
FitbitSensorEntityDescription(
key="activities/tracker/minutesVeryActive",
- name="Tracker Minutes Very Active",
+ translation_key="minutes_very_active",
native_unit_of_measurement=UnitOfTime.MINUTES,
icon="mdi:run",
device_class=SensorDeviceClass.DURATION,
@@ -370,7 +359,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
),
FitbitSensorEntityDescription(
key="activities/tracker/steps",
- name="Tracker Steps",
+ translation_key="steps",
native_unit_of_measurement="steps",
icon="mdi:walk",
scope=FitbitScope.ACTIVITY,
@@ -380,7 +369,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
),
FitbitSensorEntityDescription(
key="body/bmi",
- name="BMI",
+ translation_key="bmi",
native_unit_of_measurement="BMI",
icon="mdi:human",
state_class=SensorStateClass.MEASUREMENT,
@@ -391,7 +380,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
),
FitbitSensorEntityDescription(
key="body/fat",
- name="Body Fat",
+ translation_key="body_fat",
native_unit_of_measurement=PERCENTAGE,
icon="mdi:human",
state_class=SensorStateClass.MEASUREMENT,
@@ -402,7 +391,6 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
),
FitbitSensorEntityDescription(
key="body/weight",
- name="Weight",
icon="mdi:human",
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.WEIGHT,
@@ -412,7 +400,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
),
FitbitSensorEntityDescription(
key="sleep/awakeningsCount",
- name="Awakenings Count",
+ translation_key="awakenings_count",
native_unit_of_measurement="times awaken",
icon="mdi:sleep",
scope=FitbitScope.SLEEP,
@@ -421,7 +409,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
),
FitbitSensorEntityDescription(
key="sleep/efficiency",
- name="Sleep Efficiency",
+ translation_key="sleep_efficiency",
native_unit_of_measurement=PERCENTAGE,
icon="mdi:sleep",
state_class=SensorStateClass.MEASUREMENT,
@@ -430,7 +418,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
),
FitbitSensorEntityDescription(
key="sleep/minutesAfterWakeup",
- name="Minutes After Wakeup",
+ translation_key="minutes_after_wakeup",
native_unit_of_measurement=UnitOfTime.MINUTES,
icon="mdi:sleep",
device_class=SensorDeviceClass.DURATION,
@@ -440,7 +428,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
),
FitbitSensorEntityDescription(
key="sleep/minutesAsleep",
- name="Sleep Minutes Asleep",
+ translation_key="sleep_minutes_asleep",
native_unit_of_measurement=UnitOfTime.MINUTES,
icon="mdi:sleep",
device_class=SensorDeviceClass.DURATION,
@@ -450,7 +438,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
),
FitbitSensorEntityDescription(
key="sleep/minutesAwake",
- name="Sleep Minutes Awake",
+ translation_key="sleep_minutes_awake",
native_unit_of_measurement=UnitOfTime.MINUTES,
icon="mdi:sleep",
device_class=SensorDeviceClass.DURATION,
@@ -460,7 +448,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
),
FitbitSensorEntityDescription(
key="sleep/minutesToFallAsleep",
- name="Sleep Minutes to Fall Asleep",
+ translation_key="sleep_minutes_to_fall_asleep",
native_unit_of_measurement=UnitOfTime.MINUTES,
icon="mdi:sleep",
device_class=SensorDeviceClass.DURATION,
@@ -470,7 +458,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
),
FitbitSensorEntityDescription(
key="sleep/timeInBed",
- name="Sleep Time in Bed",
+ translation_key="sleep_time_in_bed",
native_unit_of_measurement=UnitOfTime.MINUTES,
icon="mdi:hotel",
device_class=SensorDeviceClass.DURATION,
@@ -480,7 +468,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
),
FitbitSensorEntityDescription(
key="foods/log/caloriesIn",
- name="Calories In",
+ translation_key="calories_in",
native_unit_of_measurement="cal",
icon="mdi:food-apple",
state_class=SensorStateClass.TOTAL_INCREASING,
@@ -489,7 +477,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
),
FitbitSensorEntityDescription(
key="foods/log/water",
- name="Water",
+ translation_key="water",
icon="mdi:cup-water",
unit_fn=_water_unit,
state_class=SensorStateClass.TOTAL_INCREASING,
@@ -501,14 +489,14 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
# Different description depending on clock format
SLEEP_START_TIME = FitbitSensorEntityDescription(
key="sleep/startTime",
- name="Sleep Start Time",
+ translation_key="sleep_start_time",
icon="mdi:clock",
scope=FitbitScope.SLEEP,
entity_category=EntityCategory.DIAGNOSTIC,
)
SLEEP_START_TIME_12HR = FitbitSensorEntityDescription(
key="sleep/startTime",
- name="Sleep Start Time",
+ translation_key="sleep_start_time",
icon="mdi:clock",
value_fn=_clock_format_12h,
scope=FitbitScope.SLEEP,
@@ -533,126 +521,6 @@ FITBIT_RESOURCE_BATTERY_LEVEL = FitbitSensorEntityDescription(
native_unit_of_measurement=PERCENTAGE,
)
-FITBIT_RESOURCES_KEYS: Final[list[str]] = [
- desc.key
- for desc in (*FITBIT_RESOURCES_LIST, FITBIT_RESOURCE_BATTERY, SLEEP_START_TIME)
-]
-
-PLATFORM_SCHEMA: Final = SENSOR_PLATFORM_SCHEMA.extend(
- {
- vol.Optional(
- CONF_MONITORED_RESOURCES, default=FITBIT_DEFAULT_RESOURCES
- ): vol.All(cv.ensure_list, [vol.In(FITBIT_RESOURCES_KEYS)]),
- vol.Optional(CONF_CLOCK_FORMAT, default=DEFAULT_CLOCK_FORMAT): vol.In(
- ["12H", "24H"]
- ),
- vol.Optional(CONF_UNIT_SYSTEM, default=FitbitUnitSystem.LEGACY_DEFAULT): vol.In(
- [
- FitbitUnitSystem.EN_GB,
- FitbitUnitSystem.EN_US,
- FitbitUnitSystem.METRIC,
- FitbitUnitSystem.LEGACY_DEFAULT,
- ]
- ),
- }
-)
-
-# Only import configuration if it was previously created successfully with all
-# of the following fields.
-FITBIT_CONF_KEYS = [
- CONF_CLIENT_ID,
- CONF_CLIENT_SECRET,
- ATTR_ACCESS_TOKEN,
- ATTR_REFRESH_TOKEN,
- ATTR_LAST_SAVED_AT,
-]
-
-
-def load_config_file(config_path: str) -> dict[str, Any] | None:
- """Load existing valid fitbit.conf from disk for import."""
- if os.path.isfile(config_path):
- config_file = load_json_object(config_path)
- if config_file != DEFAULT_CONFIG and all(
- key in config_file for key in FITBIT_CONF_KEYS
- ):
- return config_file
- return None
-
-
-async def async_setup_platform(
- hass: HomeAssistant,
- config: ConfigType,
- add_entities: AddEntitiesCallback,
- discovery_info: DiscoveryInfoType | None = None,
-) -> None:
- """Set up the Fitbit sensor."""
- config_path = hass.config.path(FITBIT_CONFIG_FILE)
- config_file = await hass.async_add_executor_job(load_config_file, config_path)
- _LOGGER.debug("loaded config file: %s", config_file)
-
- if config_file is not None:
- _LOGGER.debug("Importing existing fitbit.conf application credentials")
-
- # Refresh the token before importing to ensure it is working and not
- # expired on first initialization.
- authd_client = Fitbit(
- config_file[CONF_CLIENT_ID],
- config_file[CONF_CLIENT_SECRET],
- access_token=config_file[ATTR_ACCESS_TOKEN],
- refresh_token=config_file[ATTR_REFRESH_TOKEN],
- expires_at=config_file[ATTR_LAST_SAVED_AT],
- refresh_cb=lambda x: None,
- )
- try:
- updated_token = await hass.async_add_executor_job(
- authd_client.client.refresh_token
- )
- except OAuth2Error as err:
- _LOGGER.debug("Unable to import fitbit OAuth2 credentials: %s", err)
- translation_key = "deprecated_yaml_import_issue_cannot_connect"
- else:
- await async_import_client_credential(
- hass,
- DOMAIN,
- ClientCredential(
- config_file[CONF_CLIENT_ID], config_file[CONF_CLIENT_SECRET]
- ),
- )
- result = await hass.config_entries.flow.async_init(
- DOMAIN,
- context={"source": SOURCE_IMPORT},
- data={
- "auth_implementation": DOMAIN,
- CONF_TOKEN: {
- ATTR_ACCESS_TOKEN: updated_token[ATTR_ACCESS_TOKEN],
- ATTR_REFRESH_TOKEN: updated_token[ATTR_REFRESH_TOKEN],
- "expires_at": updated_token["expires_at"],
- "scope": " ".join(updated_token.get("scope", [])),
- },
- CONF_CLOCK_FORMAT: config[CONF_CLOCK_FORMAT],
- CONF_UNIT_SYSTEM: config[CONF_UNIT_SYSTEM],
- CONF_MONITORED_RESOURCES: config[CONF_MONITORED_RESOURCES],
- },
- )
- translation_key = "deprecated_yaml_import"
- if (
- result.get("type") == FlowResultType.ABORT
- and result.get("reason") == "cannot_connect"
- ):
- translation_key = "deprecated_yaml_import_issue_cannot_connect"
- else:
- translation_key = "deprecated_yaml_no_import"
-
- async_create_issue(
- hass,
- DOMAIN,
- "deprecated_yaml",
- breaks_in_ha_version="2024.5.0",
- is_fixable=False,
- severity=IssueSeverity.WARNING,
- translation_key=translation_key,
- )
-
async def async_setup_entry(
hass: HomeAssistant,
@@ -694,6 +562,7 @@ async def async_setup_entry(
description,
units=description.unit_fn(unit_system),
enable_default_override=is_explicit_enable(description),
+ device_info=_build_device_info(entry, description),
)
for description in resource_list
if is_allowed_resource(description)
@@ -728,6 +597,7 @@ class FitbitSensor(SensorEntity):
entity_description: FitbitSensorEntityDescription
_attr_attribution = ATTRIBUTION
+ _attr_has_entity_name = True
def __init__(
self,
@@ -737,6 +607,7 @@ class FitbitSensor(SensorEntity):
description: FitbitSensorEntityDescription,
units: str | None,
enable_default_override: bool,
+ device_info: DeviceInfo,
) -> None:
"""Initialize the Fitbit sensor."""
self.config_entry = config_entry
@@ -744,6 +615,7 @@ class FitbitSensor(SensorEntity):
self.api = api
self._attr_unique_id = f"{user_profile_id}_{description.key}"
+ self._attr_device_info = device_info
if units is not None:
self._attr_native_unit_of_measurement = units
diff --git a/homeassistant/components/fitbit/strings.json b/homeassistant/components/fitbit/strings.json
index e1ca1b01f7a..9029a8265bb 100644
--- a/homeassistant/components/fitbit/strings.json
+++ b/homeassistant/components/fitbit/strings.json
@@ -38,21 +38,82 @@
},
"battery_level": {
"name": "Battery level"
+ },
+ "activity_calories": {
+ "name": "Activity calories"
+ },
+ "calories": {
+ "name": "Calories"
+ },
+ "calories_bmr": {
+ "name": "Calories BMR"
+ },
+ "elevation": {
+ "name": "Elevation"
+ },
+ "floors": {
+ "name": "Floors"
+ },
+ "resting_heart_rate": {
+ "name": "Resting heart rate"
+ },
+ "minutes_fairly_active": {
+ "name": "Minutes fairly active"
+ },
+ "minutes_lightly_active": {
+ "name": "Minutes lightly active"
+ },
+ "minutes_sedentary": {
+ "name": "Minutes sedentary"
+ },
+ "minutes_very_active": {
+ "name": "Minutes very active"
+ },
+ "sleep_start_time": {
+ "name": "Sleep start time"
+ },
+ "steps": {
+ "name": "Steps"
+ },
+ "bmi": {
+ "name": "BMI"
+ },
+ "body_fat": {
+ "name": "Body fat"
+ },
+ "awakenings_count": {
+ "name": "Awakenings count"
+ },
+ "sleep_efficiency": {
+ "name": "Sleep efficiency"
+ },
+ "minutes_after_wakeup": {
+ "name": "Minutes after wakeup"
+ },
+ "sleep_minutes_asleep": {
+ "name": "Sleep minutes asleep"
+ },
+ "sleep_minutes_awake": {
+ "name": "Sleep minutes awake"
+ },
+ "sleep_minutes_to_fall_asleep": {
+ "name": "Sleep minutes to fall asleep"
+ },
+ "sleep_time_in_bed": {
+ "name": "Sleep time in bed"
+ },
+ "calories_in": {
+ "name": "Calories in"
+ },
+ "water": {
+ "name": "Water"
}
}
},
- "issues": {
- "deprecated_yaml_no_import": {
- "title": "Fitbit YAML configuration is being removed",
- "description": "Configuring Fitbit using YAML is being removed.\n\nRemove the `fitbit` configuration from your configuration.yaml file and remove fitbit.conf if it exists and restart Home Assistant and [set up the integration](/config/integrations/dashboard/add?domain=fitbit) manually."
- },
- "deprecated_yaml_import": {
- "title": "Fitbit YAML configuration is being removed",
- "description": "Configuring Fitbit using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically, including OAuth Application Credentials.\n\nRemove the `fitbit` configuration from your configuration.yaml file and remove fitbit.conf and restart Home Assistant to fix this issue."
- },
- "deprecated_yaml_import_issue_cannot_connect": {
- "title": "The Fitbit YAML configuration import failed",
- "description": "Configuring Fitbit using YAML is being removed but there was a connection error importing your YAML configuration.\n\nRestart Home Assistant to try again or remove the Fitbit YAML configuration from your configuration.yaml file and remove the fitbit.conf and continue to [set up the integration](/config/integrations/dashboard/add?domain=fitbit) manually."
+
+ "device": {
+ "tracker": {
+ "name": "{display_name} tracker"
}
}
}
diff --git a/homeassistant/components/fixer/manifest.json b/homeassistant/components/fixer/manifest.json
index 052a594b745..3c457919ac3 100644
--- a/homeassistant/components/fixer/manifest.json
+++ b/homeassistant/components/fixer/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/fixer",
"iot_class": "cloud_polling",
"loggers": ["fixerio"],
+ "quality_scale": "legacy",
"requirements": ["fixerio==1.0.0a0"]
}
diff --git a/homeassistant/components/fjaraskupan/fan.py b/homeassistant/components/fjaraskupan/fan.py
index 864160cb464..540a7dd410d 100644
--- a/homeassistant/components/fjaraskupan/fan.py
+++ b/homeassistant/components/fjaraskupan/fan.py
@@ -71,7 +71,7 @@ class Fan(CoordinatorEntity[FjaraskupanCoordinator], FanEntity):
| FanEntityFeature.TURN_OFF
| FanEntityFeature.TURN_ON
)
- _enable_turn_on_off_backwards_compatibility = False
+
_attr_has_entity_name = True
_attr_name = None
diff --git a/homeassistant/components/fjaraskupan/light.py b/homeassistant/components/fjaraskupan/light.py
index b33904c805d..f0083591d4d 100644
--- a/homeassistant/components/fjaraskupan/light.py
+++ b/homeassistant/components/fjaraskupan/light.py
@@ -4,8 +4,6 @@ from __future__ import annotations
from typing import Any
-from fjaraskupan import COMMAND_LIGHT_ON_OFF
-
from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
@@ -62,7 +60,6 @@ class Light(CoordinatorEntity[FjaraskupanCoordinator], LightEntity):
if self.is_on:
async with self.coordinator.async_connect_and_update() as device:
await device.send_dim(0)
- await device.send_command(COMMAND_LIGHT_ON_OFF)
@property
def is_on(self) -> bool:
diff --git a/homeassistant/components/fjaraskupan/manifest.json b/homeassistant/components/fjaraskupan/manifest.json
index 91c74b68e01..2fd49aac5ee 100644
--- a/homeassistant/components/fjaraskupan/manifest.json
+++ b/homeassistant/components/fjaraskupan/manifest.json
@@ -14,5 +14,5 @@
"documentation": "https://www.home-assistant.io/integrations/fjaraskupan",
"iot_class": "local_polling",
"loggers": ["bleak", "fjaraskupan"],
- "requirements": ["fjaraskupan==2.3.0"]
+ "requirements": ["fjaraskupan==2.3.2"]
}
diff --git a/homeassistant/components/fleetgo/manifest.json b/homeassistant/components/fleetgo/manifest.json
index 9e916bd7fcd..ad00ca3b7b1 100644
--- a/homeassistant/components/fleetgo/manifest.json
+++ b/homeassistant/components/fleetgo/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/fleetgo",
"iot_class": "cloud_polling",
"loggers": ["geopy", "ritassist"],
+ "quality_scale": "legacy",
"requirements": ["ritassist==0.9.2"]
}
diff --git a/homeassistant/components/flexit/climate.py b/homeassistant/components/flexit/climate.py
index d456fbef6fc..8be5df4eca7 100644
--- a/homeassistant/components/flexit/climate.py
+++ b/homeassistant/components/flexit/climate.py
@@ -70,7 +70,6 @@ class Flexit(ClimateEntity):
ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE
)
_attr_temperature_unit = UnitOfTemperature.CELSIUS
- _enable_turn_on_off_backwards_compatibility = False
def __init__(
self, hub: ModbusHub, modbus_slave: int | None, name: str | None
diff --git a/homeassistant/components/flexit/manifest.json b/homeassistant/components/flexit/manifest.json
index 98e5a3734a8..b3b66fb871e 100644
--- a/homeassistant/components/flexit/manifest.json
+++ b/homeassistant/components/flexit/manifest.json
@@ -4,5 +4,6 @@
"codeowners": [],
"dependencies": ["modbus"],
"documentation": "https://www.home-assistant.io/integrations/flexit",
- "iot_class": "local_polling"
+ "iot_class": "local_polling",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/flexit_bacnet/climate.py b/homeassistant/components/flexit_bacnet/climate.py
index 0526a0d6bd3..a2291dea9d6 100644
--- a/homeassistant/components/flexit_bacnet/climate.py
+++ b/homeassistant/components/flexit_bacnet/climate.py
@@ -74,7 +74,6 @@ class FlexitClimateEntity(FlexitEntity, ClimateEntity):
_attr_temperature_unit = UnitOfTemperature.CELSIUS
_attr_max_temp = MAX_TEMP
_attr_min_temp = MIN_TEMP
- _enable_turn_on_off_backwards_compatibility = False
def __init__(self, coordinator: FlexitCoordinator) -> None:
"""Initialize the Flexit unit."""
diff --git a/homeassistant/components/flexit_bacnet/entity.py b/homeassistant/components/flexit_bacnet/entity.py
index bd92550db19..38efa838c93 100644
--- a/homeassistant/components/flexit_bacnet/entity.py
+++ b/homeassistant/components/flexit_bacnet/entity.py
@@ -26,6 +26,7 @@ class FlexitEntity(CoordinatorEntity[FlexitCoordinator]):
name=coordinator.device.device_name,
manufacturer="Flexit",
model="Nordic",
+ model_id=coordinator.device.model,
serial_number=coordinator.device.serial_number,
)
diff --git a/homeassistant/components/flexit_bacnet/number.py b/homeassistant/components/flexit_bacnet/number.py
index 6e6e2eea980..6e405e8e8ac 100644
--- a/homeassistant/components/flexit_bacnet/number.py
+++ b/homeassistant/components/flexit_bacnet/number.py
@@ -23,135 +23,158 @@ from . import FlexitCoordinator
from .const import DOMAIN
from .entity import FlexitEntity
+_MAX_FAN_SETPOINT = 100
+_MIN_FAN_SETPOINT = 30
+
@dataclass(kw_only=True, frozen=True)
class FlexitNumberEntityDescription(NumberEntityDescription):
"""Describes a Flexit number entity."""
native_value_fn: Callable[[FlexitBACnet], float]
+ native_max_value_fn: Callable[[FlexitBACnet], int]
+ native_min_value_fn: Callable[[FlexitBACnet], int]
set_native_value_fn: Callable[[FlexitBACnet], Callable[[int], Awaitable[None]]]
+# Setpoints for Away, Home and High are dependent of each other. Fireplace and Cooker Hood
+# have setpoints between 0 (MIN_FAN_SETPOINT) and 100 (MAX_FAN_SETPOINT).
+# See the table below for all the setpoints.
+#
+# | Mode | Setpoint | Min | Max |
+# |:------------|----------|:----------------------|:----------------------|
+# | HOME | Supply | AWAY Supply setpoint | 100 |
+# | HOME | Extract | AWAY Extract setpoint | 100 |
+# | AWAY | Supply | 30 | HOME Supply setpoint |
+# | AWAY | Extract | 30 | HOME Extract setpoint |
+# | HIGH | Supply | HOME Supply setpoint | 100 |
+# | HIGH | Extract | HOME Extract setpoint | 100 |
+# | COOKER_HOOD | Supply | 30 | 100 |
+# | COOKER_HOOD | Extract | 30 | 100 |
+# | FIREPLACE | Supply | 30 | 100 |
+# | FIREPLACE | Extract | 30 | 100 |
+
+
NUMBERS: tuple[FlexitNumberEntityDescription, ...] = (
FlexitNumberEntityDescription(
key="away_extract_fan_setpoint",
translation_key="away_extract_fan_setpoint",
device_class=NumberDeviceClass.POWER_FACTOR,
- native_min_value=0,
- native_max_value=100,
native_step=1,
mode=NumberMode.SLIDER,
native_value_fn=lambda device: device.fan_setpoint_extract_air_away,
set_native_value_fn=lambda device: device.set_fan_setpoint_extract_air_away,
native_unit_of_measurement=PERCENTAGE,
+ native_max_value_fn=lambda device: int(device.fan_setpoint_extract_air_home),
+ native_min_value_fn=lambda _: _MIN_FAN_SETPOINT,
),
FlexitNumberEntityDescription(
key="away_supply_fan_setpoint",
translation_key="away_supply_fan_setpoint",
device_class=NumberDeviceClass.POWER_FACTOR,
- native_min_value=0,
- native_max_value=100,
native_step=1,
mode=NumberMode.SLIDER,
native_value_fn=lambda device: device.fan_setpoint_supply_air_away,
set_native_value_fn=lambda device: device.set_fan_setpoint_supply_air_away,
native_unit_of_measurement=PERCENTAGE,
+ native_max_value_fn=lambda device: int(device.fan_setpoint_supply_air_home),
+ native_min_value_fn=lambda _: _MIN_FAN_SETPOINT,
),
FlexitNumberEntityDescription(
key="cooker_hood_extract_fan_setpoint",
translation_key="cooker_hood_extract_fan_setpoint",
device_class=NumberDeviceClass.POWER_FACTOR,
- native_min_value=0,
- native_max_value=100,
native_step=1,
mode=NumberMode.SLIDER,
native_value_fn=lambda device: device.fan_setpoint_extract_air_cooker,
set_native_value_fn=lambda device: device.set_fan_setpoint_extract_air_cooker,
native_unit_of_measurement=PERCENTAGE,
+ native_max_value_fn=lambda _: _MAX_FAN_SETPOINT,
+ native_min_value_fn=lambda _: _MIN_FAN_SETPOINT,
),
FlexitNumberEntityDescription(
key="cooker_hood_supply_fan_setpoint",
translation_key="cooker_hood_supply_fan_setpoint",
device_class=NumberDeviceClass.POWER_FACTOR,
- native_min_value=0,
- native_max_value=100,
native_step=1,
mode=NumberMode.SLIDER,
native_value_fn=lambda device: device.fan_setpoint_supply_air_cooker,
set_native_value_fn=lambda device: device.set_fan_setpoint_supply_air_cooker,
native_unit_of_measurement=PERCENTAGE,
+ native_max_value_fn=lambda _: _MAX_FAN_SETPOINT,
+ native_min_value_fn=lambda _: _MIN_FAN_SETPOINT,
),
FlexitNumberEntityDescription(
key="fireplace_extract_fan_setpoint",
translation_key="fireplace_extract_fan_setpoint",
device_class=NumberDeviceClass.POWER_FACTOR,
- native_min_value=0,
- native_max_value=100,
native_step=1,
mode=NumberMode.SLIDER,
native_value_fn=lambda device: device.fan_setpoint_extract_air_fire,
set_native_value_fn=lambda device: device.set_fan_setpoint_extract_air_fire,
native_unit_of_measurement=PERCENTAGE,
+ native_max_value_fn=lambda _: _MAX_FAN_SETPOINT,
+ native_min_value_fn=lambda _: _MIN_FAN_SETPOINT,
),
FlexitNumberEntityDescription(
key="fireplace_supply_fan_setpoint",
translation_key="fireplace_supply_fan_setpoint",
device_class=NumberDeviceClass.POWER_FACTOR,
- native_min_value=0,
- native_max_value=100,
native_step=1,
mode=NumberMode.SLIDER,
native_value_fn=lambda device: device.fan_setpoint_supply_air_fire,
set_native_value_fn=lambda device: device.set_fan_setpoint_supply_air_fire,
native_unit_of_measurement=PERCENTAGE,
+ native_max_value_fn=lambda _: _MAX_FAN_SETPOINT,
+ native_min_value_fn=lambda _: _MIN_FAN_SETPOINT,
),
FlexitNumberEntityDescription(
key="high_extract_fan_setpoint",
translation_key="high_extract_fan_setpoint",
device_class=NumberDeviceClass.POWER_FACTOR,
- native_min_value=0,
- native_max_value=100,
native_step=1,
mode=NumberMode.SLIDER,
native_value_fn=lambda device: device.fan_setpoint_extract_air_high,
set_native_value_fn=lambda device: device.set_fan_setpoint_extract_air_high,
native_unit_of_measurement=PERCENTAGE,
+ native_max_value_fn=lambda _: _MAX_FAN_SETPOINT,
+ native_min_value_fn=lambda device: int(device.fan_setpoint_extract_air_home),
),
FlexitNumberEntityDescription(
key="high_supply_fan_setpoint",
translation_key="high_supply_fan_setpoint",
device_class=NumberDeviceClass.POWER_FACTOR,
- native_min_value=0,
- native_max_value=100,
native_step=1,
mode=NumberMode.SLIDER,
native_value_fn=lambda device: device.fan_setpoint_supply_air_high,
set_native_value_fn=lambda device: device.set_fan_setpoint_supply_air_high,
native_unit_of_measurement=PERCENTAGE,
+ native_max_value_fn=lambda _: _MAX_FAN_SETPOINT,
+ native_min_value_fn=lambda device: int(device.fan_setpoint_supply_air_home),
),
FlexitNumberEntityDescription(
key="home_extract_fan_setpoint",
translation_key="home_extract_fan_setpoint",
device_class=NumberDeviceClass.POWER_FACTOR,
- native_min_value=0,
- native_max_value=100,
native_step=1,
mode=NumberMode.SLIDER,
native_value_fn=lambda device: device.fan_setpoint_extract_air_home,
set_native_value_fn=lambda device: device.set_fan_setpoint_extract_air_home,
native_unit_of_measurement=PERCENTAGE,
+ native_max_value_fn=lambda _: _MAX_FAN_SETPOINT,
+ native_min_value_fn=lambda device: int(device.fan_setpoint_extract_air_away),
),
FlexitNumberEntityDescription(
key="home_supply_fan_setpoint",
translation_key="home_supply_fan_setpoint",
device_class=NumberDeviceClass.POWER_FACTOR,
- native_min_value=0,
- native_max_value=100,
native_step=1,
mode=NumberMode.SLIDER,
native_value_fn=lambda device: device.fan_setpoint_supply_air_home,
set_native_value_fn=lambda device: device.set_fan_setpoint_supply_air_home,
native_unit_of_measurement=PERCENTAGE,
+ native_max_value_fn=lambda _: _MAX_FAN_SETPOINT,
+ native_min_value_fn=lambda device: int(device.fan_setpoint_supply_air_away),
),
)
@@ -192,6 +215,16 @@ class FlexitNumber(FlexitEntity, NumberEntity):
"""Return the state of the number."""
return self.entity_description.native_value_fn(self.coordinator.device)
+ @property
+ def native_max_value(self) -> float:
+ """Return the native max value of the number."""
+ return self.entity_description.native_max_value_fn(self.coordinator.device)
+
+ @property
+ def native_min_value(self) -> float:
+ """Return the native min value of the number."""
+ return self.entity_description.native_min_value_fn(self.coordinator.device)
+
async def async_set_native_value(self, value: float) -> None:
"""Update the current value."""
set_native_value_fn = self.entity_description.set_native_value_fn(
diff --git a/homeassistant/components/flic/manifest.json b/homeassistant/components/flic/manifest.json
index 0442e4a7b7b..67a9a2e901c 100644
--- a/homeassistant/components/flic/manifest.json
+++ b/homeassistant/components/flic/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/flic",
"iot_class": "local_push",
"loggers": ["pyflic"],
+ "quality_scale": "legacy",
"requirements": ["pyflic==2.0.4"]
}
diff --git a/homeassistant/components/flick_electric/__init__.py b/homeassistant/components/flick_electric/__init__.py
index a963d199c5a..3ffddee1c7d 100644
--- a/homeassistant/components/flick_electric/__init__.py
+++ b/homeassistant/components/flick_electric/__init__.py
@@ -2,10 +2,11 @@
from datetime import datetime as dt
import logging
+from typing import Any
import jwt
from pyflick import FlickAPI
-from pyflick.authentication import AbstractFlickAuth
+from pyflick.authentication import SimpleFlickAuth
from pyflick.const import DEFAULT_CLIENT_ID, DEFAULT_CLIENT_SECRET
from homeassistant.config_entries import ConfigEntry
@@ -20,7 +21,8 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant
from homeassistant.helpers import aiohttp_client
-from .const import CONF_TOKEN_EXPIRY, DOMAIN
+from .const import CONF_ACCOUNT_ID, CONF_SUPPLY_NODE_REF, CONF_TOKEN_EXPIRY
+from .coordinator import FlickConfigEntry, FlickElectricDataCoordinator
_LOGGER = logging.getLogger(__name__)
@@ -29,36 +31,85 @@ CONF_ID_TOKEN = "id_token"
PLATFORMS = [Platform.SENSOR]
-async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_setup_entry(hass: HomeAssistant, entry: FlickConfigEntry) -> bool:
"""Set up Flick Electric from a config entry."""
auth = HassFlickAuth(hass, entry)
- hass.data.setdefault(DOMAIN, {})
- hass.data[DOMAIN][entry.entry_id] = FlickAPI(auth)
+ coordinator = FlickElectricDataCoordinator(
+ hass, FlickAPI(auth), entry.data[CONF_SUPPLY_NODE_REF]
+ )
+
+ 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: FlickConfigEntry) -> 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)
-class HassFlickAuth(AbstractFlickAuth):
+async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
+ """Migrate old 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:
+ api = FlickAPI(HassFlickAuth(hass, config_entry))
+
+ accounts = await api.getCustomerAccounts()
+ active_accounts = [
+ account for account in accounts if account["status"] == "active"
+ ]
+
+ # A single active account can be auto-migrated
+ if (len(active_accounts)) == 1:
+ account = active_accounts[0]
+
+ new_data = {**config_entry.data}
+ new_data[CONF_ACCOUNT_ID] = account["id"]
+ new_data[CONF_SUPPLY_NODE_REF] = account["main_consumer"]["supply_node_ref"]
+ hass.config_entries.async_update_entry(
+ config_entry,
+ title=account["address"],
+ unique_id=account["id"],
+ data=new_data,
+ version=2,
+ )
+ return True
+
+ config_entry.async_start_reauth(hass, data={**config_entry.data})
+ return False
+
+ return True
+
+
+class HassFlickAuth(SimpleFlickAuth):
"""Implementation of AbstractFlickAuth based on a Home Assistant entity config."""
- def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
+ def __init__(self, hass: HomeAssistant, entry: FlickConfigEntry) -> None:
"""Flick authentication based on a Home Assistant entity config."""
- super().__init__(aiohttp_client.async_get_clientsession(hass))
+ super().__init__(
+ username=entry.data[CONF_USERNAME],
+ password=entry.data[CONF_PASSWORD],
+ client_id=entry.data.get(CONF_CLIENT_ID, DEFAULT_CLIENT_ID),
+ client_secret=entry.data.get(CONF_CLIENT_SECRET, DEFAULT_CLIENT_SECRET),
+ websession=aiohttp_client.async_get_clientsession(hass),
+ )
self._entry = entry
self._hass = hass
- async def _get_entry_token(self):
+ async def _get_entry_token(self) -> dict[str, Any]:
# No token saved, generate one
if (
CONF_TOKEN_EXPIRY not in self._entry.data
@@ -75,13 +126,8 @@ class HassFlickAuth(AbstractFlickAuth):
async def _update_token(self):
_LOGGER.debug("Fetching new access token")
- token = await self.get_new_token(
- username=self._entry.data[CONF_USERNAME],
- password=self._entry.data[CONF_PASSWORD],
- client_id=self._entry.data.get(CONF_CLIENT_ID, DEFAULT_CLIENT_ID),
- client_secret=self._entry.data.get(
- CONF_CLIENT_SECRET, DEFAULT_CLIENT_SECRET
- ),
+ token = await super().get_new_token(
+ self._username, self._password, self._client_id, self._client_secret
)
_LOGGER.debug("New token: %s", token)
diff --git a/homeassistant/components/flick_electric/config_flow.py b/homeassistant/components/flick_electric/config_flow.py
index 8a2455b9d14..b6b7327fcb0 100644
--- a/homeassistant/components/flick_electric/config_flow.py
+++ b/homeassistant/components/flick_electric/config_flow.py
@@ -1,14 +1,18 @@
"""Config Flow for Flick Electric integration."""
import asyncio
+from collections.abc import Mapping
import logging
from typing import Any
-from pyflick.authentication import AuthException, SimpleFlickAuth
+from aiohttp import ClientResponseError
+from pyflick import FlickAPI
+from pyflick.authentication import AbstractFlickAuth, SimpleFlickAuth
from pyflick.const import DEFAULT_CLIENT_ID, DEFAULT_CLIENT_SECRET
+from pyflick.types import APIException, AuthException, CustomerAccount
import voluptuous as vol
-from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
+from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult
from homeassistant.const import (
CONF_CLIENT_ID,
CONF_CLIENT_SECRET,
@@ -17,12 +21,18 @@ from homeassistant.const import (
)
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import aiohttp_client
+from homeassistant.helpers.selector import (
+ SelectOptionDict,
+ SelectSelector,
+ SelectSelectorConfig,
+ SelectSelectorMode,
+)
-from .const import DOMAIN
+from .const import CONF_ACCOUNT_ID, CONF_SUPPLY_NODE_REF, DOMAIN
_LOGGER = logging.getLogger(__name__)
-DATA_SCHEMA = vol.Schema(
+LOGIN_SCHEMA = vol.Schema(
{
vol.Required(CONF_USERNAME): str,
vol.Required(CONF_PASSWORD): str,
@@ -35,10 +45,13 @@ DATA_SCHEMA = vol.Schema(
class FlickConfigFlow(ConfigFlow, domain=DOMAIN):
"""Flick config flow."""
- VERSION = 1
+ VERSION = 2
+ auth: AbstractFlickAuth
+ accounts: list[CustomerAccount]
+ data: dict[str, Any]
- async def _validate_input(self, user_input):
- auth = SimpleFlickAuth(
+ async def _validate_auth(self, user_input: Mapping[str, Any]) -> bool:
+ self.auth = SimpleFlickAuth(
username=user_input[CONF_USERNAME],
password=user_input[CONF_PASSWORD],
websession=aiohttp_client.async_get_clientsession(self.hass),
@@ -48,22 +61,83 @@ class FlickConfigFlow(ConfigFlow, domain=DOMAIN):
try:
async with asyncio.timeout(60):
- token = await auth.async_get_access_token()
- except TimeoutError as err:
+ token = await self.auth.async_get_access_token()
+ except (TimeoutError, ClientResponseError) as err:
raise CannotConnect from err
except AuthException as err:
raise InvalidAuth from err
return token is not None
+ async def async_step_select_account(
+ self, user_input: Mapping[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Ask user to select account."""
+
+ errors = {}
+ if user_input is not None and CONF_ACCOUNT_ID in user_input:
+ self.data[CONF_ACCOUNT_ID] = user_input[CONF_ACCOUNT_ID]
+ self.data[CONF_SUPPLY_NODE_REF] = self._get_supply_node_ref(
+ user_input[CONF_ACCOUNT_ID]
+ )
+ try:
+ # Ensure supply node is active
+ await FlickAPI(self.auth).getPricing(self.data[CONF_SUPPLY_NODE_REF])
+ except (APIException, ClientResponseError):
+ errors["base"] = "cannot_connect"
+ except AuthException:
+ # We should never get here as we have a valid token
+ return self.async_abort(reason="no_permissions")
+ else:
+ # Supply node is active
+ return await self._async_create_entry()
+
+ try:
+ self.accounts = await FlickAPI(self.auth).getCustomerAccounts()
+ except (APIException, ClientResponseError):
+ errors["base"] = "cannot_connect"
+
+ active_accounts = [a for a in self.accounts if a["status"] == "active"]
+
+ if len(active_accounts) == 0:
+ return self.async_abort(reason="no_accounts")
+
+ if len(active_accounts) == 1:
+ self.data[CONF_ACCOUNT_ID] = active_accounts[0]["id"]
+ self.data[CONF_SUPPLY_NODE_REF] = self._get_supply_node_ref(
+ active_accounts[0]["id"]
+ )
+
+ return await self._async_create_entry()
+
+ return self.async_show_form(
+ step_id="select_account",
+ data_schema=vol.Schema(
+ {
+ vol.Required(CONF_ACCOUNT_ID): SelectSelector(
+ SelectSelectorConfig(
+ options=[
+ SelectOptionDict(
+ value=account["id"], label=account["address"]
+ )
+ for account in active_accounts
+ ],
+ mode=SelectSelectorMode.LIST,
+ )
+ )
+ }
+ ),
+ errors=errors,
+ )
+
async def async_step_user(
- self, user_input: dict[str, Any] | None = None
+ self, user_input: Mapping[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle gathering login info."""
errors = {}
if user_input is not None:
try:
- await self._validate_input(user_input)
+ await self._validate_auth(user_input)
except CannotConnect:
errors["base"] = "cannot_connect"
except InvalidAuth:
@@ -72,20 +146,61 @@ class FlickConfigFlow(ConfigFlow, domain=DOMAIN):
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
- await self.async_set_unique_id(
- f"flick_electric_{user_input[CONF_USERNAME]}"
- )
- self._abort_if_unique_id_configured()
-
- return self.async_create_entry(
- title=f"Flick Electric: {user_input[CONF_USERNAME]}",
- data=user_input,
- )
+ self.data = dict(user_input)
+ return await self.async_step_select_account(user_input)
return self.async_show_form(
- step_id="user", data_schema=DATA_SCHEMA, errors=errors
+ step_id="user", data_schema=LOGIN_SCHEMA, errors=errors
)
+ async def async_step_reauth(
+ self, user_input: Mapping[str, Any]
+ ) -> ConfigFlowResult:
+ """Handle re-authentication."""
+
+ self.data = {**user_input}
+
+ return await self.async_step_user(user_input)
+
+ async def _async_create_entry(self) -> ConfigFlowResult:
+ """Create an entry for the flow."""
+
+ await self.async_set_unique_id(self.data[CONF_ACCOUNT_ID])
+
+ account = self._get_account(self.data[CONF_ACCOUNT_ID])
+
+ if self.source == SOURCE_REAUTH:
+ # Migration completed
+ if self._get_reauth_entry().version == 1:
+ self.hass.config_entries.async_update_entry(
+ self._get_reauth_entry(),
+ unique_id=self.unique_id,
+ data=self.data,
+ version=self.VERSION,
+ )
+
+ return self.async_update_reload_and_abort(
+ self._get_reauth_entry(),
+ unique_id=self.unique_id,
+ title=account["address"],
+ data=self.data,
+ )
+
+ self._abort_if_unique_id_configured()
+
+ return self.async_create_entry(
+ title=account["address"],
+ data=self.data,
+ )
+
+ def _get_account(self, account_id: str) -> CustomerAccount:
+ """Get the account for the account ID."""
+ return next(a for a in self.accounts if a["id"] == account_id)
+
+ def _get_supply_node_ref(self, account_id: str) -> str:
+ """Get the supply node ref for the account."""
+ return self._get_account(account_id)["main_consumer"][CONF_SUPPLY_NODE_REF]
+
class CannotConnect(HomeAssistantError):
"""Error to indicate we cannot connect."""
diff --git a/homeassistant/components/flick_electric/const.py b/homeassistant/components/flick_electric/const.py
index de1942096b5..0f94aa909b7 100644
--- a/homeassistant/components/flick_electric/const.py
+++ b/homeassistant/components/flick_electric/const.py
@@ -3,6 +3,8 @@
DOMAIN = "flick_electric"
CONF_TOKEN_EXPIRY = "expires"
+CONF_ACCOUNT_ID = "account_id"
+CONF_SUPPLY_NODE_REF = "supply_node_ref"
ATTR_START_AT = "start_at"
ATTR_END_AT = "end_at"
diff --git a/homeassistant/components/flick_electric/coordinator.py b/homeassistant/components/flick_electric/coordinator.py
new file mode 100644
index 00000000000..474efc5297d
--- /dev/null
+++ b/homeassistant/components/flick_electric/coordinator.py
@@ -0,0 +1,47 @@
+"""Data Coordinator for Flick Electric."""
+
+import asyncio
+from datetime import timedelta
+import logging
+
+import aiohttp
+from pyflick import FlickAPI, FlickPrice
+from pyflick.types import APIException, AuthException
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import ConfigEntryAuthFailed
+from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
+
+_LOGGER = logging.getLogger(__name__)
+
+SCAN_INTERVAL = timedelta(minutes=5)
+
+type FlickConfigEntry = ConfigEntry[FlickElectricDataCoordinator]
+
+
+class FlickElectricDataCoordinator(DataUpdateCoordinator[FlickPrice]):
+ """Coordinator for flick power price."""
+
+ def __init__(
+ self, hass: HomeAssistant, api: FlickAPI, supply_node_ref: str
+ ) -> None:
+ """Initialize FlickElectricDataCoordinator."""
+ super().__init__(
+ hass,
+ _LOGGER,
+ name="Flick Electric",
+ update_interval=SCAN_INTERVAL,
+ )
+ self.supply_node_ref = supply_node_ref
+ self._api = api
+
+ async def _async_update_data(self) -> FlickPrice:
+ """Fetch pricing data from Flick Electric."""
+ try:
+ async with asyncio.timeout(60):
+ return await self._api.getPricing(self.supply_node_ref)
+ except AuthException as err:
+ raise ConfigEntryAuthFailed from err
+ except (APIException, aiohttp.ClientResponseError) as err:
+ raise UpdateFailed from err
diff --git a/homeassistant/components/flick_electric/manifest.json b/homeassistant/components/flick_electric/manifest.json
index 0b1f2677d6a..3aee25995a9 100644
--- a/homeassistant/components/flick_electric/manifest.json
+++ b/homeassistant/components/flick_electric/manifest.json
@@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["pyflick"],
- "requirements": ["PyFlick==0.0.2"]
+ "requirements": ["PyFlick==1.1.2"]
}
diff --git a/homeassistant/components/flick_electric/sensor.py b/homeassistant/components/flick_electric/sensor.py
index 347109c66c0..147d00c943d 100644
--- a/homeassistant/components/flick_electric/sensor.py
+++ b/homeassistant/components/flick_electric/sensor.py
@@ -1,74 +1,72 @@
"""Support for Flick Electric Pricing data."""
-import asyncio
from datetime import timedelta
+from decimal import Decimal
import logging
from typing import Any
-from pyflick import FlickAPI, FlickPrice
-
from homeassistant.components.sensor import SensorEntity
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CURRENCY_CENT, UnitOfEnergy
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from homeassistant.util.dt import utcnow
+from homeassistant.helpers.update_coordinator import CoordinatorEntity
-from .const import ATTR_COMPONENTS, ATTR_END_AT, ATTR_START_AT, DOMAIN
+from .const import ATTR_COMPONENTS, ATTR_END_AT, ATTR_START_AT
+from .coordinator import FlickConfigEntry, FlickElectricDataCoordinator
_LOGGER = logging.getLogger(__name__)
-
SCAN_INTERVAL = timedelta(minutes=5)
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: FlickConfigEntry,
+ async_add_entities: AddEntitiesCallback,
) -> None:
"""Flick Sensor Setup."""
- api: FlickAPI = hass.data[DOMAIN][entry.entry_id]
+ coordinator = entry.runtime_data
- async_add_entities([FlickPricingSensor(api)], True)
+ async_add_entities([FlickPricingSensor(coordinator)])
-class FlickPricingSensor(SensorEntity):
+class FlickPricingSensor(CoordinatorEntity[FlickElectricDataCoordinator], SensorEntity):
"""Entity object for Flick Electric sensor."""
_attr_attribution = "Data provided by Flick Electric"
_attr_native_unit_of_measurement = f"{CURRENCY_CENT}/{UnitOfEnergy.KILO_WATT_HOUR}"
_attr_has_entity_name = True
_attr_translation_key = "power_price"
- _attributes: dict[str, Any] = {}
- def __init__(self, api: FlickAPI) -> None:
+ def __init__(self, coordinator: FlickElectricDataCoordinator) -> None:
"""Entity object for Flick Electric sensor."""
- self._api: FlickAPI = api
- self._price: FlickPrice = None
+ super().__init__(coordinator)
+
+ self._attr_unique_id = f"{coordinator.supply_node_ref}_pricing"
@property
- def native_value(self):
+ def native_value(self) -> Decimal:
"""Return the state of the sensor."""
- return self._price.price
+ # The API should return a unit price with quantity of 1.0 when no start/end time is provided
+ if self.coordinator.data.quantity != 1:
+ _LOGGER.warning(
+ "Unexpected quantity for unit price: %s", self.coordinator.data
+ )
+ return self.coordinator.data.cost
@property
- def extra_state_attributes(self):
+ def extra_state_attributes(self) -> dict[str, Any] | None:
"""Return the state attributes."""
- return self._attributes
+ components: dict[str, Decimal] = {}
- async def async_update(self) -> None:
- """Get the Flick Pricing data from the web service."""
- if self._price and self._price.end_at >= utcnow():
- return # Power price data is still valid
-
- async with asyncio.timeout(60):
- self._price = await self._api.getPricing()
-
- _LOGGER.debug("Pricing data: %s", self._price)
-
- self._attributes[ATTR_START_AT] = self._price.start_at
- self._attributes[ATTR_END_AT] = self._price.end_at
- for component in self._price.components:
+ for component in self.coordinator.data.components:
if component.charge_setter not in ATTR_COMPONENTS:
_LOGGER.warning("Found unknown component: %s", component.charge_setter)
continue
- self._attributes[component.charge_setter] = float(component.value)
+ components[component.charge_setter] = component.value
+
+ return {
+ ATTR_START_AT: self.coordinator.data.start_at,
+ ATTR_END_AT: self.coordinator.data.end_at,
+ **components,
+ }
diff --git a/homeassistant/components/flick_electric/strings.json b/homeassistant/components/flick_electric/strings.json
index 8b55bef939e..4b1fd300e2b 100644
--- a/homeassistant/components/flick_electric/strings.json
+++ b/homeassistant/components/flick_electric/strings.json
@@ -9,6 +9,12 @@
"client_id": "Client ID (optional)",
"client_secret": "Client Secret (optional)"
}
+ },
+ "select_account": {
+ "title": "Select account",
+ "data": {
+ "account_id": "Account"
+ }
}
},
"error": {
@@ -17,7 +23,10 @@
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"abort": {
- "already_configured": "[%key:common::config_flow::abort::already_configured_account%]"
+ "already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
+ "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
+ "no_permissions": "Cannot get pricing for this account. Please check user permissions.",
+ "no_accounts": "No services are active on this Flick account"
}
},
"entity": {
diff --git a/homeassistant/components/flock/manifest.json b/homeassistant/components/flock/manifest.json
index 29c3e1c881f..c4cd5cdadb3 100644
--- a/homeassistant/components/flock/manifest.json
+++ b/homeassistant/components/flock/manifest.json
@@ -3,5 +3,6 @@
"name": "Flock",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/flock",
- "iot_class": "cloud_push"
+ "iot_class": "cloud_push",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/flux/switch.py b/homeassistant/components/flux/switch.py
index 8a3d7ec7260..f7cf5b2c03a 100644
--- a/homeassistant/components/flux/switch.py
+++ b/homeassistant/components/flux/switch.py
@@ -13,7 +13,7 @@ import voluptuous as vol
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
- ATTR_COLOR_TEMP,
+ ATTR_COLOR_TEMP_KELVIN,
ATTR_RGB_COLOR,
ATTR_TRANSITION,
ATTR_XY_COLOR,
@@ -43,7 +43,6 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import slugify
from homeassistant.util.color import (
color_RGB_to_xy_brightness,
- color_temperature_kelvin_to_mired,
color_temperature_to_rgb,
)
from homeassistant.util.dt import as_local, utcnow as dt_utcnow
@@ -109,13 +108,13 @@ async def async_set_lights_xy(hass, lights, x_val, y_val, brightness, transition
await hass.services.async_call(LIGHT_DOMAIN, SERVICE_TURN_ON, service_data)
-async def async_set_lights_temp(hass, lights, mired, brightness, transition):
+async def async_set_lights_temp(hass, lights, kelvin, brightness, transition):
"""Set color of array of lights."""
for light in lights:
if is_on(hass, light):
service_data = {ATTR_ENTITY_ID: light}
- if mired is not None:
- service_data[ATTR_COLOR_TEMP] = int(mired)
+ if kelvin is not None:
+ service_data[ATTR_COLOR_TEMP_KELVIN] = kelvin
if brightness is not None:
service_data[ATTR_BRIGHTNESS] = brightness
if transition is not None:
@@ -350,17 +349,15 @@ class FluxSwitch(SwitchEntity, RestoreEntity):
now,
)
else:
- # Convert to mired and clamp to allowed values
- mired = color_temperature_kelvin_to_mired(temp)
await async_set_lights_temp(
- self.hass, self._lights, mired, brightness, self._transition
+ self.hass, self._lights, int(temp), brightness, self._transition
)
_LOGGER.debug(
(
- "Lights updated to mired:%s brightness:%s, %s%% "
+ "Lights updated to kelvin:%s brightness:%s, %s%% "
"of %s cycle complete at %s"
),
- mired,
+ temp,
brightness,
round(percentage_complete * 100),
time_state,
diff --git a/homeassistant/components/flux_led/light.py b/homeassistant/components/flux_led/light.py
index f4982a13c3a..ca7fb7aeea2 100644
--- a/homeassistant/components/flux_led/light.py
+++ b/homeassistant/components/flux_led/light.py
@@ -14,7 +14,7 @@ import voluptuous as vol
from homeassistant import config_entries
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
- ATTR_COLOR_TEMP,
+ ATTR_COLOR_TEMP_KELVIN,
ATTR_EFFECT,
ATTR_RGB_COLOR,
ATTR_RGBW_COLOR,
@@ -30,10 +30,6 @@ import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import VolDictType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
-from homeassistant.util.color import (
- color_temperature_kelvin_to_mired,
- color_temperature_mired_to_kelvin,
-)
from .const import (
CONF_COLORS,
@@ -67,7 +63,7 @@ _LOGGER = logging.getLogger(__name__)
MODE_ATTRS = {
ATTR_EFFECT,
- ATTR_COLOR_TEMP,
+ ATTR_COLOR_TEMP_KELVIN,
ATTR_RGB_COLOR,
ATTR_RGBW_COLOR,
ATTR_RGBWW_COLOR,
@@ -205,8 +201,8 @@ class FluxLight(
) -> None:
"""Initialize the light."""
super().__init__(coordinator, base_unique_id, None)
- self._attr_min_mireds = color_temperature_kelvin_to_mired(self._device.max_temp)
- self._attr_max_mireds = color_temperature_kelvin_to_mired(self._device.min_temp)
+ self._attr_min_color_temp_kelvin = self._device.min_temp
+ self._attr_max_color_temp_kelvin = self._device.max_temp
self._attr_supported_color_modes = _hass_color_modes(self._device)
custom_effects: list[str] = []
if custom_effect_colors:
@@ -222,9 +218,9 @@ class FluxLight(
return self._device.brightness
@property
- def color_temp(self) -> int:
- """Return the kelvin value of this light in mired."""
- return color_temperature_kelvin_to_mired(self._device.color_temp)
+ def color_temp_kelvin(self) -> int:
+ """Return the kelvin value of this light."""
+ return self._device.color_temp
@property
def rgb_color(self) -> tuple[int, int, int]:
@@ -304,8 +300,7 @@ class FluxLight(
await self._async_set_effect(effect, brightness)
return
# Handle switch to CCT Color Mode
- if color_temp_mired := kwargs.get(ATTR_COLOR_TEMP):
- color_temp_kelvin = color_temperature_mired_to_kelvin(color_temp_mired)
+ if color_temp_kelvin := kwargs.get(ATTR_COLOR_TEMP_KELVIN):
if (
ATTR_BRIGHTNESS not in kwargs
and self.color_mode in MULTI_BRIGHTNESS_COLOR_MODES
diff --git a/homeassistant/components/flux_led/manifest.json b/homeassistant/components/flux_led/manifest.json
index a55ae028342..962098a0bf8 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.0.4"]
+ "requirements": ["flux-led==1.1.0"]
}
diff --git a/homeassistant/components/folder/manifest.json b/homeassistant/components/folder/manifest.json
index 2436d5dbe9a..984b287c2c0 100644
--- a/homeassistant/components/folder/manifest.json
+++ b/homeassistant/components/folder/manifest.json
@@ -3,5 +3,6 @@
"name": "Folder",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/folder",
- "iot_class": "local_polling"
+ "iot_class": "local_polling",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/folder_watcher/__init__.py b/homeassistant/components/folder_watcher/__init__.py
index 3aeaa6f7ef2..dd56b3aad72 100644
--- a/homeassistant/components/folder_watcher/__init__.py
+++ b/homeassistant/components/folder_watcher/__init__.py
@@ -7,6 +7,10 @@ import os
from typing import cast
from watchdog.events import (
+ DirCreatedEvent,
+ DirDeletedEvent,
+ DirModifiedEvent,
+ DirMovedEvent,
FileClosedEvent,
FileCreatedEvent,
FileDeletedEvent,
@@ -68,7 +72,7 @@ class EventHandler(PatternMatchingEventHandler):
def __init__(self, patterns: list[str], hass: HomeAssistant, entry_id: str) -> None:
"""Initialise the EventHandler."""
- super().__init__(patterns)
+ super().__init__(patterns=patterns)
self.hass = hass
self.entry_id = entry_id
@@ -101,19 +105,19 @@ class EventHandler(PatternMatchingEventHandler):
signal = f"folder_watcher-{self.entry_id}"
dispatcher_send(self.hass, signal, event.event_type, fireable)
- def on_modified(self, event: FileModifiedEvent) -> None:
+ def on_modified(self, event: DirModifiedEvent | FileModifiedEvent) -> None:
"""File modified."""
self.process(event)
- def on_moved(self, event: FileMovedEvent) -> None:
+ def on_moved(self, event: DirMovedEvent | FileMovedEvent) -> None:
"""File moved."""
self.process(event, moved=True)
- def on_created(self, event: FileCreatedEvent) -> None:
+ def on_created(self, event: DirCreatedEvent | FileCreatedEvent) -> None:
"""File created."""
self.process(event)
- def on_deleted(self, event: FileDeletedEvent) -> None:
+ def on_deleted(self, event: DirDeletedEvent | FileDeletedEvent) -> None:
"""File deleted."""
self.process(event)
diff --git a/homeassistant/components/folder_watcher/manifest.json b/homeassistant/components/folder_watcher/manifest.json
index 7b471e08fcc..1f0d9c595ee 100644
--- a/homeassistant/components/folder_watcher/manifest.json
+++ b/homeassistant/components/folder_watcher/manifest.json
@@ -7,5 +7,5 @@
"iot_class": "local_polling",
"loggers": ["watchdog"],
"quality_scale": "internal",
- "requirements": ["watchdog==2.3.1"]
+ "requirements": ["watchdog==6.0.0"]
}
diff --git a/homeassistant/components/foobot/manifest.json b/homeassistant/components/foobot/manifest.json
index a517f1fea6f..147a0037a18 100644
--- a/homeassistant/components/foobot/manifest.json
+++ b/homeassistant/components/foobot/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/foobot",
"iot_class": "cloud_polling",
"loggers": ["foobot_async"],
+ "quality_scale": "legacy",
"requirements": ["foobot_async==1.0.0"]
}
diff --git a/homeassistant/components/forecast_solar/manifest.json b/homeassistant/components/forecast_solar/manifest.json
index f5dd79281e6..1eb9c98701d 100644
--- a/homeassistant/components/forecast_solar/manifest.json
+++ b/homeassistant/components/forecast_solar/manifest.json
@@ -6,6 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/forecast_solar",
"integration_type": "service",
"iot_class": "cloud_polling",
- "quality_scale": "platinum",
- "requirements": ["forecast-solar==3.1.0"]
+ "requirements": ["forecast-solar==4.0.0"]
}
diff --git a/homeassistant/components/fortios/manifest.json b/homeassistant/components/fortios/manifest.json
index 93e55071178..22c44acfd82 100644
--- a/homeassistant/components/fortios/manifest.json
+++ b/homeassistant/components/fortios/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/fortios",
"iot_class": "local_polling",
"loggers": ["fortiosapi", "paramiko"],
+ "quality_scale": "legacy",
"requirements": ["fortiosapi==1.0.5"]
}
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/manifest.json b/homeassistant/components/foursquare/manifest.json
index ce1c87814d7..0503ea4abb5 100644
--- a/homeassistant/components/foursquare/manifest.json
+++ b/homeassistant/components/foursquare/manifest.json
@@ -4,5 +4,6 @@
"codeowners": [],
"dependencies": ["http"],
"documentation": "https://www.home-assistant.io/integrations/foursquare",
- "iot_class": "cloud_push"
+ "iot_class": "cloud_push",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/free_mobile/manifest.json b/homeassistant/components/free_mobile/manifest.json
index 61a1f94c19d..9ce9bc72c76 100644
--- a/homeassistant/components/free_mobile/manifest.json
+++ b/homeassistant/components/free_mobile/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/free_mobile",
"iot_class": "cloud_push",
"loggers": ["freesms"],
+ "quality_scale": "legacy",
"requirements": ["freesms==0.2.0"]
}
diff --git a/homeassistant/components/freebox/manifest.json b/homeassistant/components/freebox/manifest.json
index ad7da1703b8..46422cee105 100644
--- a/homeassistant/components/freebox/manifest.json
+++ b/homeassistant/components/freebox/manifest.json
@@ -7,6 +7,6 @@
"documentation": "https://www.home-assistant.io/integrations/freebox",
"iot_class": "local_polling",
"loggers": ["freebox_api"],
- "requirements": ["freebox-api==1.1.0"],
+ "requirements": ["freebox-api==1.2.1"],
"zeroconf": ["_fbx-api._tcp.local."]
}
diff --git a/homeassistant/components/freedns/manifest.json b/homeassistant/components/freedns/manifest.json
index ac320a51d93..7c6bceb11a6 100644
--- a/homeassistant/components/freedns/manifest.json
+++ b/homeassistant/components/freedns/manifest.json
@@ -3,5 +3,6 @@
"name": "FreeDNS",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/freedns",
- "iot_class": "cloud_push"
+ "iot_class": "cloud_push",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/freedompro/climate.py b/homeassistant/components/freedompro/climate.py
index d534db7e858..a5b0144ce0c 100644
--- a/homeassistant/components/freedompro/climate.py
+++ b/homeassistant/components/freedompro/climate.py
@@ -73,7 +73,6 @@ class Device(CoordinatorEntity[FreedomproDataUpdateCoordinator], ClimateEntity):
_attr_current_temperature = 0
_attr_target_temperature = 0
_attr_hvac_mode = HVACMode.OFF
- _enable_turn_on_off_backwards_compatibility = False
def __init__(
self,
diff --git a/homeassistant/components/freedompro/fan.py b/homeassistant/components/freedompro/fan.py
index 698d57d1001..d21ede9bad3 100644
--- a/homeassistant/components/freedompro/fan.py
+++ b/homeassistant/components/freedompro/fan.py
@@ -40,7 +40,6 @@ class FreedomproFan(CoordinatorEntity[FreedomproDataUpdateCoordinator], FanEntit
_attr_name = None
_attr_is_on = False
_attr_percentage = 0
- _enable_turn_on_off_backwards_compatibility = False
def __init__(
self,
diff --git a/homeassistant/components/fritz/config_flow.py b/homeassistant/components/fritz/config_flow.py
index ec9ffdd7554..920ecda1c52 100644
--- a/homeassistant/components/fritz/config_flow.py
+++ b/homeassistant/components/fritz/config_flow.py
@@ -57,6 +57,8 @@ class FritzBoxToolsFlowHandler(ConfigFlow, domain=DOMAIN):
VERSION = 1
+ _host: str
+
@staticmethod
@callback
def async_get_options_flow(
@@ -67,7 +69,6 @@ class FritzBoxToolsFlowHandler(ConfigFlow, domain=DOMAIN):
def __init__(self) -> None:
"""Initialize FRITZ!Box Tools flow."""
- self._host: str | None = None
self._name: str = ""
self._password: str = ""
self._use_tls: bool = False
@@ -112,7 +113,6 @@ class FritzBoxToolsFlowHandler(ConfigFlow, domain=DOMAIN):
async def async_check_configured_entry(self) -> ConfigEntry | None:
"""Check if entry is configured."""
- assert self._host
current_host = await self.hass.async_add_executor_job(
socket.gethostbyname, self._host
)
@@ -154,15 +154,17 @@ class FritzBoxToolsFlowHandler(ConfigFlow, domain=DOMAIN):
) -> ConfigFlowResult:
"""Handle a flow initialized by discovery."""
ssdp_location: ParseResult = urlparse(discovery_info.ssdp_location or "")
- self._host = ssdp_location.hostname
+ host = ssdp_location.hostname
+ if not host or ipaddress.ip_address(host).is_link_local:
+ return self.async_abort(reason="ignore_ip6_link_local")
+
+ self._host = host
self._name = (
discovery_info.upnp.get(ssdp.ATTR_UPNP_FRIENDLY_NAME)
or discovery_info.upnp[ssdp.ATTR_UPNP_MODEL_NAME]
)
- if not self._host or ipaddress.ip_address(self._host).is_link_local:
- return self.async_abort(reason="ignore_ip6_link_local")
-
+ uuid: str | None
if uuid := discovery_info.upnp.get(ssdp.ATTR_UPNP_UDN):
if uuid.startswith("uuid:"):
uuid = uuid[5:]
diff --git a/homeassistant/components/fritz/coordinator.py b/homeassistant/components/fritz/coordinator.py
index 31d8ff81491..272295cd512 100644
--- a/homeassistant/components/fritz/coordinator.py
+++ b/homeassistant/components/fritz/coordinator.py
@@ -214,6 +214,18 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]):
self._options = options
await self.hass.async_add_executor_job(self.setup)
+ device_registry = dr.async_get(self.hass)
+ device_registry.async_get_or_create(
+ config_entry_id=self.config_entry.entry_id,
+ configuration_url=f"http://{self.host}",
+ connections={(dr.CONNECTION_NETWORK_MAC, self.mac)},
+ identifiers={(DOMAIN, self.unique_id)},
+ manufacturer="AVM",
+ model=self.model,
+ name=self.config_entry.title,
+ sw_version=self.current_firmware,
+ )
+
def setup(self) -> None:
"""Set up FritzboxTools class."""
@@ -326,7 +338,11 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]):
"call_deflections"
] = await self.async_update_call_deflections()
except FRITZ_EXCEPTIONS as ex:
- raise UpdateFailed(ex) from ex
+ raise UpdateFailed(
+ translation_domain=DOMAIN,
+ translation_key="update_failed",
+ translation_placeholders={"error": str(ex)},
+ ) from ex
_LOGGER.debug("enity_data: %s", entity_data)
return entity_data
diff --git a/homeassistant/components/fritz/entity.py b/homeassistant/components/fritz/entity.py
index 45665c786d4..33eb60d72cf 100644
--- a/homeassistant/components/fritz/entity.py
+++ b/homeassistant/components/fritz/entity.py
@@ -68,23 +68,14 @@ class FritzBoxBaseEntity:
"""Init device info class."""
self._avm_wrapper = avm_wrapper
self._device_name = device_name
-
- @property
- def mac_address(self) -> str:
- """Return the mac address of the main device."""
- return self._avm_wrapper.mac
+ self.mac_address = self._avm_wrapper.mac
@property
def device_info(self) -> DeviceInfo:
"""Return the device information."""
return DeviceInfo(
- configuration_url=f"http://{self._avm_wrapper.host}",
connections={(dr.CONNECTION_NETWORK_MAC, self.mac_address)},
identifiers={(DOMAIN, self._avm_wrapper.unique_id)},
- manufacturer="AVM",
- model=self._avm_wrapper.model,
- name=self._device_name,
- sw_version=self._avm_wrapper.current_firmware,
)
diff --git a/homeassistant/components/fritz/quality_scale.yaml b/homeassistant/components/fritz/quality_scale.yaml
new file mode 100644
index 00000000000..06c572f93a6
--- /dev/null
+++ b/homeassistant/components/fritz/quality_scale.yaml
@@ -0,0 +1,101 @@
+rules:
+ # Bronze
+ action-setup:
+ status: todo
+ comment: still in async_setup_entry, needs to be moved to async_setup
+ appropriate-polling: done
+ brands: done
+ common-modules: done
+ config-flow-test-coverage:
+ status: todo
+ comment: one coverage miss in line 110
+ config-flow:
+ status: todo
+ comment: data_description are missing
+ dependency-transparency: done
+ docs-actions: done
+ docs-high-level-description: done
+ docs-installation-instructions: done
+ docs-removal-instructions:
+ status: todo
+ comment: include the proper docs snippet
+ entity-event-setup: done
+ entity-unique-id: done
+ has-entity-name:
+ status: todo
+ comment: partially done
+ runtime-data:
+ status: todo
+ comment: still uses hass.data
+ 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:
+ status: todo
+ comment: add the proper configuration_basic block
+ entity-unavailable: done
+ integration-owner: done
+ log-when-unavailable: done
+ parallel-updates:
+ status: todo
+ comment: not set at the moment, we use a coordinator
+ reauthentication-flow: done
+ test-coverage:
+ status: todo
+ comment: we are close to the goal of 95%
+
+ # Gold
+ devices: done
+ diagnostics: done
+ discovery-update-info: todo
+ discovery: done
+ docs-data-update: todo
+ docs-examples: done
+ docs-known-limitations:
+ status: exempt
+ comment: no known limitations, yet
+ docs-supported-devices:
+ status: todo
+ comment: add the known supported devices
+ docs-supported-functions:
+ status: todo
+ comment: need to be overhauled
+ docs-troubleshooting: done
+ docs-use-cases:
+ status: todo
+ comment: need to be overhauled
+ dynamic-devices: done
+ 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: no known use cases for repair issues or flows, yet
+ stale-devices:
+ status: todo
+ comment: automate the current cleanup process and deprecate the corresponding button
+
+ # Platinum
+ async-dependency:
+ status: todo
+ comment: |
+ the fritzconnection lib is not async
+ changing this might need a bit more efforts to be spent
+ inject-websession:
+ status: todo
+ comment: |
+ the fritzconnection lib is not async and relies on requests
+ changing this might need a bit more efforts to be spent
+ strict-typing:
+ status: todo
+ comment: |
+ Requirements 'fritzconnection==1.14.0' and 'xmltodict==0.13.0' appear untyped
diff --git a/homeassistant/components/fritz/strings.json b/homeassistant/components/fritz/strings.json
index 96eb6243529..06a07cba79e 100644
--- a/homeassistant/components/fritz/strings.json
+++ b/homeassistant/components/fritz/strings.json
@@ -176,6 +176,9 @@
},
"unable_to_connect": {
"message": "Unable to establish a connection"
+ },
+ "update_failed": {
+ "message": "Error while uptaing the data: {error}"
}
}
}
diff --git a/homeassistant/components/fritzbox/climate.py b/homeassistant/components/fritzbox/climate.py
index 924d92d6c5b..d5a81fdef1a 100644
--- a/homeassistant/components/fritzbox/climate.py
+++ b/homeassistant/components/fritzbox/climate.py
@@ -88,7 +88,6 @@ class FritzboxThermostat(FritzBoxDeviceEntity, ClimateEntity):
_attr_precision = PRECISION_HALVES
_attr_temperature_unit = UnitOfTemperature.CELSIUS
_attr_translation_key = "thermostat"
- _enable_turn_on_off_backwards_compatibility = False
def __init__(
self,
diff --git a/homeassistant/components/fritzbox/config_flow.py b/homeassistant/components/fritzbox/config_flow.py
index 76754fc5082..ffec4a9ea29 100644
--- a/homeassistant/components/fritzbox/config_flow.py
+++ b/homeassistant/components/fritzbox/config_flow.py
@@ -43,10 +43,11 @@ class FritzboxConfigFlow(ConfigFlow, domain=DOMAIN):
VERSION = 1
+ _name: str
+
def __init__(self) -> None:
"""Initialize flow."""
self._host: str | None = None
- self._name: str | None = None
self._password: str | None = None
self._username: str | None = None
@@ -158,7 +159,6 @@ class FritzboxConfigFlow(ConfigFlow, domain=DOMAIN):
result = await self.async_try_connect()
if result == RESULT_SUCCESS:
- assert self._name is not None
return self._get_entry(self._name)
if result != RESULT_INVALID_AUTH:
return self.async_abort(reason=result)
diff --git a/homeassistant/components/fritzbox/coordinator.py b/homeassistant/components/fritzbox/coordinator.py
index 52fa3ba1a12..a6a30ffdc6a 100644
--- a/homeassistant/components/fritzbox/coordinator.py
+++ b/homeassistant/components/fritzbox/coordinator.py
@@ -27,6 +27,7 @@ class FritzboxCoordinatorData:
devices: dict[str, FritzhomeDevice]
templates: dict[str, FritzhomeTemplate]
+ supported_color_properties: dict[str, tuple[dict, list]]
class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorData]):
@@ -49,7 +50,7 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat
self.new_devices: set[str] = set()
self.new_templates: set[str] = set()
- self.data = FritzboxCoordinatorData({}, {})
+ self.data = FritzboxCoordinatorData({}, {}, {})
async def async_setup(self) -> None:
"""Set up the coordinator."""
@@ -120,6 +121,7 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat
devices = self.fritz.get_devices()
device_data = {}
+ supported_color_properties = self.data.supported_color_properties
for device in devices:
# assume device as unavailable, see #55799
if (
@@ -136,6 +138,13 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat
device_data[device.ain] = device
+ # pre-load supported colors and color temps for new devices
+ if device.has_color and device.ain not in supported_color_properties:
+ supported_color_properties[device.ain] = (
+ device.get_colors(),
+ device.get_color_temps(),
+ )
+
template_data = {}
if self.has_templates:
templates = self.fritz.get_templates()
@@ -145,7 +154,11 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat
self.new_devices = device_data.keys() - self.data.devices.keys()
self.new_templates = template_data.keys() - self.data.templates.keys()
- return FritzboxCoordinatorData(devices=device_data, templates=template_data)
+ return FritzboxCoordinatorData(
+ devices=device_data,
+ templates=template_data,
+ supported_color_properties=supported_color_properties,
+ )
async def _async_update_data(self) -> FritzboxCoordinatorData:
"""Fetch all device data."""
diff --git a/homeassistant/components/fritzbox/light.py b/homeassistant/components/fritzbox/light.py
index d347f6898c0..36cb7dc8cff 100644
--- a/homeassistant/components/fritzbox/light.py
+++ b/homeassistant/components/fritzbox/light.py
@@ -57,7 +57,6 @@ class FritzboxLight(FritzBoxDeviceEntity, LightEntity):
) -> None:
"""Initialize the FritzboxLight entity."""
super().__init__(coordinator, ain, None)
- self._supported_hs: dict[int, list[int]] = {}
self._attr_supported_color_modes = {ColorMode.ONOFF}
if self.data.has_color:
@@ -65,6 +64,26 @@ class FritzboxLight(FritzBoxDeviceEntity, LightEntity):
elif self.data.has_level:
self._attr_supported_color_modes = {ColorMode.BRIGHTNESS}
+ (supported_colors, supported_color_temps) = (
+ coordinator.data.supported_color_properties.get(self.data.ain, ({}, []))
+ )
+
+ # Fritz!DECT 500 only supports 12 values for hue, with 3 saturations each.
+ # Map supported colors to dict {hue: [sat1, sat2, sat3]} for easier lookup
+ self._supported_hs: dict[int, list[int]] = {}
+ for values in supported_colors.values():
+ hue = int(values[0][0])
+ self._supported_hs[hue] = [
+ int(values[0][1]),
+ int(values[1][1]),
+ int(values[2][1]),
+ ]
+
+ if supported_color_temps:
+ # only available for color bulbs
+ self._attr_max_color_temp_kelvin = int(max(supported_color_temps))
+ self._attr_min_color_temp_kelvin = int(min(supported_color_temps))
+
@property
def is_on(self) -> bool:
"""If the light is currently on or off."""
@@ -148,30 +167,3 @@ class FritzboxLight(FritzBoxDeviceEntity, LightEntity):
"""Turn the light off."""
await self.hass.async_add_executor_job(self.data.set_state_off)
await self.coordinator.async_refresh()
-
- async def async_added_to_hass(self) -> None:
- """Get light attributes from device after entity is added to hass."""
- await super().async_added_to_hass()
-
- def _get_color_data() -> tuple[dict, list]:
- return (self.data.get_colors(), self.data.get_color_temps())
-
- (
- supported_colors,
- supported_color_temps,
- ) = await self.hass.async_add_executor_job(_get_color_data)
-
- if supported_color_temps:
- # only available for color bulbs
- self._attr_max_color_temp_kelvin = int(max(supported_color_temps))
- self._attr_min_color_temp_kelvin = int(min(supported_color_temps))
-
- # Fritz!DECT 500 only supports 12 values for hue, with 3 saturations each.
- # Map supported colors to dict {hue: [sat1, sat2, sat3]} for easier lookup
- for values in supported_colors.values():
- hue = int(values[0][0])
- self._supported_hs[hue] = [
- int(values[0][1]),
- int(values[1][1]),
- int(values[2][1]),
- ]
diff --git a/homeassistant/components/fritzbox/manifest.json b/homeassistant/components/fritzbox/manifest.json
index 3735c16571e..1a127597b81 100644
--- a/homeassistant/components/fritzbox/manifest.json
+++ b/homeassistant/components/fritzbox/manifest.json
@@ -7,7 +7,6 @@
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["pyfritzhome"],
- "quality_scale": "gold",
"requirements": ["pyfritzhome==0.6.12"],
"ssdp": [
{
diff --git a/homeassistant/components/fritzbox_callmonitor/base.py b/homeassistant/components/fritzbox_callmonitor/base.py
index 2816880a1b2..3c8714624e7 100644
--- a/homeassistant/components/fritzbox_callmonitor/base.py
+++ b/homeassistant/components/fritzbox_callmonitor/base.py
@@ -3,6 +3,7 @@
from __future__ import annotations
from contextlib import suppress
+from dataclasses import dataclass
from datetime import timedelta
import logging
import re
@@ -19,12 +20,33 @@ _LOGGER = logging.getLogger(__name__)
MIN_TIME_PHONEBOOK_UPDATE = timedelta(hours=6)
+@dataclass
+class Contact:
+ """Store details for one phonebook contact."""
+
+ name: str
+ numbers: list[str]
+ vip: bool
+
+ def __init__(
+ self, name: str, numbers: list[str] | None = None, category: str | None = None
+ ) -> None:
+ """Initialize the class."""
+ self.name = name
+ self.numbers = [re.sub(REGEX_NUMBER, "", nr) for nr in numbers or ()]
+ self.vip = category == "1"
+
+
+unknown_contact = Contact(UNKNOWN_NAME)
+
+
class FritzBoxPhonebook:
"""Connects to a FritzBox router and downloads its phone book."""
fph: FritzPhonebook
phonebook_dict: dict[str, list[str]]
- number_dict: dict[str, str]
+ contacts: list[Contact]
+ number_dict: dict[str, Contact]
def __init__(
self,
@@ -56,27 +78,27 @@ class FritzBoxPhonebook:
if self.phonebook_id is None:
return
- self.phonebook_dict = self.fph.get_all_names(self.phonebook_id)
- self.number_dict = {
- re.sub(REGEX_NUMBER, "", nr): name
- for name, nrs in self.phonebook_dict.items()
- for nr in nrs
- }
+ self.fph.get_all_name_numbers(self.phonebook_id)
+ self.contacts = [
+ Contact(c.name, c.numbers, getattr(c, "category", None))
+ for c in self.fph.phonebook.contacts
+ ]
+ self.number_dict = {nr: c for c in self.contacts for nr in c.numbers}
_LOGGER.debug("Fritz!Box phone book successfully updated")
def get_phonebook_ids(self) -> list[int]:
"""Return list of phonebook ids."""
return self.fph.phonebook_ids # type: ignore[no-any-return]
- def get_name(self, number: str) -> str:
- """Return a name for a given phone number."""
+ def get_contact(self, number: str) -> Contact:
+ """Return a contact for a given phone number."""
number = re.sub(REGEX_NUMBER, "", str(number))
with suppress(KeyError):
return self.number_dict[number]
if not self.prefixes:
- return UNKNOWN_NAME
+ return unknown_contact
for prefix in self.prefixes:
with suppress(KeyError):
@@ -84,4 +106,4 @@ class FritzBoxPhonebook:
with suppress(KeyError):
return self.number_dict[prefix + number.lstrip("0")]
- return UNKNOWN_NAME
+ return unknown_contact
diff --git a/homeassistant/components/fritzbox_callmonitor/config_flow.py b/homeassistant/components/fritzbox_callmonitor/config_flow.py
index 7bd0eacb66a..8435eff3e18 100644
--- a/homeassistant/components/fritzbox_callmonitor/config_flow.py
+++ b/homeassistant/components/fritzbox_callmonitor/config_flow.py
@@ -12,19 +12,12 @@ from requests.exceptions import ConnectionError as RequestsConnectionError
import voluptuous as vol
from homeassistant.config_entries import (
- SOURCE_IMPORT,
ConfigEntry,
ConfigFlow,
ConfigFlowResult,
OptionsFlow,
)
-from homeassistant.const import (
- CONF_HOST,
- CONF_NAME,
- CONF_PASSWORD,
- CONF_PORT,
- CONF_USERNAME,
-)
+from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME
from homeassistant.core import callback
from .base import FritzBoxPhonebook
@@ -170,16 +163,11 @@ class FritzBoxCallMonitorConfigFlow(ConfigFlow, domain=DOMAIN):
if result != ConnectResult.SUCCESS:
return self.async_abort(reason=result)
- if self.context["source"] == SOURCE_IMPORT:
- self._phonebook_id = user_input[CONF_PHONEBOOK]
- self._phonebook_name = user_input[CONF_NAME]
-
- elif len(self._phonebook_ids) > 1:
+ if len(self._phonebook_ids) > 1:
return await self.async_step_phonebook()
- else:
- self._phonebook_id = DEFAULT_PHONEBOOK
- self._phonebook_name = await self._get_name_of_phonebook(self._phonebook_id)
+ self._phonebook_id = DEFAULT_PHONEBOOK
+ self._phonebook_name = await self._get_name_of_phonebook(self._phonebook_id)
await self.async_set_unique_id(f"{self._serial_number}-{self._phonebook_id}")
self._abort_if_unique_id_configured()
diff --git a/homeassistant/components/fritzbox_callmonitor/sensor.py b/homeassistant/components/fritzbox_callmonitor/sensor.py
index 668369c35a7..df18ae5702a 100644
--- a/homeassistant/components/fritzbox_callmonitor/sensor.py
+++ b/homeassistant/components/fritzbox_callmonitor/sensor.py
@@ -20,7 +20,7 @@ from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import FritzBoxCallMonitorConfigEntry
-from .base import FritzBoxPhonebook
+from .base import Contact, FritzBoxPhonebook
from .const import (
ATTR_PREFIXES,
CONF_PHONEBOOK,
@@ -96,7 +96,7 @@ class FritzBoxCallSensor(SensorEntity):
self._host = host
self._port = port
self._monitor: FritzBoxCallMonitor | None = None
- self._attributes: dict[str, str | list[str]] = {}
+ self._attributes: dict[str, str | list[str] | bool] = {}
self._attr_translation_placeholders = {"phonebook_name": phonebook_name}
self._attr_unique_id = unique_id
@@ -152,20 +152,20 @@ class FritzBoxCallSensor(SensorEntity):
"""Set the state."""
self._attr_native_value = state
- def set_attributes(self, attributes: Mapping[str, str]) -> None:
+ def set_attributes(self, attributes: Mapping[str, str | bool]) -> None:
"""Set the state attributes."""
self._attributes = {**attributes}
@property
- def extra_state_attributes(self) -> dict[str, str | list[str]]:
+ def extra_state_attributes(self) -> dict[str, str | list[str] | bool]:
"""Return the state attributes."""
if self._prefixes:
self._attributes[ATTR_PREFIXES] = self._prefixes
return self._attributes
- def number_to_name(self, number: str) -> str:
- """Return a name for a given phone number."""
- return self._fritzbox_phonebook.get_name(number)
+ def number_to_contact(self, number: str) -> Contact:
+ """Return a contact for a given phone number."""
+ return self._fritzbox_phonebook.get_contact(number)
def update(self) -> None:
"""Update the phonebook if it is defined."""
@@ -225,35 +225,42 @@ class FritzBoxCallMonitor:
df_in = "%d.%m.%y %H:%M:%S"
df_out = "%Y-%m-%dT%H:%M:%S"
isotime = datetime.strptime(line[0], df_in).strftime(df_out)
+ att: dict[str, str | bool]
if line[1] == FritzState.RING:
self._sensor.set_state(CallState.RINGING)
+ contact = self._sensor.number_to_contact(line[3])
att = {
"type": "incoming",
"from": line[3],
"to": line[4],
"device": line[5],
"initiated": isotime,
- "from_name": self._sensor.number_to_name(line[3]),
+ "from_name": contact.name,
+ "vip": contact.vip,
}
self._sensor.set_attributes(att)
elif line[1] == FritzState.CALL:
self._sensor.set_state(CallState.DIALING)
+ contact = self._sensor.number_to_contact(line[5])
att = {
"type": "outgoing",
"from": line[4],
"to": line[5],
"device": line[6],
"initiated": isotime,
- "to_name": self._sensor.number_to_name(line[5]),
+ "to_name": contact.name,
+ "vip": contact.vip,
}
self._sensor.set_attributes(att)
elif line[1] == FritzState.CONNECT:
self._sensor.set_state(CallState.TALKING)
+ contact = self._sensor.number_to_contact(line[4])
att = {
"with": line[4],
"device": line[3],
"accepted": isotime,
- "with_name": self._sensor.number_to_name(line[4]),
+ "with_name": contact.name,
+ "vip": contact.vip,
}
self._sensor.set_attributes(att)
elif line[1] == FritzState.DISCONNECT:
diff --git a/homeassistant/components/fritzbox_callmonitor/strings.json b/homeassistant/components/fritzbox_callmonitor/strings.json
index e935549035c..437b218a8e2 100644
--- a/homeassistant/components/fritzbox_callmonitor/strings.json
+++ b/homeassistant/components/fritzbox_callmonitor/strings.json
@@ -78,7 +78,8 @@
"accepted": { "name": "Accepted" },
"with_name": { "name": "With name" },
"duration": { "name": "Duration" },
- "closed": { "name": "Closed" }
+ "closed": { "name": "Closed" },
+ "vip": { "name": "Important" }
}
}
}
diff --git a/homeassistant/components/fronius/__init__.py b/homeassistant/components/fronius/__init__.py
index e30f8e85fa0..4ba893df85c 100644
--- a/homeassistant/components/fronius/__init__.py
+++ b/homeassistant/components/fronius/__init__.py
@@ -60,7 +60,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: FroniusConfigEntry) ->
async def async_remove_config_entry_device(
- hass: HomeAssistant, config_entry: ConfigEntry, device_entry: dr.DeviceEntry
+ hass: HomeAssistant, config_entry: FroniusConfigEntry, device_entry: dr.DeviceEntry
) -> bool:
"""Remove a config entry from a device."""
return True
@@ -226,7 +226,14 @@ class FroniusSolarNet:
_LOGGER.debug("Re-scan failed for %s", self.host)
return inverter_infos
- raise ConfigEntryNotReady from err
+ raise ConfigEntryNotReady(
+ translation_domain=DOMAIN,
+ translation_key="entry_cannot_connect",
+ translation_placeholders={
+ "host": self.host,
+ "fronius_error": str(err),
+ },
+ ) from err
for inverter in _inverter_info["inverters"]:
solar_net_id = inverter["device_id"]["value"]
diff --git a/homeassistant/components/fronius/config_flow.py b/homeassistant/components/fronius/config_flow.py
index 2adbf2ae2f3..ccc15d80401 100644
--- a/homeassistant/components/fronius/config_flow.py
+++ b/homeassistant/components/fronius/config_flow.py
@@ -52,11 +52,9 @@ async def validate_host(
try:
inverter_info = await fronius.inverter_info()
first_inverter = next(inverter for inverter in inverter_info["inverters"])
- except FroniusError as err:
+ except (FroniusError, StopIteration) as err:
_LOGGER.debug(err)
raise CannotConnect from err
- except StopIteration as err:
- raise CannotConnect("No supported Fronius SolarNet device found.") from err
first_inverter_uid: str = first_inverter["unique_id"]["value"]
return first_inverter_uid, FroniusConfigEntryData(
host=host,
@@ -89,7 +87,7 @@ class FroniusConfigFlow(ConfigFlow, domain=DOMAIN):
errors["base"] = "unknown"
else:
await self.async_set_unique_id(unique_id, raise_on_progress=False)
- self._abort_if_unique_id_configured(updates=dict(info))
+ self._abort_if_unique_id_configured()
return self.async_create_entry(title=create_title(info), data=info)
diff --git a/homeassistant/components/fronius/const.py b/homeassistant/components/fronius/const.py
index 083085270e0..e8b2fa6c2e8 100644
--- a/homeassistant/components/fronius/const.py
+++ b/homeassistant/components/fronius/const.py
@@ -42,8 +42,6 @@ class InverterStatusCodeOption(StrEnum):
IDLE = "idle"
READY = "ready"
SLEEPING = "sleeping"
- UNKNOWN = "unknown"
- INVALID = "invalid"
_INVERTER_STATUS_CODES: Final[dict[int, InverterStatusCodeOption]] = {
@@ -61,13 +59,174 @@ _INVERTER_STATUS_CODES: Final[dict[int, InverterStatusCodeOption]] = {
11: InverterStatusCodeOption.IDLE,
12: InverterStatusCodeOption.READY,
13: InverterStatusCodeOption.SLEEPING,
- 255: InverterStatusCodeOption.UNKNOWN,
+ # 255: "Unknown" is handled by `None` state - same as the invalid codes.
}
-def get_inverter_status_message(code: StateType) -> InverterStatusCodeOption:
+def get_inverter_status_message(code: StateType) -> InverterStatusCodeOption | None:
"""Return a status message for a given status code."""
- return _INVERTER_STATUS_CODES.get(code, InverterStatusCodeOption.INVALID) # type: ignore[arg-type]
+ return _INVERTER_STATUS_CODES.get(code) # type: ignore[arg-type]
+
+
+INVERTER_ERROR_CODES: Final[dict[int, str]] = {
+ 0: "no_error",
+ 102: "ac_voltage_too_high",
+ 103: "ac_voltage_too_low",
+ 105: "ac_frequency_too_high",
+ 106: "ac_frequency_too_low",
+ 107: "ac_grid_outside_permissible_limits",
+ 108: "stand_alone_operation_detected",
+ 112: "rcmu_error",
+ 240: "arc_detection_triggered",
+ 241: "arc_detection_triggered",
+ 242: "arc_detection_triggered",
+ 243: "arc_detection_triggered",
+ 301: "overcurrent_ac",
+ 302: "overcurrent_dc",
+ 303: "dc_module_over_temperature",
+ 304: "ac_module_over_temperature",
+ 305: "no_power_fed_in_despite_closed_relay",
+ 306: "pv_output_too_low_for_feeding_energy_into_the_grid",
+ 307: "low_pv_voltage_dc_input_voltage_too_low",
+ 308: "intermediate_circuit_voltage_too_high",
+ 309: "dc_input_voltage_mppt_1_too_high",
+ 311: "polarity_of_dc_strings_reversed",
+ 313: "dc_input_voltage_mppt_2_too_high",
+ 314: "current_sensor_calibration_timeout",
+ 315: "ac_current_sensor_error",
+ 316: "interrupt_check_fail",
+ 325: "overtemperature_in_connection_area",
+ 326: "fan_1_error",
+ 327: "fan_2_error",
+ 401: "no_communication_with_power_stage_set",
+ 406: "ac_module_temperature_sensor_faulty_l1",
+ 407: "ac_module_temperature_sensor_faulty_l2",
+ 408: "dc_component_measured_in_grid_too_high",
+ 412: "fixed_voltage_mode_out_of_range",
+ 415: "safety_cut_out_triggered",
+ 416: "no_communication_between_power_stage_and_control_system",
+ 417: "hardware_id_problem",
+ 419: "unique_id_conflict",
+ 420: "no_communication_with_hybrid_manager",
+ 421: "hid_range_error",
+ 425: "no_communication_with_power_stage_set",
+ 426: "possible_hardware_fault",
+ 427: "possible_hardware_fault",
+ 428: "possible_hardware_fault",
+ 431: "software_problem",
+ 436: "functional_incompatibility_between_pc_boards",
+ 437: "power_stage_set_problem",
+ 438: "functional_incompatibility_between_pc_boards",
+ 443: "intermediate_circuit_voltage_too_low_or_asymmetric",
+ 445: "compatibility_error_invalid_power_stage_configuration",
+ 447: "insulation_fault",
+ 448: "neutral_conductor_not_connected",
+ 450: "guard_cannot_be_found",
+ 451: "memory_error_detected",
+ 452: "communication",
+ 502: "insulation_error_on_solar_panels",
+ 509: "no_energy_fed_into_grid_past_24_hours",
+ 515: "no_communication_with_filter",
+ 516: "no_communication_with_storage_unit",
+ 517: "power_derating_due_to_high_temperature",
+ 518: "internal_dsp_malfunction",
+ 519: "no_communication_with_storage_unit",
+ 520: "no_energy_fed_by_mppt1_past_24_hours",
+ 522: "dc_low_string_1",
+ 523: "dc_low_string_2",
+ 558: "functional_incompatibility_between_pc_boards",
+ 559: "functional_incompatibility_between_pc_boards",
+ 560: "derating_caused_by_over_frequency",
+ 564: "functional_incompatibility_between_pc_boards",
+ 566: "arc_detector_switched_off",
+ 567: "grid_voltage_dependent_power_reduction_active",
+ 601: "can_bus_full",
+ 603: "ac_module_temperature_sensor_faulty_l3",
+ 604: "dc_module_temperature_sensor_faulty",
+ 607: "rcmu_error",
+ 608: "functional_incompatibility_between_pc_boards",
+ 701: "internal_processor_status",
+ 702: "internal_processor_status",
+ 703: "internal_processor_status",
+ 704: "internal_processor_status",
+ 705: "internal_processor_status",
+ 706: "internal_processor_status",
+ 707: "internal_processor_status",
+ 708: "internal_processor_status",
+ 709: "internal_processor_status",
+ 710: "internal_processor_status",
+ 711: "internal_processor_status",
+ 712: "internal_processor_status",
+ 713: "internal_processor_status",
+ 714: "internal_processor_status",
+ 715: "internal_processor_status",
+ 716: "internal_processor_status",
+ 721: "eeprom_reinitialised",
+ 722: "internal_processor_status",
+ 723: "internal_processor_status",
+ 724: "internal_processor_status",
+ 725: "internal_processor_status",
+ 726: "internal_processor_status",
+ 727: "internal_processor_status",
+ 728: "internal_processor_status",
+ 729: "internal_processor_status",
+ 730: "internal_processor_status",
+ 731: "initialisation_error_usb_flash_drive_not_supported",
+ 732: "initialisation_error_usb_stick_over_current",
+ 733: "no_usb_flash_drive_connected",
+ 734: "update_file_not_recognised_or_missing",
+ 735: "update_file_does_not_match_device",
+ 736: "write_or_read_error_occurred",
+ 737: "file_could_not_be_opened",
+ 738: "log_file_cannot_be_saved",
+ 740: "initialisation_error_file_system_error_on_usb",
+ 741: "error_during_logging_data_recording",
+ 743: "error_during_update_process",
+ 745: "update_file_corrupt",
+ 746: "error_during_update_process",
+ 751: "time_lost",
+ 752: "real_time_clock_communication_error",
+ 753: "real_time_clock_in_emergency_mode",
+ 754: "internal_processor_status",
+ 755: "internal_processor_status",
+ 757: "real_time_clock_hardware_error",
+ 758: "real_time_clock_in_emergency_mode",
+ 760: "internal_hardware_error",
+ 761: "internal_processor_status",
+ 762: "internal_processor_status",
+ 763: "internal_processor_status",
+ 764: "internal_processor_status",
+ 765: "internal_processor_status",
+ 766: "emergency_power_derating_activated",
+ 767: "internal_processor_status",
+ 768: "different_power_limitation_in_hardware_modules",
+ 772: "storage_unit_not_available",
+ 773: "software_update_invalid_country_setup",
+ 775: "pmc_power_stage_set_not_available",
+ 776: "invalid_device_type",
+ 781: "internal_processor_status",
+ 782: "internal_processor_status",
+ 783: "internal_processor_status",
+ 784: "internal_processor_status",
+ 785: "internal_processor_status",
+ 786: "internal_processor_status",
+ 787: "internal_processor_status",
+ 788: "internal_processor_status",
+ 789: "internal_processor_status",
+ 790: "internal_processor_status",
+ 791: "internal_processor_status",
+ 792: "internal_processor_status",
+ 793: "internal_processor_status",
+ 794: "internal_processor_status",
+ 1001: "insulation_measurement_triggered",
+ 1024: "inverter_settings_changed_restart_required",
+ 1030: "wired_shut_down_triggered",
+ 1036: "grid_frequency_exceeded_limit_reconnecting",
+ 1112: "mains_voltage_dependent_power_reduction",
+ 1175: "too_little_dc_power_for_feed_in_operation",
+ 1196: "inverter_required_setup_values_not_received",
+ 65000: "dc_connection_inverter_battery_interrupted",
+}
class MeterLocationCodeOption(StrEnum):
diff --git a/homeassistant/components/fronius/coordinator.py b/homeassistant/components/fronius/coordinator.py
index c3dea123a77..d4f1fc6c230 100644
--- a/homeassistant/components/fronius/coordinator.py
+++ b/homeassistant/components/fronius/coordinator.py
@@ -13,6 +13,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import (
+ DOMAIN,
SOLAR_NET_ID_POWER_FLOW,
SOLAR_NET_ID_SYSTEM,
FroniusDeviceInfo,
@@ -67,7 +68,11 @@ class FroniusCoordinatorBase(
self._failed_update_count += 1
if self._failed_update_count == self.MAX_FAILED_UPDATES:
self.update_interval = self.error_interval
- raise UpdateFailed(err) from err
+ raise UpdateFailed(
+ translation_domain=DOMAIN,
+ translation_key="update_failed",
+ translation_placeholders={"fronius_error": str(err)},
+ ) from err
if self._failed_update_count != 0:
self._failed_update_count = 0
diff --git a/homeassistant/components/fronius/manifest.json b/homeassistant/components/fronius/manifest.json
index c2f635119aa..94d0f90b0bd 100644
--- a/homeassistant/components/fronius/manifest.json
+++ b/homeassistant/components/fronius/manifest.json
@@ -11,6 +11,6 @@
"documentation": "https://www.home-assistant.io/integrations/fronius",
"iot_class": "local_polling",
"loggers": ["pyfronius"],
- "quality_scale": "platinum",
+ "quality_scale": "gold",
"requirements": ["PyFronius==0.7.3"]
}
diff --git a/homeassistant/components/fronius/quality_scale.yaml b/homeassistant/components/fronius/quality_scale.yaml
new file mode 100644
index 00000000000..2c4b892475b
--- /dev/null
+++ b/homeassistant/components/fronius/quality_scale.yaml
@@ -0,0 +1,89 @@
+rules:
+ # Bronze
+ action-setup:
+ status: exempt
+ comment: |
+ This integration does not provide additional actions.
+ appropriate-polling: done
+ brands: done
+ common-modules:
+ status: done
+ comment: |
+ Single platform only, so no entity.py file.
+ CoordinatorEntity is used.
+ 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: |
+ This integration does not 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:
+ status: exempt
+ comment: |
+ This integration does not provide additional actions.
+ config-entry-unloading: done
+ docs-configuration-parameters:
+ status: exempt
+ comment: |
+ This integration does not provide configuration options.
+ docs-installation-parameters: done
+ entity-unavailable: done
+ integration-owner: done
+ log-when-unavailable: done
+ parallel-updates:
+ status: done
+ comment: |
+ Coordinators are used and asyncio.Lock mutex across them ensure proper
+ rate limiting. Platforms are read-only.
+ reauthentication-flow:
+ status: exempt
+ comment: |
+ This integration doesn't require authentication.
+ 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: done
+ 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: |
+ This integration doesn't have any known user-repairable issues.
+ stale-devices: done
+ # Platinum
+ async-dependency: done
+ inject-websession: done
+ strict-typing:
+ status: todo
+ comment: |
+ The pyfronius library isn't strictly typed and doesn't export type information.
diff --git a/homeassistant/components/fronius/sensor.py b/homeassistant/components/fronius/sensor.py
index c8a840b1c2c..03f666ffafd 100644
--- a/homeassistant/components/fronius/sensor.py
+++ b/homeassistant/components/fronius/sensor.py
@@ -33,6 +33,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import (
DOMAIN,
+ INVERTER_ERROR_CODES,
SOLAR_NET_DISCOVERY_NEW,
InverterStatusCodeOption,
MeterLocationCodeOption,
@@ -54,6 +55,9 @@ if TYPE_CHECKING:
FroniusStorageUpdateCoordinator,
)
+
+PARALLEL_UPDATES = 0
+
ENERGY_VOLT_AMPERE_REACTIVE_HOUR: Final = "varh"
@@ -202,6 +206,15 @@ INVERTER_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [
FroniusSensorEntityDescription(
key="error_code",
entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ ),
+ FroniusSensorEntityDescription(
+ key="error_message",
+ response_key="error_code",
+ entity_category=EntityCategory.DIAGNOSTIC,
+ device_class=SensorDeviceClass.ENUM,
+ options=list(dict.fromkeys(INVERTER_ERROR_CODES.values())),
+ value_fn=INVERTER_ERROR_CODES.get, # type: ignore[arg-type]
),
FroniusSensorEntityDescription(
key="status_code",
diff --git a/homeassistant/components/fronius/strings.json b/homeassistant/components/fronius/strings.json
index dfdcfc0ddb2..b77f6fec83c 100644
--- a/homeassistant/components/fronius/strings.json
+++ b/homeassistant/components/fronius/strings.json
@@ -3,10 +3,12 @@
"flow_title": "{device}",
"step": {
"user": {
- "title": "Fronius SolarNet",
- "description": "Configure the IP address or local hostname of your Fronius device.",
+ "description": "Configure your Fronius SolarAPI device.",
"data": {
"host": "[%key:common::config_flow::data::host%]"
+ },
+ "data_description": {
+ "host": "The IP address or hostname of your Fronius device."
}
},
"confirm_discovery": {
@@ -16,6 +18,9 @@
"description": "Update your configuration information for {device}.",
"data": {
"host": "[%key:common::config_flow::data::host%]"
+ },
+ "data_description": {
+ "host": "[%key:component::fronius::config::step::user::data_description::host%]"
}
}
},
@@ -41,9 +46,6 @@
"energy_total": {
"name": "Total energy"
},
- "frequency_ac": {
- "name": "[%key:component::sensor::entity_component::frequency::name%]"
- },
"current_ac": {
"name": "AC current"
},
@@ -71,6 +73,107 @@
"error_code": {
"name": "Error code"
},
+ "error_message": {
+ "name": "Error message",
+ "state": {
+ "no_error": "No error",
+ "ac_voltage_too_high": "AC voltage too high",
+ "ac_voltage_too_low": "AC voltage too low",
+ "ac_frequency_too_high": "AC frequency too high",
+ "ac_frequency_too_low": "AC frequency too low",
+ "ac_grid_outside_permissible_limits": "AC grid outside the permissible limits",
+ "stand_alone_operation_detected": "Stand alone operation detected",
+ "rcmu_error": "RCMU error",
+ "arc_detection_triggered": "Arc detection triggered",
+ "overcurrent_ac": "Overcurrent (AC)",
+ "overcurrent_dc": "Overcurrent (DC)",
+ "dc_module_over_temperature": "DC module over temperature",
+ "ac_module_over_temperature": "AC module over temperature",
+ "no_power_fed_in_despite_closed_relay": "No power being fed in, despite closed relay",
+ "pv_output_too_low_for_feeding_energy_into_the_grid": "PV output too low for feeding energy into the grid",
+ "low_pv_voltage_dc_input_voltage_too_low": "Low PV voltage - DC input voltage too low for feeding energy into the grid",
+ "intermediate_circuit_voltage_too_high": "Intermediate circuit voltage too high",
+ "dc_input_voltage_mppt_1_too_high": "DC input voltage MPPT 1 too high",
+ "polarity_of_dc_strings_reversed": "Polarity of DC strings reversed",
+ "dc_input_voltage_mppt_2_too_high": "DC input voltage MPPT 2 too high",
+ "current_sensor_calibration_timeout": "Current sensor calibration timeout",
+ "ac_current_sensor_error": "AC current sensor error",
+ "interrupt_check_fail": "Interrupt Check fail",
+ "overtemperature_in_connection_area": "Overtemperature in the connection area",
+ "fan_1_error": "Fan 1 error",
+ "fan_2_error": "Fan 2 error",
+ "no_communication_with_power_stage_set": "No communication with the power stage set possible",
+ "ac_module_temperature_sensor_faulty_l1": "AC module temperature sensor faulty (L1)",
+ "ac_module_temperature_sensor_faulty_l2": "AC module temperature sensor faulty (L2)",
+ "dc_component_measured_in_grid_too_high": "DC component measured in the grid too high",
+ "fixed_voltage_mode_out_of_range": "Fixed voltage mode has been selected instead of MPP voltage mode and the fixed voltage has been set to too low or too high a value",
+ "safety_cut_out_triggered": "Safety cut out via option card or RECERBO has triggered",
+ "no_communication_between_power_stage_and_control_system": "No communication possible between power stage set and control system",
+ "hardware_id_problem": "Hardware ID problem",
+ "unique_id_conflict": "Unique ID conflict",
+ "no_communication_with_hybrid_manager": "No communication possible with the Hybrid manager",
+ "hid_range_error": "HID range error",
+ "possible_hardware_fault": "Possible hardware fault",
+ "software_problem": "Software problem",
+ "functional_incompatibility_between_pc_boards": "Functional incompatibility (one or more PC boards in the inverter are not compatible with each other, e.g. after a PC board has been replaced)",
+ "power_stage_set_problem": "Power stage set problem",
+ "intermediate_circuit_voltage_too_low_or_asymmetric": "Intermediate circuit voltage too low or asymmetric",
+ "compatibility_error_invalid_power_stage_configuration": "Compatibility error (e.g. due to replacement of a PC board) - invalid power stage set configuration",
+ "insulation_fault": "Insulation fault",
+ "neutral_conductor_not_connected": "Neutral conductor not connected",
+ "guard_cannot_be_found": "Guard cannot be found",
+ "memory_error_detected": "Memory error detected",
+ "communication": "Communication error",
+ "insulation_error_on_solar_panels": "Insulation error on the solar panels",
+ "no_energy_fed_into_grid_past_24_hours": "No energy fed into the grid in the past 24 hours",
+ "no_communication_with_filter": "No communication with filter possible",
+ "no_communication_with_storage_unit": "No communication possible with the storage unit",
+ "power_derating_due_to_high_temperature": "Power derating caused by too high a temperature",
+ "internal_dsp_malfunction": "Internal DSP malfunction",
+ "no_energy_fed_by_mppt1_past_24_hours": "No energy fed into the grid by MPPT1 in the past 24 hours",
+ "dc_low_string_1": "DC low string 1",
+ "dc_low_string_2": "DC low string 2",
+ "derating_caused_by_over_frequency": "Derating caused by over-frequency",
+ "arc_detector_switched_off": "Arc detector switched off (e.g. during external arc monitoring)",
+ "grid_voltage_dependent_power_reduction_active": "Grid Voltage Dependent Power Reduction is active",
+ "can_bus_full": "CAN bus is full",
+ "ac_module_temperature_sensor_faulty_l3": "AC module temperature sensor faulty (L3)",
+ "dc_module_temperature_sensor_faulty": "DC module temperature sensor faulty",
+ "internal_processor_status": "Warning about the internal processor status. See status code for more information",
+ "eeprom_reinitialised": "EEPROM has been re-initialised",
+ "initialisation_error_usb_flash_drive_not_supported": "Initialisation error – USB flash drive is not supported",
+ "initialisation_error_usb_stick_over_current": "Initialisation error – Over current on USB stick",
+ "no_usb_flash_drive_connected": "No USB flash drive connected",
+ "update_file_not_recognised_or_missing": "Update file not recognised or not present",
+ "update_file_does_not_match_device": "Update file does not match the device, update file too old",
+ "write_or_read_error_occurred": "Write or read error occurred",
+ "file_could_not_be_opened": "File could not be opened",
+ "log_file_cannot_be_saved": "Log file cannot be saved (e.g. USB flash drive is write protected or full)",
+ "initialisation_error_file_system_error_on_usb": "Initialisation error in file system on USB flash drive",
+ "error_during_logging_data_recording": "Error during recording of logging data",
+ "error_during_update_process": "Error occurred during update process",
+ "update_file_corrupt": "Update file corrupt",
+ "time_lost": "Time lost",
+ "real_time_clock_communication_error": "Real Time Clock module communication error",
+ "real_time_clock_in_emergency_mode": "Internal error: Real Time Clock module is in emergency mode",
+ "real_time_clock_hardware_error": "Hardware error in the Real Time Clock module",
+ "internal_hardware_error": "Internal hardware error",
+ "emergency_power_derating_activated": "Emergency power derating activated",
+ "different_power_limitation_in_hardware_modules": "Different power limitation in the hardware modules",
+ "storage_unit_not_available": "Storage unit not available",
+ "software_update_invalid_country_setup": "Software update group 0 (invalid country setup)",
+ "pmc_power_stage_set_not_available": "PMC power stage set not available",
+ "invalid_device_type": "Invalid device type",
+ "insulation_measurement_triggered": "Insulation measurement triggered",
+ "inverter_settings_changed_restart_required": "Inverter settings have been changed, inverter restart required",
+ "wired_shut_down_triggered": "Wired shut down triggered",
+ "grid_frequency_exceeded_limit_reconnecting": "The grid frequency has exceeded a limit value when reconnecting",
+ "mains_voltage_dependent_power_reduction": "Mains voltage-dependent power reduction",
+ "too_little_dc_power_for_feed_in_operation": "Too little DC power for feed-in operation",
+ "inverter_required_setup_values_not_received": "Inverter required setup values could not be received",
+ "dc_connection_inverter_battery_interrupted": "DC connection between inverter and battery interrupted"
+ }
+ },
"status_code": {
"name": "Status code"
},
@@ -84,9 +187,7 @@
"error": "Error",
"idle": "Idle",
"ready": "Ready",
- "sleeping": "Sleeping",
- "unknown": "Unknown",
- "invalid": "Invalid"
+ "sleeping": "Sleeping"
}
},
"led_state": {
@@ -156,9 +257,6 @@
"power_apparent_phase_3": {
"name": "Apparent power phase 3"
},
- "power_apparent": {
- "name": "[%key:component::sensor::entity_component::apparent_power::name%]"
- },
"power_factor_phase_1": {
"name": "Power factor phase 1"
},
@@ -168,9 +266,6 @@
"power_factor_phase_3": {
"name": "Power factor phase 3"
},
- "power_factor": {
- "name": "[%key:component::sensor::entity_component::power_factor::name%]"
- },
"power_reactive_phase_1": {
"name": "Reactive power phase 1"
},
@@ -216,12 +311,6 @@
"energy_real_ac_consumed": {
"name": "Energy consumed"
},
- "power_real_ac": {
- "name": "[%key:component::sensor::entity_component::power::name%]"
- },
- "temperature_channel_1": {
- "name": "[%key:component::sensor::entity_component::temperature::name%]"
- },
"state_code": {
"name": "State code"
},
@@ -294,5 +383,13 @@
"name": "[%key:component::sensor::entity_component::temperature::name%]"
}
}
+ },
+ "exceptions": {
+ "entry_cannot_connect": {
+ "message": "Failed to connect to Fronius device at {host}: {fronius_error}"
+ },
+ "update_failed": {
+ "message": "An error occurred while attempting to fetch data: {fronius_error}"
+ }
}
}
diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json
index 4dc5a2b0ae4..267374aa302 100644
--- a/homeassistant/components/frontend/manifest.json
+++ b/homeassistant/components/frontend/manifest.json
@@ -1,6 +1,7 @@
{
"domain": "frontend",
"name": "Home Assistant Frontend",
+ "after_dependencies": ["backup"],
"codeowners": ["@home-assistant/frontend"],
"dependencies": [
"api",
@@ -20,5 +21,5 @@
"documentation": "https://www.home-assistant.io/integrations/frontend",
"integration_type": "system",
"quality_scale": "internal",
- "requirements": ["home-assistant-frontend==20241106.2"]
+ "requirements": ["home-assistant-frontend==20250106.0"]
}
diff --git a/homeassistant/components/frontier_silicon/strings.json b/homeassistant/components/frontier_silicon/strings.json
index 03d9f28c016..d3e1cd84e4a 100644
--- a/homeassistant/components/frontier_silicon/strings.json
+++ b/homeassistant/components/frontier_silicon/strings.json
@@ -12,7 +12,7 @@
},
"device_config": {
"title": "Device configuration",
- "description": "The pin can be found via 'MENU button > Main Menu > System setting > Network > NetRemote PIN setup'",
+ "description": "The PIN can be found via 'MENU button > Main Menu > System setting > Network > NetRemote PIN setup'",
"data": {
"pin": "[%key:common::config_flow::data::pin%]"
}
diff --git a/homeassistant/components/fujitsu_fglair/climate.py b/homeassistant/components/fujitsu_fglair/climate.py
index 726096eab1a..5359075c728 100644
--- a/homeassistant/components/fujitsu_fglair/climate.py
+++ b/homeassistant/components/fujitsu_fglair/climate.py
@@ -81,8 +81,6 @@ class FGLairDevice(CoordinatorEntity[FGLairCoordinator], ClimateEntity):
_attr_has_entity_name = True
_attr_name = None
- _enable_turn_on_off_backwards_compatibility: bool = False
-
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)
diff --git a/homeassistant/components/fujitsu_fglair/manifest.json b/homeassistant/components/fujitsu_fglair/manifest.json
index f7f3af8d037..ea08a2cfe02 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.3"]
+ "requirements": ["ayla-iot-unofficial==1.4.4"]
}
diff --git a/homeassistant/components/fully_kiosk/__init__.py b/homeassistant/components/fully_kiosk/__init__.py
index 99b477c2989..074ec3feaa0 100644
--- a/homeassistant/components/fully_kiosk/__init__.py
+++ b/homeassistant/components/fully_kiosk/__init__.py
@@ -10,6 +10,8 @@ from .const import DOMAIN
from .coordinator import FullyKioskDataUpdateCoordinator
from .services import async_setup_services
+type FullyKioskConfigEntry = ConfigEntry[FullyKioskDataUpdateCoordinator]
+
PLATFORMS = [
Platform.BINARY_SENSOR,
Platform.BUTTON,
@@ -33,13 +35,13 @@ 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: FullyKioskConfigEntry) -> bool:
"""Set up Fully Kiosk Browser from a config entry."""
coordinator = FullyKioskDataUpdateCoordinator(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)
coordinator.async_update_listeners()
@@ -47,10 +49,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: FullyKioskConfigEntry) -> 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/fully_kiosk/binary_sensor.py b/homeassistant/components/fully_kiosk/binary_sensor.py
index 3cf9adea1d5..c039baa0397 100644
--- a/homeassistant/components/fully_kiosk/binary_sensor.py
+++ b/homeassistant/components/fully_kiosk/binary_sensor.py
@@ -7,12 +7,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 . import FullyKioskConfigEntry
from .coordinator import FullyKioskDataUpdateCoordinator
from .entity import FullyKioskEntity
@@ -38,13 +37,11 @@ SENSORS: tuple[BinarySensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ config_entry: FullyKioskConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Fully Kiosk Browser sensor."""
- coordinator: FullyKioskDataUpdateCoordinator = hass.data[DOMAIN][
- config_entry.entry_id
- ]
+ coordinator = config_entry.runtime_data
async_add_entities(
FullyBinarySensor(coordinator, description)
diff --git a/homeassistant/components/fully_kiosk/button.py b/homeassistant/components/fully_kiosk/button.py
index 94c34b50de1..4b172d45ae2 100644
--- a/homeassistant/components/fully_kiosk/button.py
+++ b/homeassistant/components/fully_kiosk/button.py
@@ -13,12 +13,11 @@ 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 DOMAIN
+from . import FullyKioskConfigEntry
from .coordinator import FullyKioskDataUpdateCoordinator
from .entity import FullyKioskEntity
@@ -68,13 +67,11 @@ BUTTONS: tuple[FullyButtonEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ config_entry: FullyKioskConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Fully Kiosk Browser button entities."""
- coordinator: FullyKioskDataUpdateCoordinator = hass.data[DOMAIN][
- config_entry.entry_id
- ]
+ coordinator = config_entry.runtime_data
async_add_entities(
FullyButtonEntity(coordinator, description) for description in BUTTONS
diff --git a/homeassistant/components/fully_kiosk/camera.py b/homeassistant/components/fully_kiosk/camera.py
index d55875e094f..7dfbe9e9257 100644
--- a/homeassistant/components/fully_kiosk/camera.py
+++ b/homeassistant/components/fully_kiosk/camera.py
@@ -5,21 +5,22 @@ from __future__ import annotations
from fullykiosk import FullyKioskError
from homeassistant.components.camera import Camera, CameraEntityFeature
-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 DOMAIN
+from . import FullyKioskConfigEntry
from .coordinator import FullyKioskDataUpdateCoordinator
from .entity import FullyKioskEntity
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: FullyKioskConfigEntry,
+ async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the cameras."""
- coordinator: FullyKioskDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
+ coordinator = entry.runtime_data
async_add_entities([FullyCameraEntity(coordinator)])
diff --git a/homeassistant/components/fully_kiosk/diagnostics.py b/homeassistant/components/fully_kiosk/diagnostics.py
index 0ff567b0b46..c8364c77753 100644
--- a/homeassistant/components/fully_kiosk/diagnostics.py
+++ b/homeassistant/components/fully_kiosk/diagnostics.py
@@ -5,11 +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.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
-from .const import DOMAIN
+from . import FullyKioskConfigEntry
DEVICE_INFO_TO_REDACT = {
"serial",
@@ -57,10 +56,10 @@ SETTINGS_TO_REDACT = {
async def async_get_device_diagnostics(
- hass: HomeAssistant, entry: ConfigEntry, device: dr.DeviceEntry
+ hass: HomeAssistant, entry: FullyKioskConfigEntry, device: dr.DeviceEntry
) -> dict[str, Any]:
"""Return device diagnostics."""
- coordinator = hass.data[DOMAIN][entry.entry_id]
+ coordinator = entry.runtime_data
data = coordinator.data
data["settings"] = async_redact_data(data["settings"], SETTINGS_TO_REDACT)
return async_redact_data(data, DEVICE_INFO_TO_REDACT)
diff --git a/homeassistant/components/fully_kiosk/image.py b/homeassistant/components/fully_kiosk/image.py
index fbf3481e38b..00318a77ab5 100644
--- a/homeassistant/components/fully_kiosk/image.py
+++ b/homeassistant/components/fully_kiosk/image.py
@@ -9,13 +9,12 @@ from typing import Any
from fullykiosk import FullyKiosk, FullyKioskError
from homeassistant.components.image import ImageEntity, ImageEntityDescription
-from homeassistant.config_entries import ConfigEntry
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 .const import DOMAIN
+from . import FullyKioskConfigEntry
from .coordinator import FullyKioskDataUpdateCoordinator
from .entity import FullyKioskEntity
@@ -37,10 +36,12 @@ IMAGES: tuple[FullyImageEntityDescription, ...] = (
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: FullyKioskConfigEntry,
+ async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Fully Kiosk Browser image entities."""
- coordinator: FullyKioskDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
+ coordinator = entry.runtime_data
async_add_entities(
FullyImageEntity(coordinator, description) for description in IMAGES
)
diff --git a/homeassistant/components/fully_kiosk/manifest.json b/homeassistant/components/fully_kiosk/manifest.json
index 4d7d1a2d7da..1fbbb6656a2 100644
--- a/homeassistant/components/fully_kiosk/manifest.json
+++ b/homeassistant/components/fully_kiosk/manifest.json
@@ -12,5 +12,6 @@
"documentation": "https://www.home-assistant.io/integrations/fully_kiosk",
"iot_class": "local_polling",
"mqtt": ["fully/deviceInfo/+"],
+ "quality_scale": "bronze",
"requirements": ["python-fullykiosk==0.0.14"]
}
diff --git a/homeassistant/components/fully_kiosk/media_player.py b/homeassistant/components/fully_kiosk/media_player.py
index ae61a39bb81..24f002a7544 100644
--- a/homeassistant/components/fully_kiosk/media_player.py
+++ b/homeassistant/components/fully_kiosk/media_player.py
@@ -12,23 +12,23 @@ from homeassistant.components.media_player import (
MediaType,
async_process_play_media_url,
)
-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 AUDIOMANAGER_STREAM_MUSIC, DOMAIN, MEDIA_SUPPORT_FULLYKIOSK
+from . import FullyKioskConfigEntry
+from .const import AUDIOMANAGER_STREAM_MUSIC, MEDIA_SUPPORT_FULLYKIOSK
from .coordinator import FullyKioskDataUpdateCoordinator
from .entity import FullyKioskEntity
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ config_entry: FullyKioskConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Fully Kiosk Browser media player entity."""
- coordinator = hass.data[DOMAIN][config_entry.entry_id]
+ coordinator = config_entry.runtime_data
async_add_entities([FullyMediaPlayer(coordinator)])
diff --git a/homeassistant/components/fully_kiosk/notify.py b/homeassistant/components/fully_kiosk/notify.py
index aa47c178f03..bddc07439b3 100644
--- a/homeassistant/components/fully_kiosk/notify.py
+++ b/homeassistant/components/fully_kiosk/notify.py
@@ -7,12 +7,11 @@ from dataclasses import dataclass
from fullykiosk import FullyKioskError
from homeassistant.components.notify import NotifyEntity, NotifyEntityDescription
-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 DOMAIN
+from . import FullyKioskConfigEntry
from .coordinator import FullyKioskDataUpdateCoordinator
from .entity import FullyKioskEntity
@@ -39,10 +38,12 @@ NOTIFIERS: tuple[FullyNotifyEntityDescription, ...] = (
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: FullyKioskConfigEntry,
+ async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Fully Kiosk Browser notify entities."""
- coordinator: FullyKioskDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
+ coordinator = entry.runtime_data
async_add_entities(
FullyNotifyEntity(coordinator, description) for description in NOTIFIERS
)
diff --git a/homeassistant/components/fully_kiosk/number.py b/homeassistant/components/fully_kiosk/number.py
index 59c249fd1c2..ef25a69f1ee 100644
--- a/homeassistant/components/fully_kiosk/number.py
+++ b/homeassistant/components/fully_kiosk/number.py
@@ -5,12 +5,11 @@ from __future__ import annotations
from contextlib import suppress
from homeassistant.components.number import NumberEntity, NumberEntityDescription
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory, UnitOfTime
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from .const import DOMAIN
+from . import FullyKioskConfigEntry
from .coordinator import FullyKioskDataUpdateCoordinator
from .entity import FullyKioskEntity
@@ -54,11 +53,11 @@ ENTITY_TYPES: tuple[NumberEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ config_entry: FullyKioskConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Fully Kiosk Browser number entities."""
- coordinator = hass.data[DOMAIN][config_entry.entry_id]
+ coordinator = config_entry.runtime_data
async_add_entities(
FullyNumberEntity(coordinator, entity)
diff --git a/homeassistant/components/fully_kiosk/quality_scale.yaml b/homeassistant/components/fully_kiosk/quality_scale.yaml
new file mode 100644
index 00000000000..68fa7b9c3f9
--- /dev/null
+++ b/homeassistant/components/fully_kiosk/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: done
+ dependency-transparency: done
+ action-setup: done
+ 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: todo
+ reauthentication-flow: todo
+ parallel-updates: todo
+ test-coverage: done
+ integration-owner: done
+ docs-installation-parameters: done
+ docs-configuration-parameters:
+ status: exempt
+ comment: This integration does not utilize an options flow.
+
+ # Gold
+ entity-translations: todo
+ entity-device-class: done
+ devices: done
+ entity-category: done
+ entity-disabled-by-default: done
+ discovery: done
+ stale-devices:
+ status: exempt
+ comment: Each config entry maps to a single device
+ diagnostics: done
+ exception-translations: todo
+ icon-translations: done
+ reconfiguration-flow: todo
+ dynamic-devices:
+ status: exempt
+ comment: Each config entry maps to a single device
+ discovery-update-info: done
+ repair-issues: todo
+ docs-use-cases: todo
+ docs-supported-devices: todo
+ docs-supported-functions: done
+ docs-data-update: todo
+ docs-known-limitations: done
+ docs-troubleshooting: todo
+ docs-examples: done
+
+ # Platinum
+ async-dependency: todo
+ inject-websession: done
+ strict-typing: todo
diff --git a/homeassistant/components/fully_kiosk/sensor.py b/homeassistant/components/fully_kiosk/sensor.py
index 48fc8e51425..ed95323547f 100644
--- a/homeassistant/components/fully_kiosk/sensor.py
+++ b/homeassistant/components/fully_kiosk/sensor.py
@@ -12,13 +12,12 @@ from homeassistant.components.sensor import (
SensorEntityDescription,
SensorStateClass,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfInformation
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import StateType
-from .const import DOMAIN
+from . import FullyKioskConfigEntry
from .coordinator import FullyKioskDataUpdateCoordinator
from .entity import FullyKioskEntity
@@ -114,13 +113,11 @@ SENSORS: tuple[FullySensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ config_entry: FullyKioskConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Fully Kiosk Browser sensor."""
- coordinator: FullyKioskDataUpdateCoordinator = hass.data[DOMAIN][
- config_entry.entry_id
- ]
+ coordinator = config_entry.runtime_data
async_add_entities(
FullySensor(coordinator, description)
for description in SENSORS
diff --git a/homeassistant/components/fully_kiosk/services.py b/homeassistant/components/fully_kiosk/services.py
index b9369198940..089ae1d4246 100644
--- a/homeassistant/components/fully_kiosk/services.py
+++ b/homeassistant/components/fully_kiosk/services.py
@@ -53,7 +53,7 @@ async def async_setup_services(hass: HomeAssistant) -> None:
for config_entry in config_entries:
if config_entry.state != ConfigEntryState.LOADED:
raise HomeAssistantError(f"{config_entry.title} is not loaded")
- coordinators.append(hass.data[DOMAIN][config_entry.entry_id])
+ coordinators.append(config_entry.runtime_data)
return coordinators
async def async_load_url(call: ServiceCall) -> None:
diff --git a/homeassistant/components/fully_kiosk/strings.json b/homeassistant/components/fully_kiosk/strings.json
index 9c0049d3e5f..a4b466926f0 100644
--- a/homeassistant/components/fully_kiosk/strings.json
+++ b/homeassistant/components/fully_kiosk/strings.json
@@ -1,10 +1,22 @@
{
+ "common": {
+ "data_description_password": "The Remote Admin Password from the Fully Kiosk Browser app settings.",
+ "data_description_ssl": "Is the Fully Kiosk app configured to require SSL for the connection?",
+ "data_description_verify_ssl": "Should SSL certificartes be verified? This should be off for self-signed certificates."
+ },
"config": {
"step": {
"discovery_confirm": {
"description": "Do you want to set up {name} ({host})?",
"data": {
- "password": "[%key:common::config_flow::data::password%]"
+ "password": "[%key:common::config_flow::data::password%]",
+ "ssl": "[%key:common::config_flow::data::ssl%]",
+ "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
+ },
+ "data_description": {
+ "password": "[%key:component::fully_kiosk::common::data_description_password%]",
+ "ssl": "[%key:component::fully_kiosk::common::data_description_ssl%]",
+ "verify_ssl": "[%key:component::fully_kiosk::common::data_description_verify_ssl%]"
}
},
"user": {
@@ -15,7 +27,10 @@
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
},
"data_description": {
- "host": "The hostname or IP address of the device running your Fully Kiosk Browser application."
+ "host": "The hostname or IP address of the device running your Fully Kiosk Browser application.",
+ "password": "[%key:component::fully_kiosk::common::data_description_password%]",
+ "ssl": "[%key:component::fully_kiosk::common::data_description_ssl%]",
+ "verify_ssl": "[%key:component::fully_kiosk::common::data_description_verify_ssl%]"
}
}
},
diff --git a/homeassistant/components/fully_kiosk/switch.py b/homeassistant/components/fully_kiosk/switch.py
index 9d5af87abe9..4adf8e8c924 100644
--- a/homeassistant/components/fully_kiosk/switch.py
+++ b/homeassistant/components/fully_kiosk/switch.py
@@ -9,12 +9,11 @@ from typing import Any
from fullykiosk import FullyKiosk
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from .const import DOMAIN
+from . import FullyKioskConfigEntry
from .coordinator import FullyKioskDataUpdateCoordinator
from .entity import FullyKioskEntity
@@ -84,13 +83,11 @@ SWITCHES: tuple[FullySwitchEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ config_entry: FullyKioskConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Fully Kiosk Browser switch."""
- coordinator: FullyKioskDataUpdateCoordinator = hass.data[DOMAIN][
- config_entry.entry_id
- ]
+ coordinator = config_entry.runtime_data
async_add_entities(
FullySwitchEntity(coordinator, description) for description in SWITCHES
diff --git a/homeassistant/components/futurenow/manifest.json b/homeassistant/components/futurenow/manifest.json
index dbe1b2d06fb..32a8761b1db 100644
--- a/homeassistant/components/futurenow/manifest.json
+++ b/homeassistant/components/futurenow/manifest.json
@@ -4,5 +4,6 @@
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/futurenow",
"iot_class": "local_polling",
+ "quality_scale": "legacy",
"requirements": ["pyfnip==0.2"]
}
diff --git a/homeassistant/components/fyta/__init__.py b/homeassistant/components/fyta/__init__.py
index efbb1453456..77724e3f673 100644
--- a/homeassistant/components/fyta/__init__.py
+++ b/homeassistant/components/fyta/__init__.py
@@ -15,6 +15,7 @@ from homeassistant.const import (
Platform,
)
from homeassistant.core import HomeAssistant
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.util.dt import async_get_time_zone
from .const import CONF_EXPIRATION
@@ -23,6 +24,7 @@ from .coordinator import FytaCoordinator
_LOGGER = logging.getLogger(__name__)
PLATFORMS = [
+ Platform.BINARY_SENSOR,
Platform.SENSOR,
]
type FytaConfigEntry = ConfigEntry[FytaCoordinator]
@@ -39,7 +41,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: FytaConfigEntry) -> bool
entry.data[CONF_EXPIRATION]
).astimezone(await async_get_time_zone(tz))
- fyta = FytaConnector(username, password, access_token, expiration, tz)
+ fyta = FytaConnector(
+ username, password, access_token, expiration, tz, async_get_clientsession(hass)
+ )
coordinator = FytaCoordinator(hass, fyta)
@@ -52,13 +56,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: FytaConfigEntry) -> bool
return True
-async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_unload_entry(hass: HomeAssistant, entry: FytaConfigEntry) -> bool:
"""Unload Fyta entity."""
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: FytaConfigEntry
+) -> bool:
"""Migrate old entry."""
_LOGGER.debug("Migrating from version %s", config_entry.version)
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 c4aa9bfe589..553960bdcc6 100644
--- a/homeassistant/components/fyta/coordinator.py
+++ b/homeassistant/components/fyta/coordinator.py
@@ -61,7 +61,9 @@ class FytaCoordinator(DataUpdateCoordinator[dict[int, Plant]]):
try:
data = await self.fyta.update_all_plants()
except (FytaConnectionError, FytaPlantError) as err:
- raise UpdateFailed(err) from err
+ raise UpdateFailed(
+ translation_domain=DOMAIN, translation_key="update_error"
+ ) from err
_LOGGER.debug("Data successfully updated")
# data must be assigned before _async_add_remove_devices, as it is uses to set-up possible new devices
@@ -122,9 +124,14 @@ class FytaCoordinator(DataUpdateCoordinator[dict[int, Plant]]):
try:
credentials = await self.fyta.login()
except FytaConnectionError as ex:
- raise ConfigEntryNotReady from ex
+ raise ConfigEntryNotReady(
+ translation_domain=DOMAIN, translation_key="config_entry_not_ready"
+ ) from ex
except (FytaAuthentificationError, FytaPasswordError) as ex:
- raise ConfigEntryAuthFailed from ex
+ raise ConfigEntryAuthFailed(
+ translation_domain=DOMAIN,
+ translation_key="auth_failed",
+ ) from ex
new_config_entry = {**self.config_entry.data}
new_config_entry[CONF_ACCESS_TOKEN] = credentials.access_token
diff --git a/homeassistant/components/fyta/entity.py b/homeassistant/components/fyta/entity.py
index 18c52d74e25..0d0ec533c44 100644
--- a/homeassistant/components/fyta/entity.py
+++ b/homeassistant/components/fyta/entity.py
@@ -2,11 +2,11 @@
from fyta_cli.fyta_models import Plant
-from homeassistant.components.sensor import SensorEntityDescription
-from homeassistant.config_entries import ConfigEntry
from homeassistant.helpers.device_registry import DeviceInfo
+from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity
+from . import FytaConfigEntry
from .const import DOMAIN
from .coordinator import FytaCoordinator
@@ -19,8 +19,8 @@ class FytaPlantEntity(CoordinatorEntity[FytaCoordinator]):
def __init__(
self,
coordinator: FytaCoordinator,
- entry: ConfigEntry,
- description: SensorEntityDescription,
+ entry: FytaConfigEntry,
+ 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/manifest.json b/homeassistant/components/fyta/manifest.json
index 17fe5199eee..ea628f55c6c 100644
--- a/homeassistant/components/fyta/manifest.json
+++ b/homeassistant/components/fyta/manifest.json
@@ -3,10 +3,11 @@
"name": "FYTA",
"codeowners": ["@dontinelli"],
"config_flow": true,
+ "dhcp": [{ "hostname": "fyta*" }],
"documentation": "https://www.home-assistant.io/integrations/fyta",
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["fyta_cli"],
"quality_scale": "platinum",
- "requirements": ["fyta_cli==0.6.10"]
+ "requirements": ["fyta_cli==0.7.0"]
}
diff --git a/homeassistant/components/fyta/quality_scale.yaml b/homeassistant/components/fyta/quality_scale.yaml
new file mode 100644
index 00000000000..0fbacd0e12e
--- /dev/null
+++ b/homeassistant/components/fyta/quality_scale.yaml
@@ -0,0 +1,90 @@
+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: done
+ dependency-transparency: done
+ action-setup:
+ status: exempt
+ comment: No custom action.
+ common-modules: done
+ docs-high-level-description: done
+ docs-installation-instructions: done
+ docs-removal-instructions: done
+ docs-actions:
+ status: exempt
+ comment: No custom action.
+ brands: done
+
+ # Silver
+ config-entry-unloading: done
+ log-when-unavailable: done
+ entity-unavailable: done
+ action-exceptions:
+ status: exempt
+ comment: No custom action.
+ reauthentication-flow: done
+ parallel-updates:
+ status: exempt
+ comment: |
+ Coordinator and only sensor platform.
+
+ test-coverage: done
+ integration-owner: done
+ docs-installation-parameters: done
+ docs-configuration-parameters:
+ status: exempt
+ comment: |
+ No options flow.
+
+ # Gold
+ entity-translations: done
+ entity-device-class: done
+ devices: done
+ entity-category: done
+ entity-disabled-by-default:
+ status: exempt
+ comment: No noisy entities.
+ discovery:
+ status: done
+ comment: DHCP
+ stale-devices: done
+ diagnostics: done
+ exception-translations: done
+ icon-translations: done
+ reconfiguration-flow:
+ status: exempt
+ comment: No configuration besides credentials.
+ dynamic-devices: done
+ discovery-update-info:
+ status: exempt
+ comment: Fyta can be discovered but does not have a local connection.
+ repair-issues:
+ status: exempt
+ comment: |
+ No issues/repairs.
+ docs-use-cases: done
+ docs-supported-devices: done
+ docs-supported-functions: done
+ docs-data-update: done
+ docs-known-limitations: done
+ docs-troubleshooting:
+ status: exempt
+ comment: |
+ No known issues that could be resolved by the user.
+ docs-examples:
+ status: exempt
+ comment: |
+ As only sensors are provided, no examples deemed necessary/appropriate.
+
+ # Platinum
+ async-dependency: done
+ inject-websession: done
+ strict-typing: done
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 bacd24555b0..1a25f654e19 100644
--- a/homeassistant/components/fyta/strings.json
+++ b/homeassistant/components/fyta/strings.json
@@ -3,10 +3,14 @@
"step": {
"user": {
"title": "Credentials for FYTA API",
- "description": "Provide username and password to connect to the FYTA server",
+ "description": "Provide email and password to connect to the FYTA server",
"data": {
- "username": "[%key:common::config_flow::data::username%]",
+ "username": "[%key:common::config_flow::data::email%]",
"password": "[%key:common::config_flow::data::password%]"
+ },
+ "data_description": {
+ "username": "The email address to login to your FYTA account.",
+ "password": "The password to login to your FYTA account."
}
},
"reauth_confirm": {
@@ -14,11 +18,16 @@
"data": {
"username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]"
+ },
+ "data_description": {
+ "username": "[%key:component::fyta::config::step::user::data_description::username%]",
+ "password": "[%key:component::fyta::config::step::user::data_description::password%]"
}
}
},
"abort": {
- "already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
+ "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%]"
},
"error": {
@@ -29,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"
@@ -75,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": {
@@ -91,7 +134,24 @@
},
"salinity": {
"name": "Salinity"
+ },
+ "last_fertilised": {
+ "name": "Last fertilized"
+ },
+ "next_fertilisation": {
+ "name": "Next fertilization"
}
}
+ },
+ "exceptions": {
+ "update_error": {
+ "message": "Error while updating data from the API."
+ },
+ "config_entry_not_ready": {
+ "message": "Error while loading the config entry."
+ },
+ "auth_failed": {
+ "message": "Error while logging in to the API."
+ }
}
}
diff --git a/homeassistant/components/garadget/manifest.json b/homeassistant/components/garadget/manifest.json
index c7a30a465d2..bd1920a7c4c 100644
--- a/homeassistant/components/garadget/manifest.json
+++ b/homeassistant/components/garadget/manifest.json
@@ -3,5 +3,6 @@
"name": "Garadget",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/garadget",
- "iot_class": "cloud_polling"
+ "iot_class": "cloud_polling",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/garages_amsterdam/__init__.py b/homeassistant/components/garages_amsterdam/__init__.py
index 81ec72d9fbf..99d751cfcc8 100644
--- a/homeassistant/components/garages_amsterdam/__init__.py
+++ b/homeassistant/components/garages_amsterdam/__init__.py
@@ -1,62 +1,38 @@
"""The Garages Amsterdam integration."""
-import asyncio
-from datetime import timedelta
-import logging
+from __future__ import annotations
-from odp_amsterdam import ODPAmsterdam, VehicleType
+from odp_amsterdam import ODPAmsterdam
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
-from homeassistant.helpers import aiohttp_client
-from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
-from .const import DOMAIN
+from .coordinator import GaragesAmsterdamDataUpdateCoordinator
-PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR]
+PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR]
+
+type GaragesAmsterdamConfigEntry = ConfigEntry[GaragesAmsterdamDataUpdateCoordinator]
-async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_setup_entry(
+ hass: HomeAssistant, entry: GaragesAmsterdamConfigEntry
+) -> bool:
"""Set up Garages Amsterdam from a config entry."""
- await get_coordinator(hass)
+ client = ODPAmsterdam(session=async_get_clientsession(hass))
+ coordinator = GaragesAmsterdamDataUpdateCoordinator(hass, 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: GaragesAmsterdamConfigEntry
+) -> bool:
"""Unload Garages Amsterdam config entry."""
- unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
- if len(hass.config_entries.async_entries(DOMAIN)) == 1:
- hass.data.pop(DOMAIN)
-
- return unload_ok
-
-
-async def get_coordinator(
- hass: HomeAssistant,
-) -> DataUpdateCoordinator:
- """Get the data update coordinator."""
- if DOMAIN in hass.data:
- return hass.data[DOMAIN]
-
- async def async_get_garages():
- async with asyncio.timeout(10):
- return {
- garage.garage_name: garage
- for garage in await ODPAmsterdam(
- session=aiohttp_client.async_get_clientsession(hass)
- ).all_garages(vehicle=VehicleType.CAR)
- }
-
- coordinator = DataUpdateCoordinator(
- hass,
- logging.getLogger(__name__),
- name=DOMAIN,
- update_method=async_get_garages,
- update_interval=timedelta(minutes=10),
- )
- await coordinator.async_config_entry_first_refresh()
-
- hass.data[DOMAIN] = coordinator
- return coordinator
+ return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
diff --git a/homeassistant/components/garages_amsterdam/binary_sensor.py b/homeassistant/components/garages_amsterdam/binary_sensor.py
index 0aebe36baeb..b93b43e1173 100644
--- a/homeassistant/components/garages_amsterdam/binary_sensor.py
+++ b/homeassistant/components/garages_amsterdam/binary_sensor.py
@@ -2,47 +2,77 @@
from __future__ import annotations
+from collections.abc import Callable
+from dataclasses import dataclass
+
+from odp_amsterdam import Garage
+
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 . import get_coordinator
+from . import GaragesAmsterdamConfigEntry
+from .coordinator import GaragesAmsterdamDataUpdateCoordinator
from .entity import GaragesAmsterdamEntity
-BINARY_SENSORS = {
- "state",
-}
+
+@dataclass(frozen=True, kw_only=True)
+class GaragesAmsterdamBinarySensorEntityDescription(BinarySensorEntityDescription):
+ """Class describing Garages Amsterdam binary sensor entity."""
+
+ is_on: Callable[[Garage], bool]
+
+
+BINARY_SENSORS: tuple[GaragesAmsterdamBinarySensorEntityDescription, ...] = (
+ GaragesAmsterdamBinarySensorEntityDescription(
+ key="state",
+ translation_key="state",
+ device_class=BinarySensorDeviceClass.PROBLEM,
+ is_on=lambda garage: garage.state != "ok",
+ ),
+)
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ entry: GaragesAmsterdamConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Defer sensor setup to the shared sensor module."""
- coordinator = await get_coordinator(hass)
+ coordinator = entry.runtime_data
async_add_entities(
GaragesAmsterdamBinarySensor(
- coordinator, config_entry.data["garage_name"], info_type
+ coordinator=coordinator,
+ garage_name=entry.data["garage_name"],
+ description=description,
)
- for info_type in BINARY_SENSORS
+ for description in BINARY_SENSORS
)
class GaragesAmsterdamBinarySensor(GaragesAmsterdamEntity, BinarySensorEntity):
"""Binary Sensor representing garages amsterdam data."""
- _attr_device_class = BinarySensorDeviceClass.PROBLEM
- _attr_name = None
+ entity_description: GaragesAmsterdamBinarySensorEntityDescription
+
+ def __init__(
+ self,
+ *,
+ coordinator: GaragesAmsterdamDataUpdateCoordinator,
+ garage_name: str,
+ description: GaragesAmsterdamBinarySensorEntityDescription,
+ ) -> None:
+ """Initialize garages amsterdam binary sensor."""
+ super().__init__(coordinator, garage_name)
+ self.entity_description = description
+ self._attr_unique_id = f"{garage_name}-{description.key}"
@property
def is_on(self) -> bool:
"""If the binary sensor is currently on or off."""
- return (
- getattr(self.coordinator.data[self._garage_name], self._info_type) != "ok"
- )
+ return self.entity_description.is_on(self.coordinator.data[self._garage_name])
diff --git a/homeassistant/components/garages_amsterdam/const.py b/homeassistant/components/garages_amsterdam/const.py
index ae7801a9abd..be5e2216a81 100644
--- a/homeassistant/components/garages_amsterdam/const.py
+++ b/homeassistant/components/garages_amsterdam/const.py
@@ -1,4 +1,13 @@
"""Constants for the Garages Amsterdam integration."""
-DOMAIN = "garages_amsterdam"
-ATTRIBUTION = f'{"Data provided by municipality of Amsterdam"}'
+from __future__ import annotations
+
+from datetime import timedelta
+import logging
+from typing import Final
+
+DOMAIN: Final = "garages_amsterdam"
+ATTRIBUTION = "Data provided by municipality of Amsterdam"
+
+LOGGER = logging.getLogger(__package__)
+SCAN_INTERVAL = timedelta(minutes=10)
diff --git a/homeassistant/components/garages_amsterdam/coordinator.py b/homeassistant/components/garages_amsterdam/coordinator.py
new file mode 100644
index 00000000000..3d06aba79e2
--- /dev/null
+++ b/homeassistant/components/garages_amsterdam/coordinator.py
@@ -0,0 +1,34 @@
+"""Coordinator for the Garages Amsterdam integration."""
+
+from __future__ import annotations
+
+from odp_amsterdam import Garage, ODPAmsterdam, VehicleType
+
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
+
+from .const import DOMAIN, LOGGER, SCAN_INTERVAL
+
+
+class GaragesAmsterdamDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Garage]]):
+ """Class to manage fetching Garages Amsterdam data from single endpoint."""
+
+ def __init__(
+ self,
+ hass: HomeAssistant,
+ client: ODPAmsterdam,
+ ) -> None:
+ """Initialize global Garages Amsterdam data updater."""
+ super().__init__(
+ hass,
+ LOGGER,
+ name=DOMAIN,
+ update_interval=SCAN_INTERVAL,
+ )
+ self.client = client
+
+ async def _async_update_data(self) -> dict[str, Garage]:
+ return {
+ garage.garage_name: garage
+ for garage in await self.client.all_garages(vehicle=VehicleType.CAR)
+ }
diff --git a/homeassistant/components/garages_amsterdam/entity.py b/homeassistant/components/garages_amsterdam/entity.py
index 671405235d4..433bc75b962 100644
--- a/homeassistant/components/garages_amsterdam/entity.py
+++ b/homeassistant/components/garages_amsterdam/entity.py
@@ -3,28 +3,26 @@
from __future__ import annotations
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
-from homeassistant.helpers.update_coordinator import (
- CoordinatorEntity,
- DataUpdateCoordinator,
-)
+from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import ATTRIBUTION, DOMAIN
+from .coordinator import GaragesAmsterdamDataUpdateCoordinator
-class GaragesAmsterdamEntity(CoordinatorEntity):
+class GaragesAmsterdamEntity(CoordinatorEntity[GaragesAmsterdamDataUpdateCoordinator]):
"""Base Entity for garages amsterdam data."""
_attr_attribution = ATTRIBUTION
_attr_has_entity_name = True
def __init__(
- self, coordinator: DataUpdateCoordinator, garage_name: str, info_type: str
+ self,
+ coordinator: GaragesAmsterdamDataUpdateCoordinator,
+ garage_name: str,
) -> None:
"""Initialize garages amsterdam entity."""
super().__init__(coordinator)
- self._attr_unique_id = f"{garage_name}-{info_type}"
self._garage_name = garage_name
- self._info_type = info_type
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, garage_name)},
name=garage_name,
diff --git a/homeassistant/components/garages_amsterdam/sensor.py b/homeassistant/components/garages_amsterdam/sensor.py
index b6fc950a843..b562fff841a 100644
--- a/homeassistant/components/garages_amsterdam/sensor.py
+++ b/homeassistant/components/garages_amsterdam/sensor.py
@@ -2,49 +2,93 @@
from __future__ import annotations
-from homeassistant.components.sensor import SensorEntity
-from homeassistant.config_entries import ConfigEntry
+from collections.abc import Callable
+from dataclasses import dataclass
+
+from odp_amsterdam import Garage
+
+from homeassistant.components.sensor import (
+ SensorEntity,
+ SensorEntityDescription,
+ SensorStateClass,
+)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
+from homeassistant.helpers.typing import StateType
-from . import get_coordinator
+from . import GaragesAmsterdamConfigEntry
+from .coordinator import GaragesAmsterdamDataUpdateCoordinator
from .entity import GaragesAmsterdamEntity
-SENSORS = {
- "free_space_short",
- "free_space_long",
- "short_capacity",
- "long_capacity",
-}
+
+@dataclass(frozen=True, kw_only=True)
+class GaragesAmsterdamSensorEntityDescription(SensorEntityDescription):
+ """Class describing Garages Amsterdam sensor entity."""
+
+ value_fn: Callable[[Garage], StateType]
+
+
+SENSORS: tuple[GaragesAmsterdamSensorEntityDescription, ...] = (
+ GaragesAmsterdamSensorEntityDescription(
+ key="free_space_short",
+ translation_key="free_space_short",
+ state_class=SensorStateClass.MEASUREMENT,
+ value_fn=lambda garage: garage.free_space_short,
+ ),
+ GaragesAmsterdamSensorEntityDescription(
+ key="free_space_long",
+ translation_key="free_space_long",
+ state_class=SensorStateClass.MEASUREMENT,
+ value_fn=lambda garage: garage.free_space_long,
+ ),
+ GaragesAmsterdamSensorEntityDescription(
+ key="short_capacity",
+ translation_key="short_capacity",
+ value_fn=lambda garage: garage.short_capacity,
+ ),
+ GaragesAmsterdamSensorEntityDescription(
+ key="long_capacity",
+ translation_key="long_capacity",
+ value_fn=lambda garage: garage.long_capacity,
+ ),
+)
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ entry: GaragesAmsterdamConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Defer sensor setup to the shared sensor module."""
- coordinator = await get_coordinator(hass)
+ coordinator = entry.runtime_data
async_add_entities(
- GaragesAmsterdamSensor(coordinator, config_entry.data["garage_name"], info_type)
- for info_type in SENSORS
- if getattr(coordinator.data[config_entry.data["garage_name"]], info_type) != ""
+ GaragesAmsterdamSensor(
+ coordinator=coordinator,
+ garage_name=entry.data["garage_name"],
+ description=description,
+ )
+ for description in SENSORS
+ if description.value_fn(coordinator.data[entry.data["garage_name"]]) is not None
)
class GaragesAmsterdamSensor(GaragesAmsterdamEntity, SensorEntity):
"""Sensor representing garages amsterdam data."""
- _attr_native_unit_of_measurement = "cars"
+ entity_description: GaragesAmsterdamSensorEntityDescription
def __init__(
- self, coordinator: DataUpdateCoordinator, garage_name: str, info_type: str
+ self,
+ *,
+ coordinator: GaragesAmsterdamDataUpdateCoordinator,
+ garage_name: str,
+ description: GaragesAmsterdamSensorEntityDescription,
) -> None:
"""Initialize garages amsterdam sensor."""
- super().__init__(coordinator, garage_name, info_type)
- self._attr_translation_key = info_type
+ super().__init__(coordinator, garage_name)
+ self.entity_description = description
+ self._attr_unique_id = f"{garage_name}-{description.key}"
@property
def available(self) -> bool:
@@ -54,6 +98,8 @@ class GaragesAmsterdamSensor(GaragesAmsterdamEntity, SensorEntity):
)
@property
- def native_value(self) -> str:
+ def native_value(self) -> StateType:
"""Return the state of the sensor."""
- return getattr(self.coordinator.data[self._garage_name], self._info_type)
+ return self.entity_description.value_fn(
+ self.coordinator.data[self._garage_name]
+ )
diff --git a/homeassistant/components/garages_amsterdam/strings.json b/homeassistant/components/garages_amsterdam/strings.json
index 89a85f97448..19157afdafb 100644
--- a/homeassistant/components/garages_amsterdam/strings.json
+++ b/homeassistant/components/garages_amsterdam/strings.json
@@ -3,8 +3,13 @@
"config": {
"step": {
"user": {
- "title": "Pick a garage to monitor",
- "data": { "garage_name": "Garage name" }
+ "description": "Select a garage from the list",
+ "data": {
+ "garage_name": "Garage name"
+ },
+ "data_description": {
+ "garage_name": "The name of the garage you want to monitor."
+ }
}
},
"abort": {
@@ -16,16 +21,25 @@
"entity": {
"sensor": {
"free_space_short": {
- "name": "Short parking free space"
+ "name": "Short parking free space",
+ "unit_of_measurement": "cars"
},
"free_space_long": {
- "name": "Long parking free space"
+ "name": "Long parking free space",
+ "unit_of_measurement": "cars"
},
"short_capacity": {
- "name": "Short parking capacity"
+ "name": "Short parking capacity",
+ "unit_of_measurement": "cars"
},
"long_capacity": {
- "name": "Long parking capacity"
+ "name": "Long parking capacity",
+ "unit_of_measurement": "cars"
+ }
+ },
+ "binary_sensor": {
+ "state": {
+ "name": "State"
}
}
}
diff --git a/homeassistant/components/gardena_bluetooth/manifest.json b/homeassistant/components/gardena_bluetooth/manifest.json
index da5c08c38c5..28bba1015f5 100644
--- a/homeassistant/components/gardena_bluetooth/manifest.json
+++ b/homeassistant/components/gardena_bluetooth/manifest.json
@@ -14,5 +14,5 @@
"documentation": "https://www.home-assistant.io/integrations/gardena_bluetooth",
"iot_class": "local_polling",
"loggers": ["bleak", "bleak_esphome", "gardena_bluetooth"],
- "requirements": ["gardena-bluetooth==1.4.4"]
+ "requirements": ["gardena-bluetooth==1.5.0"]
}
diff --git a/homeassistant/components/gc100/manifest.json b/homeassistant/components/gc100/manifest.json
index b4af14a323b..687e09f5c89 100644
--- a/homeassistant/components/gc100/manifest.json
+++ b/homeassistant/components/gc100/manifest.json
@@ -4,5 +4,6 @@
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/gc100",
"iot_class": "local_polling",
+ "quality_scale": "legacy",
"requirements": ["python-gc100==1.0.3a0"]
}
diff --git a/homeassistant/components/gdacs/manifest.json b/homeassistant/components/gdacs/manifest.json
index fab47e00904..a40dc8cf91b 100644
--- a/homeassistant/components/gdacs/manifest.json
+++ b/homeassistant/components/gdacs/manifest.json
@@ -7,6 +7,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["aio_georss_gdacs", "aio_georss_client"],
- "quality_scale": "platinum",
"requirements": ["aio-georss-gdacs==0.10"]
}
diff --git a/homeassistant/components/generic/camera.py b/homeassistant/components/generic/camera.py
index 3aac5145ca5..edefbc55ca6 100644
--- a/homeassistant/components/generic/camera.py
+++ b/homeassistant/components/generic/camera.py
@@ -96,10 +96,9 @@ class GenericCamera(Camera):
self._stream_source = device_info.get(CONF_STREAM_SOURCE)
if self._stream_source:
self._stream_source = Template(self._stream_source, hass)
- self._limit_refetch = device_info[CONF_LIMIT_REFETCH_TO_URL_CHANGE]
- self._attr_frame_interval = 1 / device_info[CONF_FRAMERATE]
- if self._stream_source:
self._attr_supported_features = CameraEntityFeature.STREAM
+ self._limit_refetch = device_info.get(CONF_LIMIT_REFETCH_TO_URL_CHANGE, False)
+ self._attr_frame_interval = 1 / device_info[CONF_FRAMERATE]
self.content_type = device_info[CONF_CONTENT_TYPE]
self.verify_ssl = device_info[CONF_VERIFY_SSL]
if device_info.get(CONF_RTSP_TRANSPORT):
diff --git a/homeassistant/components/generic/config_flow.py b/homeassistant/components/generic/config_flow.py
index 8bd238fd0e6..b20793fe060 100644
--- a/homeassistant/components/generic/config_flow.py
+++ b/homeassistant/components/generic/config_flow.py
@@ -5,7 +5,7 @@ from __future__ import annotations
import asyncio
from collections.abc import Mapping
import contextlib
-from datetime import datetime
+from datetime import datetime, timedelta
from errno import EHOSTUNREACH, EIO
import io
import logging
@@ -17,18 +17,21 @@ import PIL.Image
import voluptuous as vol
import yarl
+from homeassistant.components import websocket_api
from homeassistant.components.camera import (
CAMERA_IMAGE_TIMEOUT,
+ DOMAIN as CAMERA_DOMAIN,
DynamicStreamSettings,
_async_get_image,
)
-from homeassistant.components.http import HomeAssistantView
+from homeassistant.components.http.view import HomeAssistantView
from homeassistant.components.stream import (
CONF_RTSP_TRANSPORT,
CONF_USE_WALLCLOCK_AS_TIMESTAMPS,
HLS_PROVIDER,
RTSP_TRANSPORTS,
SOURCE_TIMEOUT,
+ Stream,
create_stream,
)
from homeassistant.config_entries import (
@@ -49,7 +52,9 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError, TemplateError
from homeassistant.helpers import config_validation as cv, template as template_helper
+from homeassistant.helpers.entity_platform import EntityPlatform
from homeassistant.helpers.httpx_client import get_async_client
+from homeassistant.setup import async_prepare_setup_platform
from homeassistant.util import slugify
from .camera import GenericCamera, generate_auth
@@ -79,6 +84,15 @@ SUPPORTED_IMAGE_TYPES = {"png", "jpeg", "gif", "svg+xml", "webp"}
IMAGE_PREVIEWS_ACTIVE = "previews"
+class InvalidStreamException(HomeAssistantError):
+ """Error to indicate an invalid stream."""
+
+ def __init__(self, error: str, details: str | None = None) -> None:
+ """Initialize the error."""
+ super().__init__(error)
+ self.details = details
+
+
def build_schema(
user_input: Mapping[str, Any],
is_options_flow: bool = False,
@@ -157,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):
@@ -231,16 +247,16 @@ def slug(
return None
-async def async_test_stream(
+async def async_test_and_preview_stream(
hass: HomeAssistant, info: Mapping[str, Any]
-) -> dict[str, str]:
- """Verify that the stream is valid before we create an entity."""
+) -> Stream | None:
+ """Verify that the stream is valid before we create an entity.
+
+ Returns the stream object if valid. Raises InvalidStreamException if not.
+ The stream object is used to preview the video in the UI.
+ """
if not (stream_source := info.get(CONF_STREAM_SOURCE)):
- return {}
- # 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
+ return None
if not isinstance(stream_source, template_helper.Template):
stream_source = template_helper.Template(stream_source, hass)
@@ -248,7 +264,7 @@ async def async_test_stream(
stream_source = stream_source.async_render(parse_result=False)
except TemplateError as err:
_LOGGER.warning("Problem rendering template %s: %s", stream_source, err)
- return {CONF_STREAM_SOURCE: "template_error"}
+ raise InvalidStreamException("template_error") from err
stream_options: dict[str, str | bool | float] = {}
if rtsp_transport := info.get(CONF_RTSP_TRANSPORT):
stream_options[CONF_RTSP_TRANSPORT] = rtsp_transport
@@ -257,10 +273,10 @@ async def async_test_stream(
try:
url = yarl.URL(stream_source)
- except ValueError:
- return {CONF_STREAM_SOURCE: "malformed_url"}
+ except ValueError as err:
+ raise InvalidStreamException("malformed_url") from err
if not url.is_absolute():
- return {CONF_STREAM_SOURCE: "relative_url"}
+ raise InvalidStreamException("relative_url")
if not url.user and not url.password:
username = info.get(CONF_USERNAME)
password = info.get(CONF_PASSWORD)
@@ -273,33 +289,30 @@ async def async_test_stream(
stream_source,
stream_options,
DynamicStreamSettings(),
- "test_stream",
+ f"{DOMAIN}.test_stream",
)
hls_provider = stream.add_provider(HLS_PROVIDER)
- await stream.start()
- if not await hls_provider.part_recv(timeout=SOURCE_TIMEOUT):
- hass.async_create_task(stream.stop())
- return {CONF_STREAM_SOURCE: "timeout"}
- await stream.stop()
- except StreamWorkerError as err:
- return {CONF_STREAM_SOURCE: str(err)}
- except PermissionError:
- return {CONF_STREAM_SOURCE: "stream_not_permitted"}
+ except PermissionError as err:
+ raise InvalidStreamException("stream_not_permitted") from err
except OSError as err:
if err.errno == EHOSTUNREACH:
- return {CONF_STREAM_SOURCE: "stream_no_route_to_host"}
+ raise InvalidStreamException("stream_no_route_to_host") from err
if err.errno == EIO: # input/output error
- return {CONF_STREAM_SOURCE: "stream_io_error"}
+ raise InvalidStreamException("stream_io_error") from err
raise
except HomeAssistantError as err:
if "Stream integration is not set up" in str(err):
- return {CONF_STREAM_SOURCE: "stream_not_set_up"}
+ raise InvalidStreamException("stream_not_set_up") from err
raise
- return {}
+ await stream.start()
+ if not await hls_provider.part_recv(timeout=SOURCE_TIMEOUT):
+ hass.async_create_task(stream.stop())
+ raise InvalidStreamException("timeout")
+ 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):
@@ -315,7 +328,8 @@ 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 = ""
@@ -326,14 +340,6 @@ class GenericIPCamConfigFlow(ConfigFlow, domain=DOMAIN):
"""Get the options flow for this handler."""
return GenericOptionsFlowHandler()
- def check_for_existing(self, options: dict[str, Any]) -> bool:
- """Check whether an existing entry is using the same URLs."""
- return any(
- entry.options.get(CONF_STILL_IMAGE_URL) == options.get(CONF_STILL_IMAGE_URL)
- and entry.options.get(CONF_STREAM_SOURCE) == options.get(CONF_STREAM_SOURCE)
- for entry in self._async_current_entries()
- )
-
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
@@ -348,30 +354,25 @@ class GenericIPCamConfigFlow(ConfigFlow, domain=DOMAIN):
errors["base"] = "no_still_image_or_stream_url"
else:
errors, still_format = await async_test_still(hass, user_input)
- errors = errors | await async_test_stream(hass, user_input)
+ try:
+ self.preview_stream = await async_test_and_preview_stream(
+ hass, user_input
+ )
+ except InvalidStreamException as err:
+ errors[CONF_STREAM_SOURCE] = str(err)
+ self.preview_stream = None
if not errors:
user_input[CONF_CONTENT_TYPE] = still_format
- user_input[CONF_LIMIT_REFETCH_TO_URL_CHANGE] = False
still_url = user_input.get(CONF_STILL_IMAGE_URL)
stream_url = user_input.get(CONF_STREAM_SOURCE)
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
-
- if still_url is None:
- return self.async_create_entry(
- title=self.title, data={}, options=self.user_input
- )
# temporary preview for user to check the image
- self.preview_cam = user_input
- return await self.async_step_user_confirm_still()
+ self.preview_image_settings = user_input
+ return await self.async_step_user_confirm()
elif self.user_input:
user_input = self.user_input
else:
@@ -382,36 +383,44 @@ class GenericIPCamConfigFlow(ConfigFlow, domain=DOMAIN):
errors=errors,
)
- async def async_step_user_confirm_still(
+ async def async_step_user_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle user clicking confirm after still preview."""
if user_input:
+ if ha_stream := self.preview_stream:
+ # Kill off the temp stream we created.
+ await ha_stream.stop()
if not user_input.get(CONF_CONFIRMED_OK):
return await self.async_step_user()
return self.async_create_entry(
title=self.title, data={}, options=self.user_input
)
- register_preview(self.hass)
- preview_url = f"/api/generic/preview_flow_image/{self.flow_id}?t={datetime.now().isoformat()}"
+ register_still_preview(self.hass)
return self.async_show_form(
- step_id="user_confirm_still",
+ step_id="user_confirm",
data_schema=vol.Schema(
{
vol.Required(CONF_CONFIRMED_OK, default=False): bool,
}
),
- description_placeholders={"preview_url": preview_url},
errors=None,
+ preview="generic_camera",
)
+ @staticmethod
+ async def async_setup_preview(hass: HomeAssistant) -> None:
+ """Set up preview WS API."""
+ websocket_api.async_register_command(hass, ws_start_preview)
+
class GenericOptionsFlowHandler(OptionsFlow):
"""Handle Generic IP Camera options."""
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] = {}
async def async_step_init(
@@ -421,30 +430,36 @@ class GenericOptionsFlowHandler(OptionsFlow):
errors: dict[str, str] = {}
hass = self.hass
- if user_input is not None:
- errors, still_format = await async_test_still(
- hass, self.config_entry.options | user_input
- )
- errors = errors | await async_test_stream(hass, user_input)
- still_url = user_input.get(CONF_STILL_IMAGE_URL)
- if not errors:
- 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
- ),
- **user_input,
- CONF_CONTENT_TYPE: still_format
- or self.config_entry.options.get(CONF_CONTENT_TYPE),
- }
- self.user_input = data
- # temporary preview for user to check the image
- self.preview_cam = data
- return await self.async_step_confirm_still()
+ if user_input:
+ # Secondary validation because serialised vol can't seem to handle this complexity:
+ if not user_input.get(CONF_STILL_IMAGE_URL) and not user_input.get(
+ CONF_STREAM_SOURCE
+ ):
+ errors["base"] = "no_still_image_or_stream_url"
+ else:
+ errors, still_format = await async_test_still(hass, user_input)
+ try:
+ self.preview_stream = await async_test_and_preview_stream(
+ hass, user_input
+ )
+ except InvalidStreamException as err:
+ errors[CONF_STREAM_SOURCE] = str(err)
+ self.preview_stream = None
+ if not errors:
+ data = {
+ CONF_USE_WALLCLOCK_AS_TIMESTAMPS: self.config_entry.options.get(
+ CONF_USE_WALLCLOCK_AS_TIMESTAMPS, False
+ ),
+ **user_input,
+ CONF_CONTENT_TYPE: still_format
+ or self.config_entry.options.get(CONF_CONTENT_TYPE),
+ }
+ self.user_input = data
+ # temporary preview for user to check the image
+ self.preview_image_settings = data
+ return await self.async_step_user_confirm()
+ elif self.user_input:
+ user_input = self.user_input
return self.async_show_form(
step_id="init",
data_schema=build_schema(
@@ -455,30 +470,37 @@ class GenericOptionsFlowHandler(OptionsFlow):
errors=errors,
)
- async def async_step_confirm_still(
+ async def async_step_user_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle user clicking confirm after still preview."""
if user_input:
+ if ha_stream := self.preview_stream:
+ # Kill off the temp stream we created.
+ await ha_stream.stop()
if not user_input.get(CONF_CONFIRMED_OK):
return await self.async_step_init()
return self.async_create_entry(
title=self.config_entry.title,
data=self.user_input,
)
- register_preview(self.hass)
- preview_url = f"/api/generic/preview_flow_image/{self.flow_id}?t={datetime.now().isoformat()}"
+ register_still_preview(self.hass)
return self.async_show_form(
- step_id="confirm_still",
+ step_id="user_confirm",
data_schema=vol.Schema(
{
vol.Required(CONF_CONFIRMED_OK, default=False): bool,
}
),
- description_placeholders={"preview_url": preview_url},
errors=None,
+ preview="generic_camera",
)
+ @staticmethod
+ async def async_setup_preview(hass: HomeAssistant) -> None:
+ """Set up preview WS API."""
+ websocket_api.async_register_command(hass, ws_start_preview)
+
class CameraImagePreview(HomeAssistantView):
"""Camera view to temporarily serve an image."""
@@ -504,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")
@@ -514,3 +536,66 @@ class CameraImagePreview(HomeAssistantView):
CAMERA_IMAGE_TIMEOUT,
)
return web.Response(body=image.content, content_type=image.content_type)
+
+
+@websocket_api.websocket_command(
+ {
+ vol.Required("type"): "generic_camera/start_preview",
+ vol.Required("flow_id"): str,
+ vol.Optional("flow_type"): vol.Any("config_flow", "options_flow"),
+ vol.Optional("user_input"): dict,
+ }
+)
+@websocket_api.async_response
+async def ws_start_preview(
+ hass: HomeAssistant,
+ connection: websocket_api.ActiveConnection,
+ msg: dict[str, Any],
+) -> None:
+ """Generate websocket handler for the camera still/stream preview."""
+ _LOGGER.debug("Generating websocket handler for generic camera preview")
+
+ flow_id = msg["flow_id"]
+ flow: GenericIPCamConfigFlow | GenericOptionsFlowHandler
+ if msg.get("flow_type", "config_flow") == "config_flow":
+ flow = cast(
+ GenericIPCamConfigFlow,
+ hass.config_entries.flow._progress.get(flow_id), # noqa: SLF001
+ )
+ else: # (flow type == "options flow")
+ flow = cast(
+ GenericOptionsFlowHandler,
+ hass.config_entries.options._progress.get(flow_id), # noqa: SLF001
+ )
+ user_input = flow.preview_image_settings
+
+ # Create an EntityPlatform, needed for name translations
+ platform = await async_prepare_setup_platform(hass, {}, CAMERA_DOMAIN, DOMAIN)
+ entity_platform = EntityPlatform(
+ hass=hass,
+ logger=_LOGGER,
+ domain=CAMERA_DOMAIN,
+ platform_name=DOMAIN,
+ platform=platform,
+ scan_interval=timedelta(seconds=3600),
+ entity_namespace=None,
+ )
+ await entity_platform.async_load_translations()
+
+ ha_still_url = None
+ ha_stream_url = None
+
+ if user_input.get(CONF_STILL_IMAGE_URL):
+ ha_still_url = f"/api/generic/preview_flow_image/{msg['flow_id']}?t={datetime.now().isoformat()}"
+ _LOGGER.debug("Got preview still URL: %s", ha_still_url)
+
+ if ha_stream := flow.preview_stream:
+ ha_stream_url = ha_stream.endpoint_url(HLS_PROVIDER)
+ _LOGGER.debug("Got preview stream URL: %s", ha_stream_url)
+
+ connection.send_message(
+ websocket_api.event_message(
+ msg["id"],
+ {"attributes": {"still_url": ha_still_url, "stream_url": ha_stream_url}},
+ )
+ )
diff --git a/homeassistant/components/generic/manifest.json b/homeassistant/components/generic/manifest.json
index b02a8fa2520..35c5ae93b72 100644
--- a/homeassistant/components/generic/manifest.json
+++ b/homeassistant/components/generic/manifest.json
@@ -3,9 +3,9 @@
"name": "Generic Camera",
"codeowners": ["@davet2001"],
"config_flow": true,
- "dependencies": ["http"],
+ "dependencies": ["http", "stream"],
"documentation": "https://www.home-assistant.io/integrations/generic",
"integration_type": "device",
"iot_class": "local_push",
- "requirements": ["av==13.1.0", "Pillow==10.4.0"]
+ "requirements": ["av==13.1.0", "Pillow==11.1.0"]
}
diff --git a/homeassistant/components/generic/strings.json b/homeassistant/components/generic/strings.json
index b05f17efc8d..854ceb93b3e 100644
--- a/homeassistant/components/generic/strings.json
+++ b/homeassistant/components/generic/strings.json
@@ -3,6 +3,7 @@
"config": {
"error": {
"unknown": "[%key:common::config_flow::error::unknown%]",
+ "unknown_with_details": "An unknown error occurred: {error}",
"already_exists": "A camera with these URL settings already exists.",
"unable_still_load": "Unable to load valid image from still image URL (e.g. invalid host, URL or authentication failure). Review log for more info.",
"unable_still_load_auth": "Unable to load valid image from still image URL: The camera may require a user name and password, or they are not correct.",
@@ -38,11 +39,11 @@
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
}
},
- "user_confirm_still": {
- "title": "Preview",
- "description": "",
+ "user_confirm": {
+ "title": "Confirmation",
+ "description": "Please wait for previews to load...",
"data": {
- "confirmed_ok": "This image looks good."
+ "confirmed_ok": "Everything looks good."
}
}
}
@@ -66,16 +67,17 @@
"use_wallclock_as_timestamps": "This option may correct segmenting or crashing issues arising from buggy timestamp implementations on some cameras"
}
},
- "confirm_still": {
- "title": "[%key:component::generic::config::step::user_confirm_still::title%]",
- "description": "[%key:component::generic::config::step::user_confirm_still::description%]",
+ "user_confirm": {
+ "title": "Confirmation",
+ "description": "Please wait for previews to load...",
"data": {
- "confirmed_ok": "[%key:component::generic::config::step::user_confirm_still::data::confirmed_ok%]"
+ "confirmed_ok": "Everything looks good."
}
}
},
"error": {
"unknown": "[%key:common::config_flow::error::unknown%]",
+ "unknown_with_details": "[%key:component::generic::config::error::unknown_with_details%]",
"already_exists": "[%key:component::generic::config::error::already_exists%]",
"unable_still_load": "[%key:component::generic::config::error::unable_still_load%]",
"unable_still_load_auth": "[%key:component::generic::config::error::unable_still_load_auth%]",
diff --git a/homeassistant/components/generic_hygrostat/strings.json b/homeassistant/components/generic_hygrostat/strings.json
index a21ab68c628..7b8d56dbaa5 100644
--- a/homeassistant/components/generic_hygrostat/strings.json
+++ b/homeassistant/components/generic_hygrostat/strings.json
@@ -3,8 +3,8 @@
"config": {
"step": {
"user": {
- "title": "Add generic hygrostat",
- "description": "Create a entity that control the humidity via a switch and sensor.",
+ "title": "Create generic hygrostat",
+ "description": "Create a humidifier entity that controls the humidity via a switch and sensor.",
"data": {
"device_class": "Device class",
"dry_tolerance": "Dry tolerance",
@@ -17,7 +17,7 @@
"data_description": {
"dry_tolerance": "The minimum amount of difference between the humidity read by the sensor specified in the target sensor option and the target humidity that must change prior to being switched on.",
"humidifier": "Humidifier or dehumidifier switch; must be a toggle device.",
- "min_cycle_duration": "Set a minimum amount of time that the switch specified in the humidifier option must be in its current state prior to being switched either off or on.",
+ "min_cycle_duration": "Set a minimum duration for which the specified switch must remain in its current state before it can be toggled off or on.",
"target_sensor": "Sensor with current humidity.",
"wet_tolerance": "The minimum amount of difference between the humidity read by the sensor specified in the target sensor option and the target humidity that must change prior to being switched off."
}
diff --git a/homeassistant/components/generic_thermostat/climate.py b/homeassistant/components/generic_thermostat/climate.py
index d68eaccbb0c..dd6829eacce 100644
--- a/homeassistant/components/generic_thermostat/climate.py
+++ b/homeassistant/components/generic_thermostat/climate.py
@@ -63,7 +63,9 @@ from .const import (
CONF_COLD_TOLERANCE,
CONF_HEATER,
CONF_HOT_TOLERANCE,
+ CONF_MAX_TEMP,
CONF_MIN_DUR,
+ CONF_MIN_TEMP,
CONF_PRESETS,
CONF_SENSOR,
DEFAULT_TOLERANCE,
@@ -77,8 +79,6 @@ DEFAULT_NAME = "Generic Thermostat"
CONF_INITIAL_HVAC_MODE = "initial_hvac_mode"
CONF_KEEP_ALIVE = "keep_alive"
-CONF_MIN_TEMP = "min_temp"
-CONF_MAX_TEMP = "max_temp"
CONF_PRECISION = "precision"
CONF_TARGET_TEMP = "target_temp"
CONF_TEMP_STEP = "target_temp_step"
@@ -205,7 +205,6 @@ class GenericThermostat(ClimateEntity, RestoreEntity):
"""Representation of a Generic Thermostat device."""
_attr_should_poll = False
- _enable_turn_on_off_backwards_compatibility = False
def __init__(
self,
diff --git a/homeassistant/components/generic_thermostat/config_flow.py b/homeassistant/components/generic_thermostat/config_flow.py
index 5b0eae8ff66..1fbeaefde6b 100644
--- a/homeassistant/components/generic_thermostat/config_flow.py
+++ b/homeassistant/components/generic_thermostat/config_flow.py
@@ -21,7 +21,9 @@ from .const import (
CONF_COLD_TOLERANCE,
CONF_HEATER,
CONF_HOT_TOLERANCE,
+ CONF_MAX_TEMP,
CONF_MIN_DUR,
+ CONF_MIN_TEMP,
CONF_PRESETS,
CONF_SENSOR,
DEFAULT_TOLERANCE,
@@ -57,6 +59,16 @@ OPTIONS_SCHEMA = {
vol.Optional(CONF_MIN_DUR): selector.DurationSelector(
selector.DurationSelectorConfig(allow_negative=False)
),
+ vol.Optional(CONF_MIN_TEMP): selector.NumberSelector(
+ selector.NumberSelectorConfig(
+ mode=selector.NumberSelectorMode.BOX, unit_of_measurement=DEGREE, step=0.1
+ )
+ ),
+ vol.Optional(CONF_MAX_TEMP): selector.NumberSelector(
+ selector.NumberSelectorConfig(
+ mode=selector.NumberSelectorMode.BOX, unit_of_measurement=DEGREE, step=0.1
+ )
+ ),
}
PRESETS_SCHEMA = {
diff --git a/homeassistant/components/generic_thermostat/const.py b/homeassistant/components/generic_thermostat/const.py
index 51927297b63..f0e6f1a7d73 100644
--- a/homeassistant/components/generic_thermostat/const.py
+++ b/homeassistant/components/generic_thermostat/const.py
@@ -18,7 +18,9 @@ CONF_AC_MODE = "ac_mode"
CONF_COLD_TOLERANCE = "cold_tolerance"
CONF_HEATER = "heater"
CONF_HOT_TOLERANCE = "hot_tolerance"
+CONF_MAX_TEMP = "max_temp"
CONF_MIN_DUR = "min_cycle_duration"
+CONF_MIN_TEMP = "min_temp"
CONF_PRESETS = {
p: f"{p}_temp"
for p in (
diff --git a/homeassistant/components/generic_thermostat/strings.json b/homeassistant/components/generic_thermostat/strings.json
index 1ddd41de734..58280e99543 100644
--- a/homeassistant/components/generic_thermostat/strings.json
+++ b/homeassistant/components/generic_thermostat/strings.json
@@ -3,7 +3,7 @@
"config": {
"step": {
"user": {
- "title": "Add generic thermostat helper",
+ "title": "Create generic thermostat",
"description": "Create a climate entity that controls the temperature via a switch and sensor.",
"data": {
"ac_mode": "Cooling mode",
@@ -12,13 +12,15 @@
"min_cycle_duration": "Minimum cycle duration",
"name": "[%key:common::config_flow::data::name%]",
"cold_tolerance": "Cold tolerance",
- "hot_tolerance": "Hot tolerance"
+ "hot_tolerance": "Hot tolerance",
+ "min_temp": "Minimum target temperature",
+ "max_temp": "Maximum target temperature"
},
"data_description": {
"ac_mode": "Set the actuator specified to be treated as a cooling device instead of a heating device.",
"heater": "Switch entity used to cool or heat depending on A/C mode.",
- "target_sensor": "Temperature sensor that reflect the current temperature.",
- "min_cycle_duration": "Set a minimum amount of time that the switch specified must be in its current state prior to being switched either off or on. This option will be ignored if the keep alive option is set.",
+ "target_sensor": "Temperature sensor that reflects the current temperature.",
+ "min_cycle_duration": "Set a minimum amount of time that the switch specified must be in its current state prior to being switched either off or on.",
"cold_tolerance": "Minimum amount of difference between the temperature read by the temperature sensor the target temperature that must change prior to being switched on. For example, if the target temperature is 25 and the tolerance is 0.5 the heater will start when the sensor equals or goes below 24.5.",
"hot_tolerance": "Minimum amount of difference between the temperature read by the temperature sensor the target temperature that must change prior to being switched off. For example, if the target temperature is 25 and the tolerance is 0.5 the heater will stop when the sensor equals or goes above 25.5."
}
@@ -45,7 +47,9 @@
"target_sensor": "[%key:component::generic_thermostat::config::step::user::data::target_sensor%]",
"min_cycle_duration": "[%key:component::generic_thermostat::config::step::user::data::min_cycle_duration%]",
"cold_tolerance": "[%key:component::generic_thermostat::config::step::user::data::cold_tolerance%]",
- "hot_tolerance": "[%key:component::generic_thermostat::config::step::user::data::hot_tolerance%]"
+ "hot_tolerance": "[%key:component::generic_thermostat::config::step::user::data::hot_tolerance%]",
+ "min_temp": "[%key:component::generic_thermostat::config::step::user::data::min_temp%]",
+ "max_temp": "[%key:component::generic_thermostat::config::step::user::data::max_temp%]"
},
"data_description": {
"heater": "[%key:component::generic_thermostat::config::step::user::data_description::heater%]",
diff --git a/homeassistant/components/geniushub/__init__.py b/homeassistant/components/geniushub/__init__.py
index f3081e50289..9ca6ecfcfe0 100644
--- a/homeassistant/components/geniushub/__init__.py
+++ b/homeassistant/components/geniushub/__init__.py
@@ -9,7 +9,6 @@ import aiohttp
from geniushubclient import GeniusHub
import voluptuous as vol
-from homeassistant import config_entries
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
ATTR_ENTITY_ID,
@@ -21,20 +20,12 @@ from homeassistant.const import (
CONF_USERNAME,
Platform,
)
-from homeassistant.core import (
- DOMAIN as HOMEASSISTANT_DOMAIN,
- HomeAssistant,
- ServiceCall,
- callback,
-)
-from homeassistant.data_entry_flow import FlowResultType
+from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.helpers import config_validation as cv, entity_registry as er
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.event import async_track_time_interval
-from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.helpers.service import verify_domain_control
-from homeassistant.helpers.typing import ConfigType
from .const import DOMAIN
@@ -45,27 +36,6 @@ SCAN_INTERVAL = timedelta(seconds=60)
MAC_ADDRESS_REGEXP = r"^([0-9A-F]{2}:){5}([0-9A-F]{2})$"
-CLOUD_API_SCHEMA = vol.Schema(
- {
- vol.Required(CONF_TOKEN): cv.string,
- vol.Required(CONF_MAC): vol.Match(MAC_ADDRESS_REGEXP),
- }
-)
-
-
-LOCAL_API_SCHEMA = vol.Schema(
- {
- vol.Required(CONF_HOST): cv.string,
- vol.Required(CONF_USERNAME): cv.string,
- vol.Required(CONF_PASSWORD): cv.string,
- vol.Optional(CONF_MAC): vol.Match(MAC_ADDRESS_REGEXP),
- }
-)
-
-CONFIG_SCHEMA = vol.Schema(
- {DOMAIN: vol.Any(LOCAL_API_SCHEMA, CLOUD_API_SCHEMA)}, extra=vol.ALLOW_EXTRA
-)
-
ATTR_ZONE_MODE = "mode"
ATTR_DURATION = "duration"
@@ -100,56 +70,6 @@ PLATFORMS = [
]
-async def _async_import(hass: HomeAssistant, base_config: ConfigType) -> None:
- """Import a config entry from configuration.yaml."""
-
- result = await hass.config_entries.flow.async_init(
- DOMAIN,
- context={"source": config_entries.SOURCE_IMPORT},
- data=base_config[DOMAIN],
- )
- 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="2024.12.0",
- is_fixable=False,
- issue_domain=DOMAIN,
- severity=IssueSeverity.WARNING,
- translation_key="deprecated_yaml",
- translation_placeholders={
- "domain": DOMAIN,
- "integration_title": "Genius Hub",
- },
- )
- return
- async_create_issue(
- hass,
- DOMAIN,
- f"deprecated_yaml_import_issue_{result['reason']}",
- breaks_in_ha_version="2024.12.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": "Genius Hub",
- },
- )
-
-
-async def async_setup(hass: HomeAssistant, base_config: ConfigType) -> bool:
- """Set up a Genius Hub system."""
- if DOMAIN in base_config:
- hass.async_create_task(_async_import(hass, base_config))
- return True
-
-
type GeniusHubConfigEntry = ConfigEntry[GeniusBroker]
diff --git a/homeassistant/components/geniushub/climate.py b/homeassistant/components/geniushub/climate.py
index 99d1bde8099..e20d649541e 100644
--- a/homeassistant/components/geniushub/climate.py
+++ b/homeassistant/components/geniushub/climate.py
@@ -51,7 +51,6 @@ class GeniusClimateZone(GeniusHeatingZone, ClimateEntity):
| ClimateEntityFeature.TURN_OFF
| ClimateEntityFeature.TURN_ON
)
- _enable_turn_on_off_backwards_compatibility = False
def __init__(self, broker, zone) -> None:
"""Initialize the climate device."""
diff --git a/homeassistant/components/geniushub/config_flow.py b/homeassistant/components/geniushub/config_flow.py
index 601eac6c2f2..b106f9907bb 100644
--- a/homeassistant/components/geniushub/config_flow.py
+++ b/homeassistant/components/geniushub/config_flow.py
@@ -13,7 +13,6 @@ import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME
-from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN
@@ -123,14 +122,3 @@ class GeniusHubConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_show_form(
step_id="cloud_api", errors=errors, data_schema=CLOUD_API_SCHEMA
)
-
- async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult:
- """Import the yaml config."""
- if CONF_HOST in import_data:
- result = await self.async_step_local_api(import_data)
- else:
- result = await self.async_step_cloud_api(import_data)
- if result["type"] is FlowResultType.FORM:
- assert result["errors"]
- return self.async_abort(reason=result["errors"]["base"])
- return result
diff --git a/homeassistant/components/geo_json_events/manifest.json b/homeassistant/components/geo_json_events/manifest.json
index 8f4b36657dd..c41796514a5 100644
--- a/homeassistant/components/geo_json_events/manifest.json
+++ b/homeassistant/components/geo_json_events/manifest.json
@@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["aio_geojson_generic_client"],
- "requirements": ["aio-geojson-generic-client==0.4"]
+ "requirements": ["aio-geojson-generic-client==0.5"]
}
diff --git a/homeassistant/components/geo_rss_events/manifest.json b/homeassistant/components/geo_rss_events/manifest.json
index 17640e37278..7c089bfa4e9 100644
--- a/homeassistant/components/geo_rss_events/manifest.json
+++ b/homeassistant/components/geo_rss_events/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/geo_rss_events",
"iot_class": "cloud_polling",
"loggers": ["georss_client", "georss_generic_client"],
+ "quality_scale": "legacy",
"requirements": ["georss-generic-client==0.8"]
}
diff --git a/homeassistant/components/geonetnz_quakes/manifest.json b/homeassistant/components/geonetnz_quakes/manifest.json
index 2314dabcf0f..e8f4ee1a8c1 100644
--- a/homeassistant/components/geonetnz_quakes/manifest.json
+++ b/homeassistant/components/geonetnz_quakes/manifest.json
@@ -7,6 +7,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["aio_geojson_geonetnz_quakes"],
- "quality_scale": "platinum",
"requirements": ["aio-geojson-geonetnz-quakes==0.16"]
}
diff --git a/homeassistant/components/gios/manifest.json b/homeassistant/components/gios/manifest.json
index b1eae512688..3d2e719fab6 100644
--- a/homeassistant/components/gios/manifest.json
+++ b/homeassistant/components/gios/manifest.json
@@ -7,6 +7,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["dacite", "gios"],
- "quality_scale": "platinum",
"requirements": ["gios==5.0.0"]
}
diff --git a/homeassistant/components/gios/strings.json b/homeassistant/components/gios/strings.json
index ee0f50ef40c..fc82f1c843d 100644
--- a/homeassistant/components/gios/strings.json
+++ b/homeassistant/components/gios/strings.json
@@ -34,6 +34,18 @@
"moderate": "Moderate",
"good": "Good",
"very_good": "Very good"
+ },
+ "state_attributes": {
+ "options": {
+ "state": {
+ "very_bad": "[%key:component::gios::entity::sensor::aqi::state::very_bad%]",
+ "bad": "[%key:component::gios::entity::sensor::aqi::state::bad%]",
+ "sufficient": "[%key:component::gios::entity::sensor::aqi::state::sufficient%]",
+ "moderate": "[%key:component::gios::entity::sensor::aqi::state::moderate%]",
+ "good": "[%key:component::gios::entity::sensor::aqi::state::good%]",
+ "very_good": "[%key:component::gios::entity::sensor::aqi::state::very_good%]"
+ }
+ }
}
},
"c6h6": {
@@ -51,6 +63,18 @@
"moderate": "[%key:component::gios::entity::sensor::aqi::state::moderate%]",
"good": "[%key:component::gios::entity::sensor::aqi::state::good%]",
"very_good": "[%key:component::gios::entity::sensor::aqi::state::very_good%]"
+ },
+ "state_attributes": {
+ "options": {
+ "state": {
+ "very_bad": "[%key:component::gios::entity::sensor::aqi::state::very_bad%]",
+ "bad": "[%key:component::gios::entity::sensor::aqi::state::bad%]",
+ "sufficient": "[%key:component::gios::entity::sensor::aqi::state::sufficient%]",
+ "moderate": "[%key:component::gios::entity::sensor::aqi::state::moderate%]",
+ "good": "[%key:component::gios::entity::sensor::aqi::state::good%]",
+ "very_good": "[%key:component::gios::entity::sensor::aqi::state::very_good%]"
+ }
+ }
}
},
"o3_index": {
@@ -62,6 +86,18 @@
"moderate": "[%key:component::gios::entity::sensor::aqi::state::moderate%]",
"good": "[%key:component::gios::entity::sensor::aqi::state::good%]",
"very_good": "[%key:component::gios::entity::sensor::aqi::state::very_good%]"
+ },
+ "state_attributes": {
+ "options": {
+ "state": {
+ "very_bad": "[%key:component::gios::entity::sensor::aqi::state::very_bad%]",
+ "bad": "[%key:component::gios::entity::sensor::aqi::state::bad%]",
+ "sufficient": "[%key:component::gios::entity::sensor::aqi::state::sufficient%]",
+ "moderate": "[%key:component::gios::entity::sensor::aqi::state::moderate%]",
+ "good": "[%key:component::gios::entity::sensor::aqi::state::good%]",
+ "very_good": "[%key:component::gios::entity::sensor::aqi::state::very_good%]"
+ }
+ }
}
},
"pm10_index": {
@@ -73,6 +109,18 @@
"moderate": "[%key:component::gios::entity::sensor::aqi::state::moderate%]",
"good": "[%key:component::gios::entity::sensor::aqi::state::good%]",
"very_good": "[%key:component::gios::entity::sensor::aqi::state::very_good%]"
+ },
+ "state_attributes": {
+ "options": {
+ "state": {
+ "very_bad": "[%key:component::gios::entity::sensor::aqi::state::very_bad%]",
+ "bad": "[%key:component::gios::entity::sensor::aqi::state::bad%]",
+ "sufficient": "[%key:component::gios::entity::sensor::aqi::state::sufficient%]",
+ "moderate": "[%key:component::gios::entity::sensor::aqi::state::moderate%]",
+ "good": "[%key:component::gios::entity::sensor::aqi::state::good%]",
+ "very_good": "[%key:component::gios::entity::sensor::aqi::state::very_good%]"
+ }
+ }
}
},
"pm25_index": {
@@ -84,6 +132,18 @@
"moderate": "[%key:component::gios::entity::sensor::aqi::state::moderate%]",
"good": "[%key:component::gios::entity::sensor::aqi::state::good%]",
"very_good": "[%key:component::gios::entity::sensor::aqi::state::very_good%]"
+ },
+ "state_attributes": {
+ "options": {
+ "state": {
+ "very_bad": "[%key:component::gios::entity::sensor::aqi::state::very_bad%]",
+ "bad": "[%key:component::gios::entity::sensor::aqi::state::bad%]",
+ "sufficient": "[%key:component::gios::entity::sensor::aqi::state::sufficient%]",
+ "moderate": "[%key:component::gios::entity::sensor::aqi::state::moderate%]",
+ "good": "[%key:component::gios::entity::sensor::aqi::state::good%]",
+ "very_good": "[%key:component::gios::entity::sensor::aqi::state::very_good%]"
+ }
+ }
}
},
"so2_index": {
@@ -95,6 +155,18 @@
"moderate": "[%key:component::gios::entity::sensor::aqi::state::moderate%]",
"good": "[%key:component::gios::entity::sensor::aqi::state::good%]",
"very_good": "[%key:component::gios::entity::sensor::aqi::state::very_good%]"
+ },
+ "state_attributes": {
+ "options": {
+ "state": {
+ "very_bad": "[%key:component::gios::entity::sensor::aqi::state::very_bad%]",
+ "bad": "[%key:component::gios::entity::sensor::aqi::state::bad%]",
+ "sufficient": "[%key:component::gios::entity::sensor::aqi::state::sufficient%]",
+ "moderate": "[%key:component::gios::entity::sensor::aqi::state::moderate%]",
+ "good": "[%key:component::gios::entity::sensor::aqi::state::good%]",
+ "very_good": "[%key:component::gios::entity::sensor::aqi::state::very_good%]"
+ }
+ }
}
}
}
diff --git a/homeassistant/components/github/sensor.py b/homeassistant/components/github/sensor.py
index 9a2b5ef5ac4..614ebe254c4 100644
--- a/homeassistant/components/github/sensor.py
+++ b/homeassistant/components/github/sensor.py
@@ -37,7 +37,6 @@ SENSOR_DESCRIPTIONS: tuple[GitHubSensorEntityDescription, ...] = (
GitHubSensorEntityDescription(
key="discussions_count",
translation_key="discussions_count",
- native_unit_of_measurement="Discussions",
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda data: data["discussion"]["total"],
@@ -45,7 +44,6 @@ SENSOR_DESCRIPTIONS: tuple[GitHubSensorEntityDescription, ...] = (
GitHubSensorEntityDescription(
key="stargazers_count",
translation_key="stargazers_count",
- native_unit_of_measurement="Stars",
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda data: data["stargazers_count"],
@@ -53,7 +51,6 @@ SENSOR_DESCRIPTIONS: tuple[GitHubSensorEntityDescription, ...] = (
GitHubSensorEntityDescription(
key="subscribers_count",
translation_key="subscribers_count",
- native_unit_of_measurement="Watchers",
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda data: data["watchers"]["total"],
@@ -61,7 +58,6 @@ SENSOR_DESCRIPTIONS: tuple[GitHubSensorEntityDescription, ...] = (
GitHubSensorEntityDescription(
key="forks_count",
translation_key="forks_count",
- native_unit_of_measurement="Forks",
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda data: data["forks_count"],
@@ -69,7 +65,6 @@ SENSOR_DESCRIPTIONS: tuple[GitHubSensorEntityDescription, ...] = (
GitHubSensorEntityDescription(
key="issues_count",
translation_key="issues_count",
- native_unit_of_measurement="Issues",
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda data: data["issue"]["total"],
@@ -77,7 +72,6 @@ SENSOR_DESCRIPTIONS: tuple[GitHubSensorEntityDescription, ...] = (
GitHubSensorEntityDescription(
key="pulls_count",
translation_key="pulls_count",
- native_unit_of_measurement="Pull Requests",
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda data: data["pull_request"]["total"],
diff --git a/homeassistant/components/github/strings.json b/homeassistant/components/github/strings.json
index 38b796e2fd2..bcda47d72fb 100644
--- a/homeassistant/components/github/strings.json
+++ b/homeassistant/components/github/strings.json
@@ -19,22 +19,28 @@
"entity": {
"sensor": {
"discussions_count": {
- "name": "Discussions"
+ "name": "Discussions",
+ "unit_of_measurement": "discussions"
},
"stargazers_count": {
- "name": "Stars"
+ "name": "Stars",
+ "unit_of_measurement": "stars"
},
"subscribers_count": {
- "name": "Watchers"
+ "name": "Watchers",
+ "unit_of_measurement": "watchers"
},
"forks_count": {
- "name": "Forks"
+ "name": "Forks",
+ "unit_of_measurement": "forks"
},
"issues_count": {
- "name": "Issues"
+ "name": "Issues",
+ "unit_of_measurement": "issues"
},
"pulls_count": {
- "name": "Pull requests"
+ "name": "Pull requests",
+ "unit_of_measurement": "pull requests"
},
"latest_commit": {
"name": "Latest commit"
diff --git a/homeassistant/components/gitlab_ci/manifest.json b/homeassistant/components/gitlab_ci/manifest.json
index 36fb356dae4..58fd827ff31 100644
--- a/homeassistant/components/gitlab_ci/manifest.json
+++ b/homeassistant/components/gitlab_ci/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/gitlab_ci",
"iot_class": "cloud_polling",
"loggers": ["gitlab"],
+ "quality_scale": "legacy",
"requirements": ["python-gitlab==1.6.0"]
}
diff --git a/homeassistant/components/gitter/manifest.json b/homeassistant/components/gitter/manifest.json
index 009746a06c6..c578f7c2242 100644
--- a/homeassistant/components/gitter/manifest.json
+++ b/homeassistant/components/gitter/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/gitter",
"iot_class": "cloud_polling",
"loggers": ["gitterpy"],
+ "quality_scale": "legacy",
"requirements": ["gitterpy==0.1.7"]
}
diff --git a/homeassistant/components/glances/__init__.py b/homeassistant/components/glances/__init__.py
index 0ddd8a86979..9d09e63606e 100644
--- a/homeassistant/components/glances/__init__.py
+++ b/homeassistant/components/glances/__init__.py
@@ -28,9 +28,7 @@ from homeassistant.exceptions import (
HomeAssistantError,
)
from homeassistant.helpers.httpx_client import get_async_client
-from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
-from .const import DOMAIN
from .coordinator import GlancesDataUpdateCoordinator
PLATFORMS = [Platform.SENSOR]
@@ -71,7 +69,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: GlancesConfigEntry) ->
async def get_api(hass: HomeAssistant, entry_data: dict[str, Any]) -> Glances:
"""Return the api from glances_api."""
httpx_client = get_async_client(hass, verify_ssl=entry_data[CONF_VERIFY_SSL])
- for version in (4, 3, 2):
+ for version in (4, 3):
api = Glances(
host=entry_data[CONF_HOST],
port=entry_data[CONF_PORT],
@@ -86,19 +84,9 @@ async def get_api(hass: HomeAssistant, entry_data: dict[str, Any]) -> Glances:
except GlancesApiNoDataAvailable as err:
_LOGGER.debug("Failed to connect to Glances API v%s: %s", version, err)
continue
- if version == 2:
- async_create_issue(
- hass,
- DOMAIN,
- "deprecated_version",
- breaks_in_ha_version="2024.8.0",
- is_fixable=False,
- severity=IssueSeverity.WARNING,
- translation_key="deprecated_version",
- )
_LOGGER.debug("Connected to Glances API v%s", version)
return api
- raise ServerVersionMismatch("Could not connect to Glances API version 2, 3 or 4")
+ raise ServerVersionMismatch("Could not connect to Glances API version 3 or 4")
class ServerVersionMismatch(HomeAssistantError):
diff --git a/homeassistant/components/glances/strings.json b/homeassistant/components/glances/strings.json
index 11735601ce9..92aa1b47e31 100644
--- a/homeassistant/components/glances/strings.json
+++ b/homeassistant/components/glances/strings.json
@@ -123,11 +123,5 @@
"name": "{sensor_label} TX"
}
}
- },
- "issues": {
- "deprecated_version": {
- "title": "Glances servers with version 2 is deprecated",
- "description": "Glances servers with version 2 is deprecated and will not be supported in future versions of HA. It is recommended to update your server to Glances version 3 then reload the integration."
- }
}
}
diff --git a/homeassistant/components/go2rtc/__init__.py b/homeassistant/components/go2rtc/__init__.py
index 04b5b9f9317..31acdd2de50 100644
--- a/homeassistant/components/go2rtc/__init__.py
+++ b/homeassistant/components/go2rtc/__init__.py
@@ -1,12 +1,10 @@
"""The go2rtc component."""
-from __future__ import annotations
-
-from dataclasses import dataclass
import logging
import shutil
from aiohttp.client_exceptions import ClientConnectionError, ServerConnectionError
+from awesomeversion import AwesomeVersion
from go2rtc_client import Go2RtcRestClient
from go2rtc_client.exceptions import Go2RtcClientError, Go2RtcVersionError
from go2rtc_client.ws import (
@@ -18,7 +16,7 @@ from go2rtc_client.ws import (
WsError,
)
import voluptuous as vol
-from webrtc_models import RTCIceCandidate
+from webrtc_models import RTCIceCandidateInit
from homeassistant.components.camera import (
Camera,
@@ -35,7 +33,11 @@ from homeassistant.config_entries import SOURCE_SYSTEM, ConfigEntry
from homeassistant.const import CONF_URL, EVENT_HOMEASSISTANT_STOP
from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady
-from homeassistant.helpers import config_validation as cv, discovery_flow
+from homeassistant.helpers import (
+ config_validation as cv,
+ discovery_flow,
+ issue_registry as ir,
+)
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.typing import ConfigType
from homeassistant.util.hass_dict import HassKey
@@ -45,8 +47,8 @@ from .const import (
CONF_DEBUG_UI,
DEBUG_UI_URL_MESSAGE,
DOMAIN,
- HA_MANAGED_RTSP_PORT,
HA_MANAGED_URL,
+ RECOMMENDED_VERSION,
)
from .server import Server
@@ -94,22 +96,13 @@ CONFIG_SCHEMA = vol.Schema(
extra=vol.ALLOW_EXTRA,
)
-_DATA_GO2RTC: HassKey[Go2RtcData] = HassKey(DOMAIN)
+_DATA_GO2RTC: HassKey[str] = HassKey(DOMAIN)
_RETRYABLE_ERRORS = (ClientConnectionError, ServerConnectionError)
-@dataclass(frozen=True)
-class Go2RtcData:
- """Data for go2rtc."""
-
- url: str
- managed: bool
-
-
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up WebRTC."""
url: str | None = None
- managed = False
if DOMAIN not in config and DEFAULT_CONFIG_DOMAIN not in config:
await _remove_go2rtc_entries(hass)
return True
@@ -144,9 +137,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
hass.bus.async_listen(EVENT_HOMEASSISTANT_STOP, on_stop)
url = HA_MANAGED_URL
- managed = True
- hass.data[_DATA_GO2RTC] = Go2RtcData(url, managed)
+ hass.data[_DATA_GO2RTC] = url
discovery_flow.async_create_flow(
hass, DOMAIN, context={"source": SOURCE_SYSTEM}, data={}
)
@@ -161,32 +153,42 @@ async def _remove_go2rtc_entries(hass: HomeAssistant) -> None:
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up go2rtc from a config entry."""
- data = hass.data[_DATA_GO2RTC]
+ url = hass.data[_DATA_GO2RTC]
# Validate the server URL
try:
- client = Go2RtcRestClient(async_get_clientsession(hass), data.url)
- await client.validate_server_version()
+ client = Go2RtcRestClient(async_get_clientsession(hass), url)
+ version = await client.validate_server_version()
+ if version < AwesomeVersion(RECOMMENDED_VERSION):
+ ir.async_create_issue(
+ hass,
+ DOMAIN,
+ "recommended_version",
+ is_fixable=False,
+ is_persistent=False,
+ severity=ir.IssueSeverity.WARNING,
+ translation_key="recommended_version",
+ translation_placeholders={
+ "recommended_version": RECOMMENDED_VERSION,
+ "current_version": str(version),
+ },
+ )
except Go2RtcClientError as err:
if isinstance(err.__cause__, _RETRYABLE_ERRORS):
raise ConfigEntryNotReady(
- f"Could not connect to go2rtc instance on {data.url}"
+ f"Could not connect to go2rtc instance on {url}"
) from err
- _LOGGER.warning(
- "Could not connect to go2rtc instance on %s (%s)", data.url, err
- )
+ _LOGGER.warning("Could not connect to go2rtc instance on %s (%s)", url, err)
return False
except Go2RtcVersionError as err:
raise ConfigEntryNotReady(
f"The go2rtc server version is not supported, {err}"
) from err
except Exception as err: # noqa: BLE001
- _LOGGER.warning(
- "Could not connect to go2rtc instance on %s (%s)", data.url, err
- )
+ _LOGGER.warning("Could not connect to go2rtc instance on %s (%s)", url, err)
return False
- provider = WebRTCProvider(hass, data)
+ provider = WebRTCProvider(hass, url)
async_register_webrtc_provider(hass, provider)
return True
@@ -204,12 +206,12 @@ async def _get_binary(hass: HomeAssistant) -> str | None:
class WebRTCProvider(CameraWebRTCProvider):
"""WebRTC provider."""
- def __init__(self, hass: HomeAssistant, data: Go2RtcData) -> None:
+ def __init__(self, hass: HomeAssistant, url: str) -> None:
"""Initialize the WebRTC provider."""
self._hass = hass
- self._data = data
+ self._url = url
self._session = async_get_clientsession(hass)
- self._rest_client = Go2RtcRestClient(self._session, data.url)
+ self._rest_client = Go2RtcRestClient(self._session, url)
self._sessions: dict[str, Go2RtcWsClient] = {}
@property
@@ -231,7 +233,7 @@ class WebRTCProvider(CameraWebRTCProvider):
) -> None:
"""Handle the WebRTC offer and return the answer via the provided callback."""
self._sessions[session_id] = ws_client = Go2RtcWsClient(
- self._session, self._data.url, source=camera.entity_id
+ self._session, self._url, source=camera.entity_id
)
if not (stream_source := await camera.stream_source()):
@@ -242,34 +244,18 @@ class WebRTCProvider(CameraWebRTCProvider):
streams = await self._rest_client.streams.list()
- if self._data.managed:
- # HA manages the go2rtc instance
- stream_original_name = f"{camera.entity_id}_original"
- stream_redirect_sources = [
- f"rtsp://127.0.0.1:{HA_MANAGED_RTSP_PORT}/{stream_original_name}",
- f"ffmpeg:{stream_original_name}#audio=opus",
- ]
-
- if (
- (stream_org := streams.get(stream_original_name)) is None
- or not any(
- stream_source == producer.url for producer in stream_org.producers
- )
- or (stream_redirect := streams.get(camera.entity_id)) is None
- or stream_redirect_sources != [p.url for p in stream_redirect.producers]
- ):
- await self._rest_client.streams.add(stream_original_name, stream_source)
- await self._rest_client.streams.add(
- camera.entity_id, stream_redirect_sources
- )
-
- # go2rtc instance is managed outside HA
- elif (stream_org := streams.get(camera.entity_id)) is None or not any(
- stream_source == producer.url for producer in stream_org.producers
+ if (stream := streams.get(camera.entity_id)) is None or not any(
+ stream_source == producer.url for producer in stream.producers
):
await self._rest_client.streams.add(
camera.entity_id,
- [stream_source, f"ffmpeg:{camera.entity_id}#audio=opus"],
+ [
+ stream_source,
+ # We are setting any ffmpeg rtsp related logs to debug
+ # Connection problems to the camera will be logged by the first stream
+ # Therefore setting it to debug will not hide any important logs
+ f"ffmpeg:{camera.entity_id}#audio=opus#query=log_level=debug",
+ ],
)
@callback
@@ -278,7 +264,7 @@ class WebRTCProvider(CameraWebRTCProvider):
value: WebRTCMessage
match message:
case WebRTCCandidate():
- value = HAWebRTCCandidate(RTCIceCandidate(message.candidate))
+ value = HAWebRTCCandidate(RTCIceCandidateInit(message.candidate))
case WebRTCAnswer():
value = HAWebRTCAnswer(message.sdp)
case WsError():
@@ -291,7 +277,7 @@ class WebRTCProvider(CameraWebRTCProvider):
await ws_client.send(WebRTCOffer(offer_sdp, config.configuration.ice_servers))
async def async_on_webrtc_candidate(
- self, session_id: str, candidate: RTCIceCandidate
+ self, session_id: str, candidate: RTCIceCandidateInit
) -> None:
"""Handle the WebRTC candidate."""
diff --git a/homeassistant/components/go2rtc/const.py b/homeassistant/components/go2rtc/const.py
index 3c4dc9a9500..3c1c84c42b5 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}/"
-HA_MANAGED_RTSP_PORT = 18554
+RECOMMENDED_VERSION = "1.9.7"
diff --git a/homeassistant/components/go2rtc/manifest.json b/homeassistant/components/go2rtc/manifest.json
index ea9308e5e18..07dbd3bd29b 100644
--- a/homeassistant/components/go2rtc/manifest.json
+++ b/homeassistant/components/go2rtc/manifest.json
@@ -7,6 +7,7 @@
"documentation": "https://www.home-assistant.io/integrations/go2rtc",
"integration_type": "system",
"iot_class": "local_polling",
- "requirements": ["go2rtc-client==0.1.0"],
+ "quality_scale": "internal",
+ "requirements": ["go2rtc-client==0.1.2"],
"single_config_entry": true
}
diff --git a/homeassistant/components/go2rtc/server.py b/homeassistant/components/go2rtc/server.py
index 91f4433546c..6699ee4d8a2 100644
--- a/homeassistant/components/go2rtc/server.py
+++ b/homeassistant/components/go2rtc/server.py
@@ -12,7 +12,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.aiohttp_client import async_get_clientsession
-from .const import HA_MANAGED_API_PORT, HA_MANAGED_RTSP_PORT, HA_MANAGED_URL
+from .const import HA_MANAGED_API_PORT, HA_MANAGED_URL
_LOGGER = logging.getLogger(__name__)
_TERMINATE_TIMEOUT = 5
@@ -33,7 +33,7 @@ api:
listen: "{api_ip}:{api_port}"
rtsp:
- listen: "127.0.0.1:{rtsp_port}"
+ listen: "127.0.0.1:18554"
webrtc:
listen: ":18555/tcp"
@@ -68,9 +68,7 @@ def _create_temp_file(api_ip: str) -> str:
with NamedTemporaryFile(prefix="go2rtc_", suffix=".yaml", delete=False) as file:
file.write(
_GO2RTC_CONFIG_FORMAT.format(
- api_ip=api_ip,
- api_port=HA_MANAGED_API_PORT,
- rtsp_port=HA_MANAGED_RTSP_PORT,
+ api_ip=api_ip, api_port=HA_MANAGED_API_PORT
).encode()
)
return file.name
diff --git a/homeassistant/components/go2rtc/strings.json b/homeassistant/components/go2rtc/strings.json
new file mode 100644
index 00000000000..e350c19af96
--- /dev/null
+++ b/homeassistant/components/go2rtc/strings.json
@@ -0,0 +1,8 @@
+{
+ "issues": {
+ "recommended_version": {
+ "title": "Outdated go2rtc server detected",
+ "description": "We detected that you are using an outdated go2rtc server version. For the best experience, we recommend updating the go2rtc server to version `{recommended_version}`.\nCurrently you are using version `{current_version}`."
+ }
+ }
+}
diff --git a/homeassistant/components/goalzero/manifest.json b/homeassistant/components/goalzero/manifest.json
index f1bfc7de876..a9fcbf26d36 100644
--- a/homeassistant/components/goalzero/manifest.json
+++ b/homeassistant/components/goalzero/manifest.json
@@ -15,6 +15,5 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["goalzero"],
- "quality_scale": "silver",
"requirements": ["goalzero==0.2.2"]
}
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 c029b46051e..acc69c3799a 100644
--- a/homeassistant/components/google/strings.json
+++ b/homeassistant/components/google/strings.json
@@ -45,7 +45,7 @@
}
},
"application_credentials": {
- "description": "Follow the [instructions]({more_info_url}) for [OAuth consent screen]({oauth_consent_url}) to give Home Assistant access to your Google Calendar. You also need to create Application Credentials linked to your Calendar:\n1. Go to [Credentials]({oauth_creds_url}) and select **Create Credentials**.\n1. From the drop-down list select **OAuth client ID**.\n1. Select **TV and Limited Input devices** for the Application Type."
+ "description": "Follow the [instructions]({more_info_url}) for [OAuth consent screen]({oauth_consent_url}) to give Home Assistant access to your Google Calendar. You also need to create Application Credentials linked to your account:\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."
},
"services": {
"add_event": {
@@ -87,8 +87,8 @@
}
},
"create_event": {
- "name": "Creates event",
- "description": "Add a new calendar event.",
+ "name": "Create event",
+ "description": "Adds a new calendar event.",
"fields": {
"summary": {
"name": "Summary",
diff --git a/homeassistant/components/google_assistant/const.py b/homeassistant/components/google_assistant/const.py
index 04c85639e07..8132ecaae2c 100644
--- a/homeassistant/components/google_assistant/const.py
+++ b/homeassistant/components/google_assistant/const.py
@@ -78,6 +78,7 @@ TYPE_AWNING = f"{PREFIX_TYPES}AWNING"
TYPE_BLINDS = f"{PREFIX_TYPES}BLINDS"
TYPE_CAMERA = f"{PREFIX_TYPES}CAMERA"
TYPE_CURTAIN = f"{PREFIX_TYPES}CURTAIN"
+TYPE_CARBON_MONOXIDE_DETECTOR = f"{PREFIX_TYPES}CARBON_MONOXIDE_DETECTOR"
TYPE_DEHUMIDIFIER = f"{PREFIX_TYPES}DEHUMIDIFIER"
TYPE_DOOR = f"{PREFIX_TYPES}DOOR"
TYPE_DOORBELL = f"{PREFIX_TYPES}DOORBELL"
@@ -93,6 +94,7 @@ TYPE_SCENE = f"{PREFIX_TYPES}SCENE"
TYPE_SENSOR = f"{PREFIX_TYPES}SENSOR"
TYPE_SETTOP = f"{PREFIX_TYPES}SETTOP"
TYPE_SHUTTER = f"{PREFIX_TYPES}SHUTTER"
+TYPE_SMOKE_DETECTOR = f"{PREFIX_TYPES}SMOKE_DETECTOR"
TYPE_SPEAKER = f"{PREFIX_TYPES}SPEAKER"
TYPE_SWITCH = f"{PREFIX_TYPES}SWITCH"
TYPE_THERMOSTAT = f"{PREFIX_TYPES}THERMOSTAT"
@@ -136,6 +138,7 @@ EVENT_SYNC_RECEIVED = "google_assistant_sync"
DOMAIN_TO_GOOGLE_TYPES = {
alarm_control_panel.DOMAIN: TYPE_ALARM,
+ binary_sensor.DOMAIN: TYPE_SENSOR,
button.DOMAIN: TYPE_SCENE,
camera.DOMAIN: TYPE_CAMERA,
climate.DOMAIN: TYPE_THERMOSTAT,
@@ -168,6 +171,14 @@ DEVICE_CLASS_TO_GOOGLE_TYPES = {
binary_sensor.DOMAIN,
binary_sensor.BinarySensorDeviceClass.GARAGE_DOOR,
): TYPE_GARAGE,
+ (
+ binary_sensor.DOMAIN,
+ binary_sensor.BinarySensorDeviceClass.SMOKE,
+ ): TYPE_SMOKE_DETECTOR,
+ (
+ binary_sensor.DOMAIN,
+ binary_sensor.BinarySensorDeviceClass.CO,
+ ): TYPE_CARBON_MONOXIDE_DETECTOR,
(cover.DOMAIN, cover.CoverDeviceClass.AWNING): TYPE_AWNING,
(cover.DOMAIN, cover.CoverDeviceClass.CURTAIN): TYPE_CURTAIN,
(cover.DOMAIN, cover.CoverDeviceClass.DOOR): TYPE_DOOR,
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/trait.py b/homeassistant/components/google_assistant/trait.py
index df56885995a..44251a3be04 100644
--- a/homeassistant/components/google_assistant/trait.py
+++ b/homeassistant/components/google_assistant/trait.py
@@ -553,15 +553,9 @@ class ColorSettingTrait(_Trait):
response["colorModel"] = "hsv"
if light.color_temp_supported(color_modes):
- # Max Kelvin is Min Mireds K = 1000000 / mireds
- # Min Kelvin is Max Mireds K = 1000000 / mireds
response["colorTemperatureRange"] = {
- "temperatureMaxK": color_util.color_temperature_mired_to_kelvin(
- attrs.get(light.ATTR_MIN_MIREDS)
- ),
- "temperatureMinK": color_util.color_temperature_mired_to_kelvin(
- attrs.get(light.ATTR_MAX_MIREDS)
- ),
+ "temperatureMaxK": int(attrs.get(light.ATTR_MAX_COLOR_TEMP_KELVIN)),
+ "temperatureMinK": int(attrs.get(light.ATTR_MIN_COLOR_TEMP_KELVIN)),
}
return response
@@ -583,7 +577,7 @@ class ColorSettingTrait(_Trait):
}
if light.color_temp_supported([color_mode]):
- temp = self.state.attributes.get(light.ATTR_COLOR_TEMP)
+ temp = self.state.attributes.get(light.ATTR_COLOR_TEMP_KELVIN)
# Some faulty integrations might put 0 in here, raising exception.
if temp == 0:
_LOGGER.warning(
@@ -592,9 +586,7 @@ class ColorSettingTrait(_Trait):
temp,
)
elif temp is not None:
- color["temperatureK"] = color_util.color_temperature_mired_to_kelvin(
- temp
- )
+ color["temperatureK"] = temp
response = {}
@@ -606,11 +598,9 @@ class ColorSettingTrait(_Trait):
async def execute(self, command, data, params, challenge):
"""Execute a color temperature command."""
if "temperature" in params["color"]:
- temp = color_util.color_temperature_kelvin_to_mired(
- params["color"]["temperature"]
- )
- min_temp = self.state.attributes[light.ATTR_MIN_MIREDS]
- max_temp = self.state.attributes[light.ATTR_MAX_MIREDS]
+ temp = params["color"]["temperature"]
+ max_temp = self.state.attributes[light.ATTR_MAX_COLOR_TEMP_KELVIN]
+ min_temp = self.state.attributes[light.ATTR_MIN_COLOR_TEMP_KELVIN]
if temp < min_temp or temp > max_temp:
raise SmartHomeError(
@@ -621,7 +611,10 @@ class ColorSettingTrait(_Trait):
await self.hass.services.async_call(
light.DOMAIN,
SERVICE_TURN_ON,
- {ATTR_ENTITY_ID: self.state.entity_id, light.ATTR_COLOR_TEMP: temp},
+ {
+ ATTR_ENTITY_ID: self.state.entity_id,
+ light.ATTR_COLOR_TEMP_KELVIN: temp,
+ },
blocking=not self.config.should_report_state,
context=data.context,
)
@@ -729,7 +722,7 @@ class DockTrait(_Trait):
def query_attributes(self) -> dict[str, Any]:
"""Return dock query attributes."""
- return {"isDocked": self.state.state == vacuum.STATE_DOCKED}
+ return {"isDocked": self.state.state == vacuum.VacuumActivity.DOCKED}
async def execute(self, command, data, params, challenge):
"""Execute a dock command."""
@@ -825,8 +818,8 @@ class EnergyStorageTrait(_Trait):
"capacityUntilFull": [
{"rawValue": 100 - battery_level, "unit": "PERCENTAGE"}
],
- "isCharging": self.state.state == vacuum.STATE_DOCKED,
- "isPluggedIn": self.state.state == vacuum.STATE_DOCKED,
+ "isCharging": self.state.state == vacuum.VacuumActivity.DOCKED,
+ "isPluggedIn": self.state.state == vacuum.VacuumActivity.DOCKED,
}
async def execute(self, command, data, params, challenge):
@@ -882,8 +875,8 @@ class StartStopTrait(_Trait):
if domain == vacuum.DOMAIN:
return {
- "isRunning": state == vacuum.STATE_CLEANING,
- "isPaused": state == vacuum.STATE_PAUSED,
+ "isRunning": state == vacuum.VacuumActivity.CLEANING,
+ "isPaused": state == vacuum.VacuumActivity.PAUSED,
}
if domain in COVER_VALVE_DOMAINS:
@@ -2706,6 +2699,21 @@ class SensorStateTrait(_Trait):
),
}
+ binary_sensor_types = {
+ binary_sensor.BinarySensorDeviceClass.CO: (
+ "CarbonMonoxideLevel",
+ ["carbon monoxide detected", "no carbon monoxide detected", "unknown"],
+ ),
+ binary_sensor.BinarySensorDeviceClass.SMOKE: (
+ "SmokeLevel",
+ ["smoke detected", "no smoke detected", "unknown"],
+ ),
+ binary_sensor.BinarySensorDeviceClass.MOISTURE: (
+ "WaterLeak",
+ ["leak", "no leak", "unknown"],
+ ),
+ }
+
name = TRAIT_SENSOR_STATE
commands: list[str] = []
@@ -2728,24 +2736,37 @@ class SensorStateTrait(_Trait):
@classmethod
def supported(cls, domain, features, device_class, _):
"""Test if state is supported."""
- return domain == sensor.DOMAIN and device_class in cls.sensor_types
+ return (domain == sensor.DOMAIN and device_class in cls.sensor_types) or (
+ domain == binary_sensor.DOMAIN and device_class in cls.binary_sensor_types
+ )
def sync_attributes(self) -> dict[str, Any]:
"""Return attributes for a sync request."""
device_class = self.state.attributes.get(ATTR_DEVICE_CLASS)
- data = self.sensor_types.get(device_class)
- if device_class is None or data is None:
- return {}
+ def create_sensor_state(
+ name: str,
+ raw_value_unit: str | None = None,
+ available_states: list[str] | None = None,
+ ) -> dict[str, Any]:
+ sensor_state: dict[str, Any] = {
+ "name": name,
+ }
+ if raw_value_unit:
+ sensor_state["numericCapabilities"] = {"rawValueUnit": raw_value_unit}
+ if available_states:
+ sensor_state["descriptiveCapabilities"] = {
+ "availableStates": available_states
+ }
+ return {"sensorStatesSupported": [sensor_state]}
- sensor_state = {
- "name": data[0],
- "numericCapabilities": {"rawValueUnit": data[1]},
- }
-
- if device_class == sensor.SensorDeviceClass.AQI:
- sensor_state["descriptiveCapabilities"] = {
- "availableStates": [
+ if self.state.domain == sensor.DOMAIN:
+ sensor_data = self.sensor_types.get(device_class)
+ if device_class is None or sensor_data is None:
+ return {}
+ available_states: list[str] | None = None
+ if device_class == sensor.SensorDeviceClass.AQI:
+ available_states = [
"healthy",
"moderate",
"unhealthy for sensitive groups",
@@ -2753,30 +2774,53 @@ class SensorStateTrait(_Trait):
"very unhealthy",
"hazardous",
"unknown",
- ],
- }
-
- return {"sensorStatesSupported": [sensor_state]}
+ ]
+ return create_sensor_state(sensor_data[0], sensor_data[1], available_states)
+ binary_sensor_data = self.binary_sensor_types.get(device_class)
+ if device_class is None or binary_sensor_data is None:
+ return {}
+ return create_sensor_state(
+ binary_sensor_data[0], available_states=binary_sensor_data[1]
+ )
def query_attributes(self) -> dict[str, Any]:
"""Return the attributes of this trait for this entity."""
device_class = self.state.attributes.get(ATTR_DEVICE_CLASS)
- data = self.sensor_types.get(device_class)
- if device_class is None or data is None:
+ def create_sensor_state(
+ name: str, raw_value: float | None = None, current_state: str | None = None
+ ) -> dict[str, Any]:
+ sensor_state: dict[str, Any] = {
+ "name": name,
+ "rawValue": raw_value,
+ }
+ if current_state:
+ sensor_state["currentSensorState"] = current_state
+ return {"currentSensorStateData": [sensor_state]}
+
+ if self.state.domain == sensor.DOMAIN:
+ sensor_data = self.sensor_types.get(device_class)
+ if device_class is None or sensor_data is None:
+ return {}
+ try:
+ value = float(self.state.state)
+ except ValueError:
+ value = None
+ if self.state.state == STATE_UNKNOWN:
+ value = None
+ current_state: str | None = None
+ if device_class == sensor.SensorDeviceClass.AQI:
+ current_state = self._air_quality_description_for_aqi(value)
+ return create_sensor_state(sensor_data[0], value, current_state)
+
+ binary_sensor_data = self.binary_sensor_types.get(device_class)
+ if device_class is None or binary_sensor_data is None:
return {}
-
- try:
- value = float(self.state.state)
- except ValueError:
- value = None
- if self.state.state == STATE_UNKNOWN:
- value = None
- sensor_data = {"name": data[0], "rawValue": value}
-
- if device_class == sensor.SensorDeviceClass.AQI:
- sensor_data["currentSensorState"] = self._air_quality_description_for_aqi(
- value
- )
-
- return {"currentSensorStateData": [sensor_data]}
+ value = {
+ STATE_ON: 0,
+ STATE_OFF: 1,
+ STATE_UNKNOWN: 2,
+ }[self.state.state]
+ return create_sensor_state(
+ binary_sensor_data[0], current_state=binary_sensor_data[1][value]
+ )
diff --git a/homeassistant/components/google_assistant_sdk/config_flow.py b/homeassistant/components/google_assistant_sdk/config_flow.py
index cd78c90e297..48c92832483 100644
--- a/homeassistant/components/google_assistant_sdk/config_flow.py
+++ b/homeassistant/components/google_assistant_sdk/config_flow.py
@@ -66,10 +66,6 @@ class OAuth2FlowHandler(
self._get_reauth_entry(), data=data
)
- if self._async_current_entries():
- # Config entry already exists, only one allowed.
- return self.async_abort(reason="single_instance_allowed")
-
return self.async_create_entry(
title=DEFAULT_NAME,
data=data,
diff --git a/homeassistant/components/google_assistant_sdk/manifest.json b/homeassistant/components/google_assistant_sdk/manifest.json
index b6281e2a4f0..85469a464b3 100644
--- a/homeassistant/components/google_assistant_sdk/manifest.json
+++ b/homeassistant/components/google_assistant_sdk/manifest.json
@@ -7,6 +7,6 @@
"documentation": "https://www.home-assistant.io/integrations/google_assistant_sdk",
"integration_type": "service",
"iot_class": "cloud_polling",
- "quality_scale": "platinum",
- "requirements": ["gassist-text==0.0.11"]
+ "requirements": ["gassist-text==0.0.11"],
+ "single_config_entry": true
}
diff --git a/homeassistant/components/google_cloud/const.py b/homeassistant/components/google_cloud/const.py
index f416d36483a..16b1463f0f3 100644
--- a/homeassistant/components/google_cloud/const.py
+++ b/homeassistant/components/google_cloud/const.py
@@ -20,6 +20,10 @@ CONF_GAIN = "gain"
CONF_PROFILES = "profiles"
CONF_TEXT_TYPE = "text_type"
+DEFAULT_SPEED = 1.0
+DEFAULT_PITCH = 0
+DEFAULT_GAIN = 0
+
# STT constants
CONF_STT_MODEL = "stt_model"
diff --git a/homeassistant/components/google_cloud/helpers.py b/homeassistant/components/google_cloud/helpers.py
index f6e89fae7fa..f1adc42b4cd 100644
--- a/homeassistant/components/google_cloud/helpers.py
+++ b/homeassistant/components/google_cloud/helpers.py
@@ -31,7 +31,10 @@ from .const import (
CONF_SPEED,
CONF_TEXT_TYPE,
CONF_VOICE,
+ DEFAULT_GAIN,
DEFAULT_LANG,
+ DEFAULT_PITCH,
+ DEFAULT_SPEED,
)
DEFAULT_VOICE = ""
@@ -104,15 +107,15 @@ def tts_options_schema(
),
vol.Optional(
CONF_SPEED,
- default=defaults.get(CONF_SPEED, 1.0),
+ default=defaults.get(CONF_SPEED, DEFAULT_SPEED),
): NumberSelector(NumberSelectorConfig(min=0.25, max=4.0, step=0.01)),
vol.Optional(
CONF_PITCH,
- default=defaults.get(CONF_PITCH, 0),
+ default=defaults.get(CONF_PITCH, DEFAULT_PITCH),
): NumberSelector(NumberSelectorConfig(min=-20.0, max=20.0, step=0.1)),
vol.Optional(
CONF_GAIN,
- default=defaults.get(CONF_GAIN, 0),
+ default=defaults.get(CONF_GAIN, DEFAULT_GAIN),
): NumberSelector(NumberSelectorConfig(min=-96.0, max=16.0, step=0.1)),
vol.Optional(
CONF_PROFILES,
diff --git a/homeassistant/components/google_cloud/tts.py b/homeassistant/components/google_cloud/tts.py
index c3a8254ad90..7f22dda4faf 100644
--- a/homeassistant/components/google_cloud/tts.py
+++ b/homeassistant/components/google_cloud/tts.py
@@ -35,7 +35,10 @@ from .const import (
CONF_SPEED,
CONF_TEXT_TYPE,
CONF_VOICE,
+ DEFAULT_GAIN,
DEFAULT_LANG,
+ DEFAULT_PITCH,
+ DEFAULT_SPEED,
DOMAIN,
)
from .helpers import async_tts_voices, tts_options_schema, tts_platform_schema
@@ -191,11 +194,23 @@ class BaseGoogleCloudProvider:
ssml_gender=gender,
name=voice,
),
+ # Avoid: "This voice does not support speaking rate or pitch parameters at this time."
+ # by not specifying the fields unless they differ from the defaults
audio_config=texttospeech.AudioConfig(
audio_encoding=encoding,
- speaking_rate=options[CONF_SPEED],
- pitch=options[CONF_PITCH],
- volume_gain_db=options[CONF_GAIN],
+ speaking_rate=(
+ options[CONF_SPEED]
+ if options[CONF_SPEED] != DEFAULT_SPEED
+ else None
+ ),
+ pitch=(
+ options[CONF_PITCH]
+ if options[CONF_PITCH] != DEFAULT_PITCH
+ else None
+ ),
+ volume_gain_db=(
+ options[CONF_GAIN] if options[CONF_GAIN] != DEFAULT_GAIN else None
+ ),
effects_profile_id=options[CONF_PROFILES],
),
)
diff --git a/homeassistant/components/google_generative_ai_conversation/conversation.py b/homeassistant/components/google_generative_ai_conversation/conversation.py
index 0d24ddbf39f..dad9c8a1920 100644
--- a/homeassistant/components/google_generative_ai_conversation/conversation.py
+++ b/homeassistant/components/google_generative_ai_conversation/conversation.py
@@ -204,9 +204,7 @@ class GoogleGenerativeAIConversationEntity(
"""Process a sentence."""
result = conversation.ConversationResult(
response=intent.IntentResponse(language=user_input.language),
- conversation_id=user_input.conversation_id
- if user_input.conversation_id in self.history
- else ulid.ulid_now(),
+ conversation_id=user_input.conversation_id or ulid.ulid_now(),
)
assert result.conversation_id
diff --git a/homeassistant/components/google_generative_ai_conversation/manifest.json b/homeassistant/components/google_generative_ai_conversation/manifest.json
index f390b1f83e9..7b687b7da6f 100644
--- a/homeassistant/components/google_generative_ai_conversation/manifest.json
+++ b/homeassistant/components/google_generative_ai_conversation/manifest.json
@@ -8,6 +8,5 @@
"documentation": "https://www.home-assistant.io/integrations/google_generative_ai_conversation",
"integration_type": "service",
"iot_class": "cloud_polling",
- "quality_scale": "platinum",
"requirements": ["google-generativeai==0.8.2"]
}
diff --git a/homeassistant/components/google_mail/strings.json b/homeassistant/components/google_mail/strings.json
index 2c6e24109c3..f93a8581e1c 100644
--- a/homeassistant/components/google_mail/strings.json
+++ b/homeassistant/components/google_mail/strings.json
@@ -68,10 +68,10 @@
},
"restrict_domain": {
"name": "Restrict to domain",
- "description": "Restrict automatic reply to domain. This only affects GSuite accounts."
+ "description": "Restrict automatic reply to domain. This only affects Google Workspace accounts."
},
"start": {
- "name": "[%key:common::action::start%]",
+ "name": "Start",
"description": "First day of the vacation."
},
"end": {
diff --git a/homeassistant/components/google_maps/manifest.json b/homeassistant/components/google_maps/manifest.json
index d7364e834a3..8311f75b732 100644
--- a/homeassistant/components/google_maps/manifest.json
+++ b/homeassistant/components/google_maps/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/google_maps",
"iot_class": "cloud_polling",
"loggers": ["locationsharinglib"],
+ "quality_scale": "legacy",
"requirements": ["locationsharinglib==5.0.1"]
}
diff --git a/homeassistant/components/google_photos/quality_scale.yaml b/homeassistant/components/google_photos/quality_scale.yaml
new file mode 100644
index 00000000000..ed313e13d6a
--- /dev/null
+++ b/homeassistant/components/google_photos/quality_scale.yaml
@@ -0,0 +1,68 @@
+rules:
+ # Bronze
+ config-flow: done
+ brands: done
+ dependency-transparency: done
+ common-modules: done
+ has-entity-name:
+ status: exempt
+ comment: Integration does not have entities
+ action-setup:
+ status: todo
+ comment: |
+ The integration does action setup in `async_setup_entry` which needs to be
+ moved to `async_setup`.
+ appropriate-polling: done
+ test-before-configure: done
+ entity-event-setup:
+ status: exempt
+ comment: Integration does not subscribe to events.
+ unique-config-entry: done
+ entity-unique-id: done
+ docs-installation-instructions: done
+ docs-removal-instructions: todo
+ test-before-setup: done
+ docs-high-level-description: done
+ config-flow-test-coverage: done
+ docs-actions: done
+ runtime-data: done
+
+ # Silver
+ log-when-unavailable: todo
+ config-entry-unloading: todo
+ reauthentication-flow: done
+ action-exceptions: todo
+ docs-installation-parameters: todo
+ integration-owner: todo
+ parallel-updates: todo
+ test-coverage: todo
+ docs-configuration-parameters: todo
+ entity-unavailable: todo
+
+ # Gold
+ docs-examples: todo
+ discovery-update-info: todo
+ entity-device-class: todo
+ entity-translations: todo
+ docs-data-update: todo
+ entity-disabled-by-default: todo
+ discovery: todo
+ exception-translations: todo
+ devices: todo
+ docs-supported-devices: todo
+ icon-translations: todo
+ docs-known-limitations: todo
+ stale-devices: todo
+ docs-supported-functions: todo
+ repair-issues: todo
+ reconfiguration-flow: todo
+ entity-category: todo
+ dynamic-devices: todo
+ docs-troubleshooting: todo
+ diagnostics: todo
+ docs-use-cases: todo
+
+ # Platinum
+ async-dependency: todo
+ strict-typing: todo
+ inject-websession: todo
diff --git a/homeassistant/components/google_photos/strings.json b/homeassistant/components/google_photos/strings.json
index bd565a6122d..aa4529ff5ea 100644
--- a/homeassistant/components/google_photos/strings.json
+++ b/homeassistant/components/google_photos/strings.json
@@ -48,7 +48,7 @@
"message": "`{filename}` is not an image"
},
"missing_upload_permission": {
- "message": "Home Assistnt was not granted permission to upload to Google Photos"
+ "message": "Home Assistant was not granted permission to upload to Google Photos"
},
"upload_error": {
"message": "Failed to upload content: {message}"
@@ -66,11 +66,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/manifest.json b/homeassistant/components/google_pubsub/manifest.json
index aa13f1808c4..9ea747898b2 100644
--- a/homeassistant/components/google_pubsub/manifest.json
+++ b/homeassistant/components/google_pubsub/manifest.json
@@ -4,5 +4,6 @@
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/google_pubsub",
"iot_class": "cloud_push",
+ "quality_scale": "legacy",
"requirements": ["google-cloud-pubsub==2.23.0"]
}
diff --git a/homeassistant/components/google_tasks/__init__.py b/homeassistant/components/google_tasks/__init__.py
index 29a1b20f2bc..45ad1777aa0 100644
--- a/homeassistant/components/google_tasks/__init__.py
+++ b/homeassistant/components/google_tasks/__init__.py
@@ -2,9 +2,10 @@
from __future__ import annotations
+import asyncio
+
from aiohttp import ClientError, ClientResponseError
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
@@ -12,11 +13,18 @@ from homeassistant.helpers import config_entry_oauth2_flow
from . import api
from .const import DOMAIN
+from .coordinator import TaskUpdateCoordinator
+from .exceptions import GoogleTasksApiError
+from .types import GoogleTasksConfigEntry
+
+__all__ = [
+ "DOMAIN",
+]
PLATFORMS: list[Platform] = [Platform.TODO]
-async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_setup_entry(hass: HomeAssistant, entry: GoogleTasksConfigEntry) -> bool:
"""Set up Google Tasks from a config entry."""
implementation = (
await config_entry_oauth2_flow.async_get_config_entry_implementation(
@@ -36,16 +44,36 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
except ClientError as err:
raise ConfigEntryNotReady from err
- hass.data.setdefault(DOMAIN, {})[entry.entry_id] = auth
+ try:
+ task_lists = await auth.list_task_lists()
+ except GoogleTasksApiError as err:
+ raise ConfigEntryNotReady from err
+
+ coordinators = [
+ TaskUpdateCoordinator(
+ hass,
+ auth,
+ task_list["id"],
+ task_list["title"],
+ )
+ for task_list in task_lists
+ ]
+ # Refresh all coordinators in parallel
+ 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: ConfigEntry) -> bool:
+async def async_unload_entry(
+ hass: HomeAssistant, entry: GoogleTasksConfigEntry
+) -> 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/google_tasks/api.py b/homeassistant/components/google_tasks/api.py
index 2a294b84654..f51c5103b87 100644
--- a/homeassistant/components/google_tasks/api.py
+++ b/homeassistant/components/google_tasks/api.py
@@ -9,6 +9,7 @@ from google.oauth2.credentials import Credentials
from googleapiclient.discovery import Resource, build
from googleapiclient.errors import HttpError
from googleapiclient.http import BatchHttpRequest, HttpRequest
+from httplib2 import ServerNotFoundError
from homeassistant.const import CONF_ACCESS_TOKEN
from homeassistant.core import HomeAssistant
@@ -115,7 +116,7 @@ class AsyncConfigEntryAuth:
def response_handler(_, response, exception: HttpError) -> None:
if exception is not None:
raise GoogleTasksApiError(
- f"Google Tasks API responded with error ({exception.status_code})"
+ f"Google Tasks API responded with error ({exception.reason or exception.status_code})"
) from exception
if response:
data = json.loads(response)
@@ -150,9 +151,9 @@ class AsyncConfigEntryAuth:
async def _execute(self, request: HttpRequest | BatchHttpRequest) -> Any:
try:
result = await self._hass.async_add_executor_job(request.execute)
- except HttpError as err:
+ except (HttpError, ServerNotFoundError) as err:
raise GoogleTasksApiError(
- f"Google Tasks API responded with error ({err.status_code})"
+ f"Google Tasks API responded with: {err.reason or err.status_code})"
) from err
if result:
_raise_if_error(result)
diff --git a/homeassistant/components/google_tasks/coordinator.py b/homeassistant/components/google_tasks/coordinator.py
index 5377e2be567..a06faf00a91 100644
--- a/homeassistant/components/google_tasks/coordinator.py
+++ b/homeassistant/components/google_tasks/coordinator.py
@@ -20,7 +20,11 @@ class TaskUpdateCoordinator(DataUpdateCoordinator[list[dict[str, Any]]]):
"""Coordinator for fetching Google Tasks for a Task List form the API."""
def __init__(
- self, hass: HomeAssistant, api: AsyncConfigEntryAuth, task_list_id: str
+ self,
+ hass: HomeAssistant,
+ api: AsyncConfigEntryAuth,
+ task_list_id: str,
+ task_list_title: str,
) -> None:
"""Initialize TaskUpdateCoordinator."""
super().__init__(
@@ -30,9 +34,10 @@ class TaskUpdateCoordinator(DataUpdateCoordinator[list[dict[str, Any]]]):
update_interval=UPDATE_INTERVAL,
)
self.api = api
- self._task_list_id = task_list_id
+ self.task_list_id = task_list_id
+ self.task_list_title = task_list_title
async def _async_update_data(self) -> list[dict[str, Any]]:
"""Fetch tasks from API endpoint."""
async with asyncio.timeout(TIMEOUT):
- return await self.api.list_tasks(self._task_list_id)
+ return await self.api.list_tasks(self.task_list_id)
diff --git a/homeassistant/components/google_tasks/quality_scale.yaml b/homeassistant/components/google_tasks/quality_scale.yaml
new file mode 100644
index 00000000000..dd1cd67d8e2
--- /dev/null
+++ b/homeassistant/components/google_tasks/quality_scale.yaml
@@ -0,0 +1,71 @@
+rules:
+ # Bronze
+ config-flow: done
+ brands: done
+ dependency-transparency: todo
+ common-modules:
+ status: exempt
+ comment: |
+ The integration has a coordinator.py and no base entities.
+ has-entity-name: done
+ action-setup:
+ status: exempt
+ comment: The integration does not register any actions.
+ appropriate-polling: done
+ test-before-configure: done
+ entity-event-setup:
+ status: exempt
+ comment: Integration does not subscribe to events.
+ unique-config-entry: done
+ entity-unique-id: done
+ docs-installation-instructions: done
+ docs-removal-instructions: done
+ test-before-setup: done
+ docs-high-level-description: done
+ config-flow-test-coverage: done
+ docs-actions:
+ status: exempt
+ comment: The integration does not register any actions.
+ runtime-data: done
+
+ # Silver
+ log-when-unavailable: done
+ config-entry-unloading: done
+ reauthentication-flow: done
+ action-exceptions: done
+ docs-installation-parameters: done
+ integration-owner: done
+ parallel-updates: done
+ test-coverage: done
+ docs-configuration-parameters:
+ status: exempt
+ comment: The integration does not have any configuration parameters.
+ entity-unavailable: done
+
+ # Gold
+ docs-examples: done
+ discovery-update-info: todo
+ entity-device-class: todo
+ entity-translations: todo
+ docs-data-update: done
+ entity-disabled-by-default: todo
+ discovery: todo
+ exception-translations: todo
+ devices: todo
+ docs-supported-devices: done
+ icon-translations: todo
+ docs-known-limitations: done
+ stale-devices: todo
+ docs-supported-functions: done
+ repair-issues: todo
+ reconfiguration-flow: todo
+ entity-category: todo
+ dynamic-devices: todo
+ docs-troubleshooting: done
+ diagnostics: todo
+ docs-use-cases: done
+
+ # Platinum
+ async-dependency: todo
+ strict-typing: todo
+ inject-websession: todo
diff --git a/homeassistant/components/google_tasks/todo.py b/homeassistant/components/google_tasks/todo.py
index 5196f89728d..1df5e5fc2e9 100644
--- a/homeassistant/components/google_tasks/todo.py
+++ b/homeassistant/components/google_tasks/todo.py
@@ -2,7 +2,7 @@
from __future__ import annotations
-from datetime import date, datetime, timedelta
+from datetime import UTC, date, datetime
from typing import Any, cast
from homeassistant.components.todo import (
@@ -11,17 +11,15 @@ from homeassistant.components.todo import (
TodoListEntity,
TodoListEntityFeature,
)
-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 homeassistant.util import dt as dt_util
-from .api import AsyncConfigEntryAuth
-from .const import DOMAIN
from .coordinator import TaskUpdateCoordinator
+from .types import GoogleTasksConfigEntry
-SCAN_INTERVAL = timedelta(minutes=15)
+PARALLEL_UPDATES = 0
TODO_STATUS_MAP = {
"needsAction": TodoItemStatus.NEEDS_ACTION,
@@ -39,8 +37,10 @@ def _convert_todo_item(item: TodoItem) -> dict[str, str | None]:
else:
result["status"] = TodoItemStatus.NEEDS_ACTION
if (due := item.due) is not None:
- # due API field is a timestamp string, but with only date resolution
- result["due"] = dt_util.start_of_local_day(due).isoformat()
+ # due API field is a timestamp string, but with only date resolution.
+ # The time portion of the date is always discarded by the API, so we
+ # always set to UTC.
+ result["due"] = dt_util.start_of_local_day(due).replace(tzinfo=UTC).isoformat()
else:
result["due"] = None
result["notes"] = item.description
@@ -51,6 +51,8 @@ def _convert_api_item(item: dict[str, str]) -> TodoItem:
"""Convert tasks API items into a TodoItem."""
due: date | None = None
if (due_str := item.get("due")) is not None:
+ # Due dates are returned always in UTC so we only need to
+ # parse the date portion which will be interpreted as a a local date.
due = datetime.fromisoformat(due_str).date()
return TodoItem(
summary=item["title"],
@@ -65,22 +67,21 @@ def _convert_api_item(item: dict[str, str]) -> TodoItem:
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: GoogleTasksConfigEntry,
+ async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Google Tasks todo platform."""
- api: AsyncConfigEntryAuth = hass.data[DOMAIN][entry.entry_id]
- task_lists = await api.list_task_lists()
async_add_entities(
(
GoogleTaskTodoListEntity(
- TaskUpdateCoordinator(hass, api, task_list["id"]),
- task_list["title"],
+ coordinator,
+ coordinator.task_list_title,
entry.entry_id,
- task_list["id"],
+ coordinator.task_list_id,
)
- for task_list in task_lists
+ for coordinator in entry.runtime_data
),
- True,
)
@@ -115,8 +116,6 @@ class GoogleTaskTodoListEntity(
@property
def todo_items(self) -> list[TodoItem] | None:
"""Get the current set of To-do items."""
- if self.coordinator.data is None:
- return None
return [_convert_api_item(item) for item in _order_tasks(self.coordinator.data)]
async def async_create_todo_item(self, item: TodoItem) -> None:
diff --git a/homeassistant/components/google_tasks/types.py b/homeassistant/components/google_tasks/types.py
new file mode 100644
index 00000000000..21500d11eb8
--- /dev/null
+++ b/homeassistant/components/google_tasks/types.py
@@ -0,0 +1,7 @@
+"""Types for the Google Tasks integration."""
+
+from homeassistant.config_entries import ConfigEntry
+
+from .coordinator import TaskUpdateCoordinator
+
+type GoogleTasksConfigEntry = ConfigEntry[list[TaskUpdateCoordinator]]
diff --git a/homeassistant/components/google_travel_time/sensor.py b/homeassistant/components/google_travel_time/sensor.py
index 618dda50bd4..a764036321b 100644
--- a/homeassistant/components/google_travel_time/sensor.py
+++ b/homeassistant/components/google_travel_time/sensor.py
@@ -7,6 +7,7 @@ import logging
from googlemaps import Client
from googlemaps.distance_matrix import distance_matrix
+from googlemaps.exceptions import ApiError, Timeout, TransportError
from homeassistant.components.sensor import (
SensorDeviceClass,
@@ -172,9 +173,13 @@ class GoogleTravelTimeSensor(SensorEntity):
self._resolved_destination,
)
if self._resolved_destination is not None and self._resolved_origin is not None:
- self._matrix = distance_matrix(
- self._client,
- self._resolved_origin,
- self._resolved_destination,
- **options_copy,
- )
+ try:
+ self._matrix = distance_matrix(
+ self._client,
+ self._resolved_origin,
+ self._resolved_destination,
+ **options_copy,
+ )
+ except (ApiError, TransportError, Timeout) as ex:
+ _LOGGER.error("Error getting travel time: %s", ex)
+ self._matrix = None
diff --git a/homeassistant/components/google_wifi/manifest.json b/homeassistant/components/google_wifi/manifest.json
index 200684b2e1c..a71558a7d6f 100644
--- a/homeassistant/components/google_wifi/manifest.json
+++ b/homeassistant/components/google_wifi/manifest.json
@@ -3,5 +3,6 @@
"name": "Google Wifi",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/google_wifi",
- "iot_class": "local_polling"
+ "iot_class": "local_polling",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/govee_ble/manifest.json b/homeassistant/components/govee_ble/manifest.json
index d9827e9155c..39a66ad36a7 100644
--- a/homeassistant/components/govee_ble/manifest.json
+++ b/homeassistant/components/govee_ble/manifest.json
@@ -122,7 +122,7 @@
"connectable": false
}
],
- "codeowners": ["@bdraco", "@PierreAronnax"],
+ "codeowners": ["@bdraco"],
"config_flow": true,
"dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/govee_ble",
diff --git a/homeassistant/components/graphite/manifest.json b/homeassistant/components/graphite/manifest.json
index da249a22829..cd50a5933f1 100644
--- a/homeassistant/components/graphite/manifest.json
+++ b/homeassistant/components/graphite/manifest.json
@@ -3,5 +3,6 @@
"name": "Graphite",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/graphite",
- "iot_class": "local_push"
+ "iot_class": "local_push",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/gree/climate.py b/homeassistant/components/gree/climate.py
index 6a8f48780c8..f197f21a4e1 100644
--- a/homeassistant/components/gree/climate.py
+++ b/homeassistant/components/gree/climate.py
@@ -126,7 +126,6 @@ class GreeClimateEntity(GreeEntity, ClimateEntity):
_attr_temperature_unit = UnitOfTemperature.CELSIUS
_attr_min_temp = TEMP_MIN
_attr_max_temp = TEMP_MAX
- _enable_turn_on_off_backwards_compatibility = False
def __init__(self, coordinator: DeviceDataUpdateCoordinator) -> None:
"""Initialize the Gree device."""
diff --git a/homeassistant/components/greeneye_monitor/manifest.json b/homeassistant/components/greeneye_monitor/manifest.json
index fcf4d004d26..15c4c2123e3 100644
--- a/homeassistant/components/greeneye_monitor/manifest.json
+++ b/homeassistant/components/greeneye_monitor/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/greeneye_monitor",
"iot_class": "local_push",
"loggers": ["greeneye"],
+ "quality_scale": "legacy",
"requirements": ["greeneye_monitor==3.0.3"]
}
diff --git a/homeassistant/components/greenwave/manifest.json b/homeassistant/components/greenwave/manifest.json
index 5cb3255192f..422d3bc512e 100644
--- a/homeassistant/components/greenwave/manifest.json
+++ b/homeassistant/components/greenwave/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/greenwave",
"iot_class": "local_polling",
"loggers": ["greenwavereality"],
+ "quality_scale": "legacy",
"requirements": ["greenwavereality==0.5.1"]
}
diff --git a/homeassistant/components/group/fan.py b/homeassistant/components/group/fan.py
index 03341b0f46b..87d9cb281f4 100644
--- a/homeassistant/components/group/fan.py
+++ b/homeassistant/components/group/fan.py
@@ -109,7 +109,6 @@ class FanGroup(GroupEntity, FanEntity):
"""Representation of a FanGroup."""
_attr_available: bool = False
- _enable_turn_on_off_backwards_compatibility = False
def __init__(self, unique_id: str | None, name: str, entities: list[str]) -> None:
"""Initialize a FanGroup entity."""
diff --git a/homeassistant/components/group/registry.py b/homeassistant/components/group/registry.py
index 7ac5770f171..2f3c4aa5221 100644
--- a/homeassistant/components/group/registry.py
+++ b/homeassistant/components/group/registry.py
@@ -11,7 +11,7 @@ from typing import Protocol
from homeassistant.components.alarm_control_panel import AlarmControlPanelState
from homeassistant.components.climate import HVACMode
from homeassistant.components.lock import LockState
-from homeassistant.components.vacuum import STATE_CLEANING, STATE_ERROR, STATE_RETURNING
+from homeassistant.components.vacuum import VacuumActivity
from homeassistant.components.water_heater import (
STATE_ECO,
STATE_ELECTRIC,
@@ -105,9 +105,9 @@ ON_OFF_STATES: dict[Platform | str, tuple[set[str], str, str]] = {
Platform.VACUUM: (
{
STATE_ON,
- STATE_CLEANING,
- STATE_RETURNING,
- STATE_ERROR,
+ VacuumActivity.CLEANING,
+ VacuumActivity.RETURNING,
+ VacuumActivity.ERROR,
},
STATE_ON,
STATE_OFF,
diff --git a/homeassistant/components/group/strings.json b/homeassistant/components/group/strings.json
index dbb6fb01f7b..fb90eb9b22c 100644
--- a/homeassistant/components/group/strings.json
+++ b/homeassistant/components/group/strings.json
@@ -3,7 +3,7 @@
"config": {
"step": {
"user": {
- "title": "Add Group",
+ "title": "Create Group",
"description": "Groups allow you to create a new entity that represents multiple entities of the same type.",
"menu_options": {
"binary_sensor": "Binary sensor group",
@@ -238,7 +238,7 @@
},
"set": {
"name": "Set",
- "description": "Creates/Updates a user group.",
+ "description": "Creates/Updates a group.",
"fields": {
"object_id": {
"name": "Object ID",
@@ -283,20 +283,20 @@
},
"issues": {
"uoms_not_matching_device_class": {
- "title": "Unit of measurements are not correct",
- "description": "Unit of measurements `{uoms}` of input sensors `{source_entities}` are not compatible and can't be converted with the device class `{device_class}` of sensor group `{entity_id}`.\n\nPlease correct the unit of measurements on the source entities and reload the group sensor to fix this issue."
+ "title": "Units of measurement are not correct",
+ "description": "Units of measurement `{uoms}` of input sensors `{source_entities}` are not compatible and can't be converted with the device class `{device_class}` of sensor group `{entity_id}`.\n\nPlease correct the unit of measurement on the source entities and reload the group sensor to fix this issue."
},
"uoms_not_matching_no_device_class": {
- "title": "Unit of measurements is not correct",
- "description": "Unit of measurements `{uoms}` of input sensors `{source_entities}` are not compatible when not using a device class on sensor group `{entity_id}`.\n\nPlease correct the unit of measurements on the source entities or set a proper device class on the sensor group and reload the group sensor to fix this issue."
+ "title": "Units of measurement are not correct",
+ "description": "Units of measurement `{uoms}` of input sensors `{source_entities}` are not compatible when not using a device class on sensor group `{entity_id}`.\n\nPlease correct the unit of measurement on the source entities or set a proper device class on the sensor group and reload the group sensor to fix this issue."
},
"device_classes_not_matching": {
- "title": "Device classes is not correct",
- "description": "Device classes `{device_classes}` on source entities `{source_entities}` needs to be same for sensor group `{entity_id}`.\n\nPlease correct the device classes on the source entities and reload the group sensor to fix this issue."
+ "title": "Device classes are not correct",
+ "description": "Device classes `{device_classes}` on source entities `{source_entities}` need to be identical for sensor group `{entity_id}`.\n\nPlease correct the device classes on the source entities and reload the group sensor to fix this issue."
},
"state_classes_not_matching": {
- "title": "State classes is not correct",
- "description": "State classes `{state_classes}` on source entities `{source_entities}` needs to be same for sensor group `{entity_id}`.\n\nPlease correct the state classes on the source entities and reload the group sensor to fix this issue."
+ "title": "State classes are not correct",
+ "description": "State classes `{state_classes}` on source entities `{source_entities}` need to be identical for sensor group `{entity_id}`.\n\nPlease correct the state classes on the source entities and reload the group sensor to fix this issue."
}
}
}
diff --git a/homeassistant/components/gstreamer/manifest.json b/homeassistant/components/gstreamer/manifest.json
index 95df94ef834..3ea9010a9d7 100644
--- a/homeassistant/components/gstreamer/manifest.json
+++ b/homeassistant/components/gstreamer/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/gstreamer",
"iot_class": "local_push",
"loggers": ["gsp"],
+ "quality_scale": "legacy",
"requirements": ["gstreamer-player==1.1.2"]
}
diff --git a/homeassistant/components/gtfs/manifest.json b/homeassistant/components/gtfs/manifest.json
index 73a5998ea92..3bf41a1c763 100644
--- a/homeassistant/components/gtfs/manifest.json
+++ b/homeassistant/components/gtfs/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/gtfs",
"iot_class": "local_polling",
"loggers": ["pygtfs"],
+ "quality_scale": "legacy",
"requirements": ["pygtfs==0.1.9"]
}
diff --git a/homeassistant/components/habitica/__init__.py b/homeassistant/components/habitica/__init__.py
index 5843e14d63e..9a9d689bedc 100644
--- a/homeassistant/components/habitica/__init__.py
+++ b/homeassistant/components/habitica/__init__.py
@@ -1,27 +1,15 @@
"""The habitica integration."""
-from http import HTTPStatus
-
-from aiohttp import ClientResponseError
-from habitipy.aio import HabitipyAsync
+from habiticalib import Habitica
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 +21,7 @@ PLATFORMS = [
Platform.BINARY_SENSOR,
Platform.BUTTON,
Platform.CALENDAR,
+ Platform.IMAGE,
Platform.SENSOR,
Platform.SWITCH,
Platform.TODO,
@@ -51,47 +40,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()
diff --git a/homeassistant/components/habitica/binary_sensor.py b/homeassistant/components/habitica/binary_sensor.py
index bc79370ea63..bf42348e2b8 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,
@@ -23,8 +24,8 @@ from .types import HabiticaConfigEntry
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 +34,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 +45,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 8b41fb8c987..14625b31c2b 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,23 +27,25 @@ 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
+PARALLEL_UPDATES = 1
+
@dataclass(kw_only=True, frozen=True)
class HabiticaButtonEntityDescription(ButtonEntityDescription):
"""Describes Habitica button entity."""
press_fn: Callable[[HabiticaDataUpdateCoordinator], Any]
- available_fn: Callable[[HabiticaData], bool] | None = None
- class_needed: str | None = None
+ available_fn: Callable[[HabiticaData], bool]
+ class_needed: HabiticaClass | None = None
entity_picture: str | None = None
-class HabitipyButtonEntity(StrEnum):
+class HabiticaButtonEntity(StrEnum):
"""Habitica button entities."""
RUN_CRON = "run_cron"
@@ -59,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",
),
)
@@ -283,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))
@@ -320,17 +331,17 @@ 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",
+ ) from e
+ except NotAuthorizedError as e:
+ raise ServiceValidationError(
+ translation_domain=DOMAIN,
+ translation_key="service_call_unallowed",
+ ) from e
+ except (HabiticaException, ClientError) as e:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="service_call_exception",
@@ -341,11 +352,10 @@ class HabiticaButton(HabiticaBase, ButtonEntity):
@property
def available(self) -> bool:
"""Is entity available."""
- if not super().available:
- return False
- if self.entity_description.available_fn:
- return self.entity_description.available_fn(self.coordinator.data)
- return True
+
+ return super().available and self.entity_description.available_fn(
+ self.coordinator.data
+ )
@property
def entity_picture(self) -> str | None:
diff --git a/homeassistant/components/habitica/calendar.py b/homeassistant/components/habitica/calendar.py
index 5a0470c3440..46191acf270 100644
--- a/homeassistant/components/habitica/calendar.py
+++ b/homeassistant/components/habitica/calendar.py
@@ -2,10 +2,14 @@
from __future__ import annotations
+from abc import abstractmethod
from datetime import date, datetime, timedelta
from enum import StrEnum
+from typing import TYPE_CHECKING
+from uuid import UUID
from dateutil.rrule import rrule
+from habiticalib import TaskType
from homeassistant.components.calendar import (
CalendarEntity,
@@ -19,7 +23,6 @@ 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
@@ -28,6 +31,8 @@ class HabiticaCalendar(StrEnum):
DAILIES = "dailys"
TODOS = "todos"
+ TODO_REMINDERS = "todo_reminders"
+ DAILY_REMINDERS = "daily_reminders"
async def async_setup_entry(
@@ -42,6 +47,8 @@ async def async_setup_entry(
[
HabiticaTodosCalendarEntity(coordinator),
HabiticaDailiesCalendarEntity(coordinator),
+ HabiticaTodoRemindersCalendarEntity(coordinator),
+ HabiticaDailyRemindersCalendarEntity(coordinator),
]
)
@@ -56,6 +63,41 @@ class HabiticaCalendarEntity(HabiticaBase, CalendarEntity):
"""Initialize calendar entity."""
super().__init__(coordinator, self.entity_description)
+ @abstractmethod
+ def get_events(
+ self, start_date: datetime, end_date: datetime | None = None
+ ) -> list[CalendarEvent]:
+ """Return events."""
+
+ @property
+ def event(self) -> CalendarEvent | None:
+ """Return the current or next upcoming event."""
+
+ return next(iter(self.get_events(dt_util.now())), None)
+
+ async def async_get_events(
+ self, hass: HomeAssistant, start_date: datetime, end_date: datetime
+ ) -> list[CalendarEvent]:
+ """Return calendar events within a datetime range."""
+
+ return self.get_events(start_date, end_date)
+
+ @property
+ def start_of_today(self) -> datetime:
+ """Habitica daystart."""
+ 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(
+ start_date, end_date - timedelta(days=1), inc=True
+ )
+ # if no end_date is given, return only the next recurrence
+ return [recurrences.after(start_date, inc=True)]
+
class HabiticaTodosCalendarEntity(HabiticaCalendarEntity):
"""Habitica todos calendar entity."""
@@ -65,7 +107,7 @@ class HabiticaTodosCalendarEntity(HabiticaCalendarEntity):
translation_key=HabiticaCalendar.TODOS,
)
- def dated_todos(
+ def get_events(
self, start_date: datetime, end_date: datetime | None = None
) -> list[CalendarEvent]:
"""Get all dated todos."""
@@ -73,13 +115,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
@@ -90,36 +132,26 @@ 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)),
),
)
- @property
- def event(self) -> CalendarEvent | None:
- """Return the current or next upcoming event."""
-
- return next(iter(self.dated_todos(dt_util.now())), None)
-
- async def async_get_events(
- self, hass: HomeAssistant, start_date: datetime, end_date: datetime
- ) -> list[CalendarEvent]:
- """Return calendar events within a datetime range."""
- return self.dated_todos(start_date, end_date)
-
class HabiticaDailiesCalendarEntity(HabiticaCalendarEntity):
"""Habitica dailies calendar entity."""
@@ -129,13 +161,6 @@ class HabiticaDailiesCalendarEntity(HabiticaCalendarEntity):
translation_key=HabiticaCalendar.DAILIES,
)
- @property
- def today(self) -> datetime:
- """Habitica daystart."""
- return dt_util.start_of_local_day(
- datetime.fromisoformat(self.coordinator.data.user["lastCron"])
- )
-
def end_date(self, recurrence: datetime, end: datetime | None = None) -> date:
"""Calculate the end date for a yesterdaily.
@@ -148,34 +173,25 @@ class HabiticaDailiesCalendarEntity(HabiticaCalendarEntity):
if end:
return recurrence.date() + timedelta(days=1)
return (
- dt_util.start_of_local_day() if recurrence == self.today else recurrence
+ dt_util.start_of_local_day()
+ if recurrence == self.start_of_today
+ else recurrence
).date() + timedelta(days=1)
- 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(
- start_date, end_date - timedelta(days=1), inc=True
- )
- # if no end_date is given, return only the next recurrence
- return [recurrences.after(self.today, inc=True)]
-
- def due_dailies(
+ def get_events(
self, start_date: datetime, end_date: datetime | None = None
) -> list[CalendarEvent]:
"""Get dailies and recurrences for a given period or the next upcoming."""
# we only have dailies for today and future recurrences
- if end_date and end_date < self.today:
+ if end_date and end_date < self.start_of_today:
return []
- start_date = max(start_date, self.today)
+ start_date = max(start_date, self.start_of_today)
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
recurrences = build_rrule(task)
@@ -183,19 +199,23 @@ class HabiticaDailiesCalendarEntity(HabiticaCalendarEntity):
recurrences, start_date, end_date
)
for recurrence in recurrence_dates:
- is_future_event = recurrence > self.today
- is_current_event = recurrence <= self.today and not task["completed"]
+ is_future_event = recurrence > self.start_of_today
+ is_current_event = (
+ recurrence <= self.start_of_today and not task.completed
+ )
- if not (is_future_event or is_current_event):
+ 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),
)
)
@@ -203,25 +223,154 @@ 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)),
),
)
@property
def event(self) -> CalendarEvent | None:
"""Return the next upcoming event."""
- return next(iter(self.due_dailies(self.today)), None)
-
- async def async_get_events(
- self, hass: HomeAssistant, start_date: datetime, end_date: datetime
- ) -> list[CalendarEvent]:
- """Return calendar events within a datetime range."""
-
- return self.due_dailies(start_date, end_date)
+ return next(iter(self.get_events(self.start_of_today)), None)
@property
def extra_state_attributes(self) -> dict[str, bool | None] | None:
"""Return entity specific state attributes."""
return {
- "yesterdaily": self.event.start < self.today.date() if self.event else None
+ "yesterdaily": self.event.start < self.start_of_today.date()
+ if self.event
+ else None
}
+
+
+class HabiticaTodoRemindersCalendarEntity(HabiticaCalendarEntity):
+ """Habitica to-do reminders calendar entity."""
+
+ entity_description = CalendarEntityDescription(
+ key=HabiticaCalendar.TODO_REMINDERS,
+ translation_key=HabiticaCalendar.TODO_REMINDERS,
+ )
+
+ def get_events(
+ self, start_date: datetime, end_date: datetime | None = None
+ ) -> list[CalendarEvent]:
+ """Reminders for todos."""
+
+ events = []
+
+ for task in self.coordinator.data.tasks:
+ if task.Type is not TaskType.TODO or task.completed:
+ continue
+
+ 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 = reminder.time.replace(
+ tzinfo=dt_util.DEFAULT_TIME_ZONE, second=0, microsecond=0
+ )
+ end = start + timedelta(hours=1)
+
+ if end < start_date:
+ # Event ends before date range
+ continue
+
+ 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}",
+ )
+ )
+
+ return sorted(
+ events,
+ key=lambda event: event.start,
+ )
+
+
+class HabiticaDailyRemindersCalendarEntity(HabiticaCalendarEntity):
+ """Habitica daily reminders calendar entity."""
+
+ entity_description = CalendarEntityDescription(
+ key=HabiticaCalendar.DAILY_REMINDERS,
+ translation_key=HabiticaCalendar.DAILY_REMINDERS,
+ )
+
+ 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,
+ only the time part is evaluated. The dates for the reminders are the
+ dailies' due dates.
+ """
+ return datetime.combine(
+ reminder_date,
+ reminder_time.replace(
+ second=0,
+ microsecond=0,
+ ).time(),
+ tzinfo=dt_util.DEFAULT_TIME_ZONE,
+ )
+
+ def get_events(
+ self, start_date: datetime, end_date: datetime | None = None
+ ) -> list[CalendarEvent]:
+ """Reminders for dailies."""
+
+ events = []
+ if end_date and end_date < self.start_of_today:
+ return []
+ start_date = max(start_date, self.start_of_today)
+
+ for task in self.coordinator.data.tasks:
+ if not (task.Type is TaskType.DAILY and task.everyX):
+ continue
+
+ recurrences = build_rrule(task)
+ recurrences_start = self.start_of_today
+
+ recurrence_dates = self.get_recurrence_dates(
+ recurrences, recurrences_start, end_date
+ )
+ 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
+ )
+
+ if not is_future_event and not is_current_event:
+ continue
+
+ 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}",
+ )
+ )
+
+ return sorted(
+ events,
+ key=lambda event: event.start,
+ )
diff --git a/homeassistant/components/habitica/config_flow.py b/homeassistant/components/habitica/config_flow.py
index 88f3d1b803c..0c7ce1fdfdb 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,7 +33,19 @@ from homeassistant.helpers.selector import (
TextSelectorType,
)
-from .const import CONF_API_USER, DEFAULT_URL, DOMAIN
+from . import HabiticaConfigEntry
+from .const import (
+ CONF_API_USER,
+ DEFAULT_URL,
+ DOMAIN,
+ FORGOT_PASSWORD_URL,
+ HABITICANS_URL,
+ SECTION_REAUTH_API_KEY,
+ SECTION_REAUTH_LOGIN,
+ SIGN_UP_URL,
+ SITE_DATA_URL,
+ X_CLIENT,
+)
STEP_ADVANCED_DATA_SCHEMA = vol.Schema(
{
@@ -53,14 +73,44 @@ 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},
+ ),
+ }
+)
+
_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:
@@ -69,6 +119,10 @@ class HabiticaConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_show_menu(
step_id="user",
menu_options=["login", "advanced"],
+ description_placeholders={
+ "signup": SIGN_UP_URL,
+ "habiticans": HABITICANS_URL,
+ },
)
async def async_step_login(
@@ -81,39 +135,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,
},
@@ -125,6 +160,7 @@ class HabiticaConfigFlow(ConfigFlow, domain=DOMAIN):
data_schema=STEP_LOGIN_DATA_SCHEMA, suggested_values=user_input
),
errors=errors,
+ description_placeholders={"forgot_password": FORGOT_PASSWORD_URL},
)
async def async_step_advanced(
@@ -137,36 +173,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(
@@ -175,4 +194,125 @@ class HabiticaConfigFlow(ConfigFlow, domain=DOMAIN):
data_schema=STEP_ADVANCED_DATA_SCHEMA, suggested_values=user_input
),
errors=errors,
+ description_placeholders={
+ "site_data": SITE_DATA_URL,
+ "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 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 55322a13e6a..47191e92775 100644
--- a/homeassistant/components/habitica/const.py
+++ b/homeassistant/components/habitica/const.py
@@ -1,11 +1,16 @@
"""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"
DEFAULT_URL = "https://habitica.com"
ASSETS_URL = "https://habitica-assets.s3.amazonaws.com/mobileApp/images/"
+SITE_DATA_URL = "https://habitica.com/user/settings/siteData"
+FORGOT_PASSWORD_URL = "https://habitica.com/forgot-password"
+SIGN_UP_URL = "https://habitica.com/register"
+HABITICANS_URL = "https://habitica.com/static/img/home-main@3x.ffc32b12.png"
+
DOMAIN = "habitica"
# service constants
@@ -20,16 +25,34 @@ ATTR_DATA = "data"
MANUFACTURER = "HabitRPG, Inc."
NAME = "Habitica"
-UNIT_TASKS = "tasks"
-
ATTR_CONFIG_ENTRY = "config_entry"
ATTR_SKILL = "skill"
ATTR_TASK = "task"
-SERVICE_CAST_SKILL = "cast_skill"
+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"
+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"
diff --git a/homeassistant/components/habitica/coordinator.py b/homeassistant/components/habitica/coordinator.py
index cce2c684ba8..587f8148398 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,20 +65,53 @@ class HabiticaDataUpdateCoordinator(DataUpdateCoordinator[HabiticaData]):
immediate=False,
),
)
- self.api = habitipy
+ 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",
+ ) from e
+ except (HabiticaException, ClientError) as e:
+ raise ConfigEntryNotReady(
+ translation_domain=DOMAIN,
+ translation_key="service_call_exception",
+ ) 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"))
- 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, ClientError) as e:
+ raise UpdateFailed(f"Unable to connect to Habitica: {e}") from e
+ else:
+ return HabiticaData(user=user, tasks=tasks + completed_todos)
async def execute(
self, func: Callable[[HabiticaDataUpdateCoordinator], Any]
@@ -72,15 +120,25 @@ 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",
+ ) from e
+ except (HabiticaException, ClientError) as e:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="service_call_exception",
) 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
new file mode 100644
index 00000000000..abfa0f35c4b
--- /dev/null
+++ b/homeassistant/components/habitica/diagnostics.py
@@ -0,0 +1,27 @@
+"""Diagnostics platform for Habitica integration."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from homeassistant.const import CONF_URL
+from homeassistant.core import HomeAssistant
+
+from .const import CONF_API_USER
+from .types import HabiticaConfigEntry
+
+
+async def async_get_config_entry_diagnostics(
+ hass: HomeAssistant, config_entry: HabiticaConfigEntry
+) -> dict[str, Any]:
+ """Return diagnostics for a config entry."""
+
+ 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.to_dict()["data"],
+ }
diff --git a/homeassistant/components/habitica/icons.json b/homeassistant/components/habitica/icons.json
index 0698b85afe1..b74600a2789 100644
--- a/homeassistant/components/habitica/icons.json
+++ b/homeassistant/components/habitica/icons.json
@@ -64,6 +64,12 @@
},
"dailys": {
"default": "mdi:calendar-multiple"
+ },
+ "todo_reminders": {
+ "default": "mdi:reminder"
+ },
+ "daily_reminders": {
+ "default": "mdi:reminder"
}
},
"sensor": {
@@ -115,17 +121,23 @@
"rogue": "mdi:ninja"
}
},
- "todos": {
- "default": "mdi:checkbox-outline"
- },
- "dailys": {
- "default": "mdi:calendar-month"
- },
"habits": {
"default": "mdi:contrast-box"
},
"rewards": {
"default": "mdi:treasure-chest"
+ },
+ "strength": {
+ "default": "mdi:arm-flex-outline"
+ },
+ "intelligence": {
+ "default": "mdi:head-snowflake-outline"
+ },
+ "perception": {
+ "default": "mdi:eye-outline"
+ },
+ "constitution": {
+ "default": "mdi:run-fast"
}
},
"switch": {
@@ -151,6 +163,39 @@
},
"cast_skill": {
"service": "mdi:creation-outline"
+ },
+ "accept_quest": {
+ "service": "mdi:script-text"
+ },
+ "reject_quest": {
+ "service": "mdi:script-text"
+ },
+ "leave_quest": {
+ "service": "mdi:script-text"
+ },
+ "abort_quest": {
+ "service": "mdi:script-text-key"
+ },
+ "cancel_quest": {
+ "service": "mdi:script-text-key"
+ },
+ "start_quest": {
+ "service": "mdi:script-text-key"
+ },
+ "score_habit": {
+ "service": "mdi:counter"
+ },
+ "score_reward": {
+ "service": "mdi:sack"
+ },
+ "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..27b406c475c
--- /dev/null
+++ b/homeassistant/components/habitica/image.py
@@ -0,0 +1,76 @@
+"""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
+
+
+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 8e3396d32cf..a1c1ae7787b 100644
--- a/homeassistant/components/habitica/manifest.json
+++ b/homeassistant/components/habitica/manifest.json
@@ -1,10 +1,10 @@
{
"domain": "habitica",
"name": "Habitica",
- "codeowners": ["@ASMfreaK", "@leikoilja", "@tr4nt0r"],
+ "codeowners": ["@tr4nt0r"],
"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.2"]
}
diff --git a/homeassistant/components/habitica/quality_scale.yaml b/homeassistant/components/habitica/quality_scale.yaml
new file mode 100644
index 00000000000..f1023e3d0dc
--- /dev/null
+++ b/homeassistant/components/habitica/quality_scale.yaml
@@ -0,0 +1,84 @@
+rules:
+ # Bronze
+ action-setup: done
+ 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: done
+ dependency-transparency: todo
+ docs-actions: done
+ docs-high-level-description: done
+ docs-installation-instructions: done
+ docs-removal-instructions: done
+ entity-event-setup:
+ status: exempt
+ comment: No events are registered by the integration.
+ 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: There is no options flow.
+ docs-installation-parameters: done
+ entity-unavailable: done
+ integration-owner: done
+ log-when-unavailable: done
+ parallel-updates: todo
+ reauthentication-flow: done
+ test-coverage: done
+
+ # Gold
+ devices: done
+ diagnostics: done
+ discovery-update-info:
+ status: exempt
+ comment: Integration represents a service
+ discovery:
+ status: exempt
+ comment: Integration represents a service
+ docs-data-update: done
+ docs-examples: done
+ docs-known-limitations: done
+ docs-supported-devices:
+ status: exempt
+ comment: No supportable devices.
+ docs-supported-functions: done
+ docs-troubleshooting: todo
+ docs-use-cases: done
+ dynamic-devices:
+ status: exempt
+ comment: |
+ Integration is a service, no devices that could be added at runtime.
+ Button entities for casting skills are created/removed dynamically if unlocked or on class change
+ entity-category:
+ status: done
+ comment: Default categories are appropriate for currently available entities.
+ entity-device-class: done
+ entity-disabled-by-default: done
+ entity-translations: done
+ exception-translations:
+ status: todo
+ comment: translations for UpdateFailed missing
+ icon-translations: done
+ reconfiguration-flow: todo
+ repair-issues:
+ status: done
+ comment: Used to inform of deprecated entities and actions.
+ stale-devices:
+ status: done
+ comment: Not applicable. Only one device per config entry. Removed together with the config entry.
+
+ # Platinum
+ async-dependency: todo
+ inject-websession: done
+ strict-typing: todo
diff --git a/homeassistant/components/habitica/sensor.py b/homeassistant/components/habitica/sensor.py
index 77356f88265..60dbf0d99b0 100644
--- a/homeassistant/components/habitica/sensor.py
+++ b/homeassistant/components/habitica/sensor.py
@@ -3,51 +3,57 @@
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,
+)
from homeassistant.components.sensor import (
- DOMAIN as SENSOR_DOMAIN,
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
)
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 homeassistant.helpers.typing import StateType
-from .const import DOMAIN, UNIT_TASKS
+from .const import ASSETS_URL
from .entity import HabiticaBase
from .types import HabiticaConfigEntry
-from .util import entity_used_in
+from .util import get_attribute_points, get_attributes_total
_LOGGER = logging.getLogger(__name__)
@dataclass(kw_only=True, frozen=True)
-class HabitipySensorEntityDescription(SensorEntityDescription):
- """Habitipy Sensor Description."""
+class HabiticaSensorEntityDescription(SensorEntityDescription):
+ """Habitica Sensor Description."""
- value_fn: Callable[[dict[str, Any]], StateType]
+ 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"
@@ -60,95 +66,118 @@ class HabitipySensorEntity(StrEnum):
GOLD = "gold"
CLASS = "class"
HABITS = "habits"
- DAILIES = "dailys"
- TODOS = "todos"
REWARDS = "rewards"
GEMS = "gems"
TRINKETS = "trinkets"
+ STRENGTH = "strength"
+ INTELLIGENCE = "intelligence"
+ CONSTITUTION = "constitution"
+ PERCEPTION = "perception"
-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,
- native_unit_of_measurement="HP",
+ 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,
),
- HabitipySensorEntityDescription(
- key=HabitipySensorEntity.HEALTH_MAX,
- translation_key=HabitipySensorEntity.HEALTH_MAX,
- native_unit_of_measurement="HP",
+ 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,
- native_unit_of_measurement="MP",
+ 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,
),
- HabitipySensorEntityDescription(
- key=HabitipySensorEntity.MANA_MAX,
- translation_key=HabitipySensorEntity.MANA_MAX,
- native_unit_of_measurement="MP",
- 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,
),
- HabitipySensorEntityDescription(
- key=HabitipySensorEntity.EXPERIENCE,
- translation_key=HabitipySensorEntity.EXPERIENCE,
- native_unit_of_measurement="XP",
- value_fn=lambda user: user.get("stats", {}).get("exp"),
+ HabiticaSensorEntityDescription(
+ key=HabiticaSensorEntity.EXPERIENCE,
+ translation_key=HabiticaSensorEntity.EXPERIENCE,
+ value_fn=lambda user, _: user.stats.exp,
),
- HabitipySensorEntityDescription(
- key=HabitipySensorEntity.EXPERIENCE_MAX,
- translation_key=HabitipySensorEntity.EXPERIENCE_MAX,
- native_unit_of_measurement="XP",
- 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,
),
- 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,
- native_unit_of_measurement="GP",
+ 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,
),
- 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, _: round(user.balance * 4) if user.balance else None,
suggested_display_precision=0,
- native_unit_of_measurement="gems",
+ 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 or 0,
suggested_display_precision=0,
native_unit_of_measurement="⧖",
+ entity_picture="notif_subscriber_reward.png",
+ ),
+ 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",
+ ),
+ 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",
+ ),
+ 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",
+ ),
+ 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",
),
)
@@ -169,7 +198,7 @@ TASKS_MAP = {
"yester_daily": "yesterDaily",
"completed": "completed",
"collapse_checklist": "collapseChecklist",
- "type": "type",
+ "type": "Type",
"notes": "notes",
"tags": "tags",
"value": "value",
@@ -183,34 +212,16 @@ TASKS_MAP = {
}
-TASK_SENSOR_DESCRIPTION: tuple[HabitipyTaskSensorEntityDescription, ...] = (
- HabitipyTaskSensorEntityDescription(
- key=HabitipySensorEntity.HABITS,
- translation_key=HabitipySensorEntity.HABITS,
- native_unit_of_measurement=UNIT_TASKS,
- 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,
- native_unit_of_measurement=UNIT_TASKS,
- 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,
- native_unit_of_measurement=UNIT_TASKS,
- 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,
- native_unit_of_measurement=UNIT_TASKS,
- 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],
),
)
@@ -225,31 +236,47 @@ async def async_setup_entry(
coordinator = config_entry.runtime_data
entities: list[SensorEntity] = [
- HabitipySensor(coordinator, description) for description in SENSOR_DESCRIPTIONS
+ HabiticaSensor(coordinator, description) for description in SENSOR_DESCRIPTIONS
]
entities.extend(
- HabitipyTaskSensor(coordinator, description)
+ HabiticaTaskSensor(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:
"""Return the state of the device."""
- return self.entity_description.value_fn(self.coordinator.data.user)
+ return self.entity_description.value_fn(
+ self.coordinator.data.user, self.coordinator.content
+ )
+
+ @property
+ def extra_state_attributes(self) -> dict[str, float | None] | None:
+ """Return entity specific state attributes."""
+ if func := self.entity_description.attributes_fn:
+ return func(self.coordinator.data.user, self.coordinator.content)
+ return None
+
+ @property
+ def entity_picture(self) -> str | None:
+ """Return the entity picture to use in the frontend, if any."""
+ if entity_picture := self.entity_description.entity_picture:
+ return 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:
@@ -263,47 +290,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 440e2d4fb23..5961c139003 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
@@ -19,19 +30,37 @@ from homeassistant.core import (
)
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.helpers.selector import ConfigEntrySelector
from .const import (
ATTR_ARGS,
ATTR_CONFIG_ENTRY,
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,
+ SERVICE_ACCEPT_QUEST,
SERVICE_API_CALL,
+ SERVICE_CANCEL_QUEST,
SERVICE_CAST_SKILL,
+ SERVICE_GET_TASKS,
+ SERVICE_LEAVE_QUEST,
+ SERVICE_REJECT_QUEST,
+ SERVICE_SCORE_HABIT,
+ SERVICE_SCORE_REWARD,
+ SERVICE_START_QUEST,
+ SERVICE_TRANSFORMATION,
)
from .types import HabiticaConfigEntry
@@ -54,6 +83,61 @@ SERVICE_CAST_SKILL_SCHEMA = vol.Schema(
}
)
+SERVICE_MANAGE_QUEST_SCHEMA = vol.Schema(
+ {
+ vol.Required(ATTR_CONFIG_ENTRY): ConfigEntrySelector(),
+ }
+)
+SERVICE_SCORE_TASK_SCHEMA = vol.Schema(
+ {
+ vol.Required(ATTR_CONFIG_ENTRY): ConfigEntrySelector(),
+ vol.Required(ATTR_TASK): cv.string,
+ vol.Optional(ATTR_DIRECTION): cv.string,
+ }
+)
+
+SERVICE_TRANSFORMATION_SCHEMA = vol.Schema(
+ {
+ vol.Required(ATTR_CONFIG_ENTRY): ConfigEntrySelector(),
+ vol.Required(ATTR_ITEM): cv.string,
+ vol.Required(ATTR_TARGET): cv.string,
+ }
+)
+
+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."""
@@ -70,18 +154,31 @@ def get_config_entry(hass: HomeAssistant, entry_id: str) -> HabiticaConfigEntry:
return entry
-def async_setup_services(hass: HomeAssistant) -> None:
+def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901
"""Set up services for Habitica integration."""
async def handle_api_call(call: ServiceCall) -> None:
+ async_create_issue(
+ hass,
+ DOMAIN,
+ "deprecated_api_call",
+ breaks_in_ha_version="2025.6.0",
+ is_fixable=False,
+ severity=IssueSeverity.WARNING,
+ translation_key="deprecated_api_call",
+ )
+ _LOGGER.warning(
+ "Deprecated action called: 'habitica.api_call' is deprecated and will be removed in Home Assistant version 2025.6.0"
+ )
+
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)
@@ -104,18 +201,15 @@ def async_setup_services(hass: HomeAssistant) -> None:
"""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(
@@ -125,40 +219,258 @@ def async_setup_services(hass: HomeAssistant) -> None:
) 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",
+ ) 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, ClientError) as e:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="service_call_exception",
) 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
+
+ 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:
+ response = await func()
+ except TooManyRequestsError as e:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="setup_rate_limit_exception",
+ ) 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, ClientError) as e:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN, translation_key="service_call_exception"
+ ) from e
+ else:
+ return asdict(response.data)
+
+ for service in (
+ SERVICE_ABORT_QUEST,
+ SERVICE_ACCEPT_QUEST,
+ SERVICE_CANCEL_QUEST,
+ SERVICE_LEAVE_QUEST,
+ SERVICE_REJECT_QUEST,
+ SERVICE_START_QUEST,
+ ):
+ hass.services.async_register(
+ DOMAIN,
+ service,
+ manage_quests,
+ schema=SERVICE_MANAGE_QUEST_SCHEMA,
+ supports_response=SupportsResponse.ONLY,
+ )
+
+ async def score_task(call: ServiceCall) -> ServiceResponse:
+ """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.value)
+ for task in coordinator.data.tasks
+ if call.data[ATTR_TASK] in (str(task.id), task.alias, task.text)
+ )
+ except StopIteration as e:
+ raise ServiceValidationError(
+ translation_domain=DOMAIN,
+ translation_key="task_not_found",
+ translation_placeholders={"task": f"'{call.data[ATTR_TASK]}'"},
+ ) from e
+
+ if TYPE_CHECKING:
+ assert task_id
+ try:
+ response = await coordinator.habitica.update_score(task_id, direction)
+ except TooManyRequestsError as e:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="setup_rate_limit_exception",
+ ) 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 or 0):.2f} GP",
+ "cost": f"{task_value:.2f} GP",
+ },
+ ) from e
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="service_call_exception",
+ ) from e
+ except (HabiticaException, ClientError) as e:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="service_call_exception",
+ ) from e
+ else:
+ await coordinator.async_request_refresh()
+ 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
+
+ item = ITEMID_MAP[call.data[ATTR_ITEM]]
+ # check if target is self
+ if call.data[ATTR_TARGET] in (
+ str(coordinator.data.user.id),
+ coordinator.data.user.profile.name,
+ coordinator.data.user.auth.local.username,
+ ):
+ target_id = coordinator.data.user.id
+ else:
+ # check if target is a party member
+ try:
+ 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 (ClientError, HabiticaException) as e:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="service_call_exception",
+ ) from e
+ try:
+ target_id = next(
+ member.id
+ for member in party.data
+ if member.id
+ and call.data[ATTR_TARGET].lower()
+ in (
+ str(member.id),
+ str(member.auth.local.username).lower(),
+ str(member.profile.name).lower(),
+ )
+ )
+ except StopIteration as e:
+ raise ServiceValidationError(
+ translation_domain=DOMAIN,
+ translation_key="target_not_found",
+ translation_placeholders={"target": f"'{call.data[ATTR_TARGET]}'"},
+ ) from e
+ try:
+ response = await coordinator.habitica.cast_skill(item, target_id)
+ except TooManyRequestsError as e:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="setup_rate_limit_exception",
+ ) 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, ClientError) as e:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="service_call_exception",
+ ) from e
+ else:
+ 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,
@@ -174,3 +486,33 @@ def async_setup_services(hass: HomeAssistant) -> None:
schema=SERVICE_CAST_SKILL_SCHEMA,
supports_response=SupportsResponse.ONLY,
)
+
+ hass.services.async_register(
+ DOMAIN,
+ SERVICE_SCORE_HABIT,
+ score_task,
+ schema=SERVICE_SCORE_TASK_SCHEMA,
+ supports_response=SupportsResponse.ONLY,
+ )
+ hass.services.async_register(
+ DOMAIN,
+ SERVICE_SCORE_REWARD,
+ score_task,
+ schema=SERVICE_SCORE_TASK_SCHEMA,
+ supports_response=SupportsResponse.ONLY,
+ )
+
+ hass.services.async_register(
+ DOMAIN,
+ SERVICE_TRANSFORMATION,
+ transformation,
+ 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 546ac8c1c34..f3095518290 100644
--- a/homeassistant/components/habitica/services.yaml
+++ b/homeassistant/components/habitica/services.yaml
@@ -17,7 +17,7 @@ api_call:
object:
cast_skill:
fields:
- config_entry:
+ config_entry: &config_entry
required: true
selector:
config_entry:
@@ -33,7 +33,110 @@ cast_skill:
- "fireball"
mode: dropdown
translation_key: "skill_select"
- task:
+ task: &task
required: true
selector:
text:
+accept_quest:
+ fields:
+ config_entry: *config_entry
+reject_quest:
+ fields:
+ config_entry: *config_entry
+start_quest:
+ fields:
+ config_entry: *config_entry
+cancel_quest:
+ fields:
+ config_entry: *config_entry
+abort_quest:
+ fields:
+ config_entry: *config_entry
+leave_quest:
+ fields:
+ config_entry: *config_entry
+score_habit:
+ fields:
+ config_entry: *config_entry
+ task: *task
+ direction:
+ required: true
+ selector:
+ select:
+ options:
+ - value: up
+ label: "➕"
+ - value: down
+ label: "➖"
+score_reward:
+ fields:
+ config_entry: *config_entry
+ task: *task
+transformation:
+ fields:
+ config_entry:
+ required: true
+ selector:
+ config_entry:
+ integration: habitica
+ item:
+ required: true
+ selector:
+ select:
+ options:
+ - "snowball"
+ - "spooky_sparkles"
+ - "seafoam"
+ - "shiny_seed"
+ mode: dropdown
+ translation_key: "transformation_item_select"
+ target:
+ 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 f7d2f20b8f9..b4925861d67 100644
--- a/homeassistant/components/habitica/strings.json
+++ b/homeassistant/components/habitica/strings.json
@@ -1,39 +1,90 @@
{
"common": {
"todos": "To-Do's",
- "dailies": "Dailies"
+ "dailies": "Dailies",
+ "config_entry_name": "Select character",
+ "task_name": "Task name",
+ "unit_tasks": "tasks",
+ "unit_health_points": "HP",
+ "unit_mana_points": "MP",
+ "unit_experience_points": "XP"
},
"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%]"
},
"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"
},
- "description": "Connect your Habitica profile to allow monitoring of your user's profile and tasks."
+ "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})"
},
"login": {
+ "title": "[%key:component::habitica::config::step::user::menu_options::login%]",
"data": {
"username": "Email or username (case-sensitive)",
"password": "[%key:common::config_flow::data::password%]"
- }
+ },
+ "data_description": {
+ "username": "Email or username (case-sensitive) to connect Home Assistant to your Habitica account",
+ "password": "Password for the account to connect Home Assistant to Habitica"
+ },
+ "description": "Enter your login details to start using Habitica with Home Assistant\n\n[Forgot your password?]({forgot_password})"
},
"advanced": {
+ "title": "[%key:component::habitica::config::step::user::menu_options::advanced%]",
"data": {
"url": "[%key:common::config_flow::data::url%]",
"api_user": "User ID",
"api_key": "API Token",
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
},
- "description": "You can retrieve your `User ID` and `API Token` from **Settings -> Site Data** on Habitica or the instance you want to connect to"
+ "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",
+ "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%]"
+ }
+ }
+ }
}
}
},
@@ -108,6 +159,17 @@
}
}
}
+ },
+ "todo_reminders": {
+ "name": "To-do reminders"
+ },
+ "daily_reminders": {
+ "name": "Daily reminders"
+ }
+ },
+ "image": {
+ "avatar": {
+ "name": "Avatar"
}
},
"sensor": {
@@ -115,31 +177,39 @@
"name": "Display name"
},
"health": {
- "name": "Health"
+ "name": "Health",
+ "unit_of_measurement": "[%key:component::habitica::common::unit_health_points%]"
},
"health_max": {
- "name": "Max. health"
+ "name": "Max. health",
+ "unit_of_measurement": "[%key:component::habitica::common::unit_health_points%]"
},
"mana": {
- "name": "Mana"
+ "name": "Mana",
+ "unit_of_measurement": "[%key:component::habitica::common::unit_mana_points%]"
},
"mana_max": {
- "name": "Max. mana"
+ "name": "Max. mana",
+ "unit_of_measurement": "[%key:component::habitica::common::unit_mana_points%]"
},
"experience": {
- "name": "Experience"
+ "name": "Experience",
+ "unit_of_measurement": "[%key:component::habitica::common::unit_experience_points%]"
},
"experience_max": {
- "name": "Next level"
+ "name": "Next level",
+ "unit_of_measurement": "[%key:component::habitica::common::unit_experience_points%]"
},
"level": {
"name": "Level"
},
"gold": {
- "name": "Gold"
+ "name": "Gold",
+ "unit_of_measurement": "GP"
},
"gems": {
- "name": "Gems"
+ "name": "Gems",
+ "unit_of_measurement": "gems"
},
"trinkets": {
"name": "Mystic hourglasses"
@@ -153,17 +223,93 @@
"rogue": "Rogue"
}
},
- "todos": {
- "name": "[%key:component::habitica::common::todos%]"
- },
- "dailys": {
- "name": "[%key:component::habitica::common::dailies%]"
- },
"habits": {
- "name": "Habits"
+ "name": "Habits",
+ "unit_of_measurement": "[%key:component::habitica::common::unit_tasks%]"
},
"rewards": {
- "name": "Rewards"
+ "name": "Rewards",
+ "unit_of_measurement": "[%key:component::habitica::common::unit_tasks%]"
+ },
+ "strength": {
+ "name": "Strength",
+ "state_attributes": {
+ "level": {
+ "name": "[%key:component::habitica::entity::sensor::level::name%]"
+ },
+ "equipment": {
+ "name": "Battle gear"
+ },
+ "class": {
+ "name": "Class equip bonus"
+ },
+ "allocated": {
+ "name": "Allocated attribute points"
+ },
+ "buffs": {
+ "name": "Buffs"
+ }
+ }
+ },
+ "intelligence": {
+ "name": "Intelligence",
+ "state_attributes": {
+ "level": {
+ "name": "[%key:component::habitica::entity::sensor::level::name%]"
+ },
+ "equipment": {
+ "name": "[%key:component::habitica::entity::sensor::strength::state_attributes::equipment::name%]"
+ },
+ "class": {
+ "name": "[%key:component::habitica::entity::sensor::strength::state_attributes::class::name%]"
+ },
+ "allocated": {
+ "name": "[%key:component::habitica::entity::sensor::strength::state_attributes::allocated::name%]"
+ },
+ "buffs": {
+ "name": "[%key:component::habitica::entity::sensor::strength::state_attributes::buffs::name%]"
+ }
+ }
+ },
+ "perception": {
+ "name": "Perception",
+ "state_attributes": {
+ "level": {
+ "name": "[%key:component::habitica::entity::sensor::level::name%]"
+ },
+ "equipment": {
+ "name": "[%key:component::habitica::entity::sensor::strength::state_attributes::equipment::name%]"
+ },
+ "class": {
+ "name": "[%key:component::habitica::entity::sensor::strength::state_attributes::class::name%]"
+ },
+ "allocated": {
+ "name": "[%key:component::habitica::entity::sensor::strength::state_attributes::allocated::name%]"
+ },
+ "buffs": {
+ "name": "[%key:component::habitica::entity::sensor::strength::state_attributes::buffs::name%]"
+ }
+ }
+ },
+ "constitution": {
+ "name": "Constitution",
+ "state_attributes": {
+ "level": {
+ "name": "[%key:component::habitica::entity::sensor::level::name%]"
+ },
+ "equipment": {
+ "name": "[%key:component::habitica::entity::sensor::strength::state_attributes::equipment::name%]"
+ },
+ "class": {
+ "name": "[%key:component::habitica::entity::sensor::strength::state_attributes::class::name%]"
+ },
+ "allocated": {
+ "name": "[%key:component::habitica::entity::sensor::strength::state_attributes::allocated::name%]"
+ },
+ "buffs": {
+ "name": "[%key:component::habitica::entity::sensor::strength::state_attributes::buffs::name%]"
+ }
+ }
}
},
"switch": {
@@ -220,6 +366,9 @@
"not_enough_mana": {
"message": "Unable to cast skill, not enough mana. Your character has {mana}, but the skill costs {cost}."
},
+ "not_enough_gold": {
+ "message": "Unable to buy reward, not enough gold. Your character has {gold}, but the reward costs {cost}."
+ },
"skill_not_found": {
"message": "Unable to cast skill, your character does not have the skill or spell {skill}."
},
@@ -230,13 +379,31 @@
"message": "The selected character is currently not loaded or disabled in Home Assistant."
},
"task_not_found": {
- "message": "Unable to cast skill, could not find the task {task}"
+ "message": "Unable to complete action, could not find the task {task}"
+ },
+ "quest_action_unallowed": {
+ "message": "Action not allowed, only quest leader or group leader can perform this action"
+ },
+ "quest_not_found": {
+ "message": "Unable to complete action, quest or group not found"
+ },
+ "target_not_found": {
+ "message": "Unable to find target {target} in your party"
+ },
+ "party_not_found": {
+ "message": "Unable to find target, you are currently not in a party. You can only target yourself"
+ },
+ "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_api_call": {
+ "title": "The Habitica action habitica.api_call is deprecated",
+ "description": "The Habitica action `habitica.api_call` is deprecated and will be removed in Home Assistant 2025.5.0.\n\nPlease update your automations and scripts to use other Habitica actions and entities."
}
},
"services": {
@@ -260,10 +427,10 @@
},
"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": "Select character",
+ "name": "[%key:component::habitica::common::config_entry_name%]",
"description": "Choose the Habitica character to cast the skill."
},
"skill": {
@@ -271,10 +438,156 @@
"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": "Accepts a pending invitation to a quest.",
+ "fields": {
+ "config_entry": {
+ "name": "[%key:component::habitica::common::config_entry_name%]",
+ "description": "Choose the Habitica character for which to perform the action."
+ }
+ }
+ },
+ "reject_quest": {
+ "name": "Reject a quest invitation",
+ "description": "Rejects a pending invitation to a quest.",
+ "fields": {
+ "config_entry": {
+ "name": "[%key:component::habitica::common::config_entry_name%]",
+ "description": "[%key:component::habitica::services::accept_quest::fields::config_entry::description%]"
+ }
+ }
+ },
+ "leave_quest": {
+ "name": "Leave a quest",
+ "description": "Leaves the current quest you are participating in.",
+ "fields": {
+ "config_entry": {
+ "name": "[%key:component::habitica::common::config_entry_name%]",
+ "description": "[%key:component::habitica::services::accept_quest::fields::config_entry::description%]"
+ }
+ }
+ },
+ "abort_quest": {
+ "name": "Abort an active quest",
+ "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%]",
+ "description": "[%key:component::habitica::services::accept_quest::fields::config_entry::description%]"
+ }
+ }
+ },
+ "cancel_quest": {
+ "name": "Cancel a pending quest",
+ "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%]",
+ "description": "[%key:component::habitica::services::accept_quest::fields::config_entry::description%]"
+ }
+ }
+ },
+ "start_quest": {
+ "name": "Force-start a pending quest",
+ "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%]",
+ "description": "[%key:component::habitica::services::accept_quest::fields::config_entry::description%]"
+ }
+ }
+ },
+ "score_habit": {
+ "name": "Track a habit",
+ "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%]",
+ "description": "Select the Habitica character tracking your habit."
+ },
+ "task": {
+ "name": "Habit name",
+ "description": "The name (or task ID) of the Habitica habit."
+ },
+ "direction": {
+ "name": "Reward or loss",
+ "description": "Is it positive or negative progress you want to track for your habit."
+ }
+ }
+ },
+ "score_reward": {
+ "name": "Buy a reward",
+ "description": "Buys one of your custom rewards with gold earned by fulfilling tasks.",
+ "fields": {
+ "config_entry": {
+ "name": "[%key:component::habitica::common::config_entry_name%]",
+ "description": "Select the Habitica character buying the reward."
+ },
+ "task": {
+ "name": "Reward name",
+ "description": "The name (or task ID) of the custom reward."
+ }
+ }
+ },
+ "transformation": {
+ "name": "Use a transformation item",
+ "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",
+ "description": "Choose the Habitica character to use the transformation item."
+ },
+ "item": {
+ "name": "Transformation item",
+ "description": "Select the transformation item you want to use. Item must be in the characters inventory."
+ },
+ "target": {
+ "name": "Target character",
+ "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": {
@@ -285,6 +598,30 @@
"backstab": "Rogue: Backstab",
"smash": "Warrior: Brutal smash"
}
+ },
+ "transformation_item_select": {
+ "options": {
+ "snowball": "Snowball",
+ "spooky_sparkles": "Spooky sparkles",
+ "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 6682911e892..ddc0db27108 100644
--- a/homeassistant/components/habitica/switch.py
+++ b/homeassistant/components/habitica/switch.py
@@ -19,6 +19,8 @@ from .coordinator import HabiticaData, HabiticaDataUpdateCoordinator
from .entity import HabiticaBase
from .types import HabiticaConfigEntry
+PARALLEL_UPDATES = 1
+
@dataclass(kw_only=True, frozen=True)
class HabiticaSwitchEntityDescription(SwitchEntityDescription):
@@ -26,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):
@@ -40,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 0fff7b66605..a14327f5378 100644
--- a/homeassistant/components/habitica/todo.py
+++ b/homeassistant/components/habitica/todo.py
@@ -2,11 +2,12 @@
from __future__ import annotations
-import datetime
from enum import StrEnum
from typing import TYPE_CHECKING
+from uuid import UUID
-from aiohttp import ClientResponseError
+from aiohttp import ClientError
+from habiticalib import Direction, HabiticaException, Task, TaskType
from homeassistant.components import persistent_notification
from homeassistant.components.todo import (
@@ -24,9 +25,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
+PARALLEL_UPDATES = 1
+
class HabiticaTodoList(StrEnum):
"""Habitica Entities."""
@@ -68,8 +71,8 @@ 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 (HabiticaException, ClientError) as e:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="delete_completed_todos_failed",
@@ -77,8 +80,8 @@ 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 (HabiticaException, ClientError) as e:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key=f"delete_{self.entity_description.key}_failed",
@@ -104,9 +107,8 @@ 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 (HabiticaException, ClientError) as e:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key=f"move_{self.entity_description.key}_item_failed",
@@ -116,12 +118,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()
@@ -136,14 +140,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
@@ -151,13 +158,9 @@ 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 (HabiticaException, ClientError) as e:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key=f"update_{self.entity_description.key}_item_failed",
@@ -170,32 +173,33 @@ 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 (HabiticaException, ClientError) as 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"
@@ -227,38 +231,36 @@ 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 (HabiticaException, ClientError) as e:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key=f"create_{self.entity_description.key}_item_failed",
@@ -293,23 +295,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 93a7c234a5d..4c1e54639d0 100644
--- a/homeassistant/components/habitica/util.py
+++ b/homeassistant/components/habitica/util.py
@@ -2,8 +2,10 @@
from __future__ import annotations
+from dataclasses import fields
import datetime
-from typing import TYPE_CHECKING, Any
+from math import floor
+from typing import TYPE_CHECKING
from dateutil.rrule import (
DAILY,
@@ -19,6 +21,7 @@ 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
@@ -26,50 +29,32 @@ 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
- 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
+ return dt_util.as_local(task.nextDue[0]).date()
def entity_used_in(hass: HomeAssistant, entity_id: str) -> list[str]:
@@ -83,30 +68,27 @@ FREQUENCY_MAP = {"daily": DAILY, "weekly": WEEKLY, "monthly": MONTHLY, "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,
@@ -139,3 +121,41 @@ def get_recurrence_rule(recurrence: rrule) -> str:
"""
return str(recurrence).split("RRULE:")[1]
+
+
+def get_attribute_points(
+ user: UserData, content: ContentData, attribute: str
+) -> dict[str, float]:
+ """Get modifiers contributing to STR/INT/CON/PER attributes."""
+
+ equipment = sum(
+ 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(
+ 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),
+ "equipment": equipment,
+ "class": class_bonus,
+ "allocated": getattr(user.stats, attribute),
+ "buffs": getattr(user.stats.buffs, attribute),
+ }
+
+
+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())
+ )
diff --git a/homeassistant/components/harman_kardon_avr/manifest.json b/homeassistant/components/harman_kardon_avr/manifest.json
index c28504cf2d8..e56aeebafe4 100644
--- a/homeassistant/components/harman_kardon_avr/manifest.json
+++ b/homeassistant/components/harman_kardon_avr/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/harman_kardon_avr",
"iot_class": "local_polling",
"loggers": ["hkavr"],
+ "quality_scale": "legacy",
"requirements": ["hkavr==0.0.5"]
}
diff --git a/homeassistant/components/harvey/__init__.py b/homeassistant/components/harvey/__init__.py
new file mode 100644
index 00000000000..e40d1799a64
--- /dev/null
+++ b/homeassistant/components/harvey/__init__.py
@@ -0,0 +1 @@
+"""Virtual integration: Harvey."""
diff --git a/homeassistant/components/harvey/manifest.json b/homeassistant/components/harvey/manifest.json
new file mode 100644
index 00000000000..3cb2a1b9aff
--- /dev/null
+++ b/homeassistant/components/harvey/manifest.json
@@ -0,0 +1,6 @@
+{
+ "domain": "harvey",
+ "name": "Harvey",
+ "integration_type": "virtual",
+ "supported_by": "aquacell"
+}
diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py
index 306c9d43d72..fec84737e78 100644
--- a/homeassistant/components/hassio/__init__.py
+++ b/homeassistant/components/hassio/__init__.py
@@ -64,7 +64,10 @@ from homeassistant.util.dt import now
# config_flow, diagnostics, system_health, and entity platforms are imported to
# ensure other dependencies that wait for hassio are not waiting
# for hassio to import its platforms
+# backup is pre-imported to ensure that the backup integration does not load
+# it from the event loop
from . import ( # noqa: F401
+ backup,
binary_sensor,
config_flow,
diagnostics,
@@ -119,7 +122,6 @@ from .handler import ( # noqa: F401
async_create_backup,
async_get_green_settings,
async_get_yellow_settings,
- async_reboot_host,
async_set_green_settings,
async_set_yellow_settings,
async_update_diagnostics,
diff --git a/homeassistant/components/hassio/backup.py b/homeassistant/components/hassio/backup.py
new file mode 100644
index 00000000000..537588e856a
--- /dev/null
+++ b/homeassistant/components/hassio/backup.py
@@ -0,0 +1,476 @@
+"""Backup functionality for supervised installations."""
+
+from __future__ import annotations
+
+import asyncio
+from collections.abc import AsyncIterator, Callable, Coroutine, Mapping
+import logging
+from pathlib import Path
+from typing import Any, cast
+
+from aiohasupervisor.exceptions import (
+ SupervisorBadRequestError,
+ SupervisorError,
+ SupervisorNotFoundError,
+)
+from aiohasupervisor.models import (
+ backups as supervisor_backups,
+ mounts as supervisor_mounts,
+)
+
+from homeassistant.components.backup import (
+ DATA_MANAGER,
+ AddonInfo,
+ AgentBackup,
+ BackupAgent,
+ BackupReaderWriter,
+ BackupReaderWriterError,
+ CreateBackupEvent,
+ Folder,
+ IncorrectPasswordError,
+ NewBackup,
+ WrittenBackup,
+)
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+
+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")
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_get_backup_agents(
+ hass: HomeAssistant,
+ **kwargs: Any,
+) -> list[BackupAgent]:
+ """Return the hassio backup agents."""
+ client = get_supervisor_client(hass)
+ mounts = await client.mounts.info()
+ agents: list[BackupAgent] = [SupervisorBackupAgent(hass, "local", None)]
+ for mount in mounts.mounts:
+ if mount.usage is not supervisor_mounts.MountUsage.BACKUP:
+ continue
+ agents.append(SupervisorBackupAgent(hass, mount.name, mount.name))
+ return agents
+
+
+@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."""
+
+ @callback
+ def unsub() -> None:
+ """Unsubscribe from job events."""
+ unsub_signal()
+
+ @callback
+ def handle_signal(data: Mapping[str, Any]) -> None:
+ """Handle a job signal."""
+ if (
+ data.get("event") != "job"
+ or not (event_data := data.get("data"))
+ or event_data.get("name") not in MOUNT_JOBS
+ or event_data.get("done") is not True
+ ):
+ return
+ _LOGGER.debug("Mount added or removed %s, calling listener", data)
+ listener()
+
+ unsub_signal = async_dispatcher_connect(hass, EVENT_SUPERVISOR_EVENT, handle_signal)
+ return unsub
+
+
+def _backup_details_to_agent_backup(
+ details: supervisor_backups.BackupComplete,
+) -> AgentBackup:
+ """Convert a supervisor backup details object to an agent backup."""
+ homeassistant_included = details.homeassistant is not None
+ if not homeassistant_included:
+ database_included = False
+ else:
+ database_included = details.homeassistant_exclude_database is False
+ addons = [
+ AddonInfo(name=addon.name, slug=addon.slug, version=addon.version)
+ for addon in details.addons
+ ]
+ return AgentBackup(
+ addons=addons,
+ backup_id=details.slug,
+ database_included=database_included,
+ 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,
+ )
+
+
+class SupervisorBackupAgent(BackupAgent):
+ """Backup agent for supervised installations."""
+
+ domain = DOMAIN
+
+ def __init__(self, hass: HomeAssistant, name: str, location: str | None) -> 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.location = location
+
+ async def async_download_backup(
+ self,
+ backup_id: str,
+ **kwargs: Any,
+ ) -> AsyncIterator[bytes]:
+ """Download a backup file."""
+ return await self._client.backups.download_backup(
+ backup_id,
+ options=supervisor_backups.DownloadBackupOptions(location=self.location),
+ )
+
+ async def async_upload_backup(
+ self,
+ *,
+ open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]],
+ backup: AgentBackup,
+ **kwargs: Any,
+ ) -> None:
+ """Upload a backup.
+
+ Not required for supervisor, the SupervisorBackupReaderWriter stores files.
+ """
+
+ 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:
+ continue
+ details = await self._client.backups.backup_info(backup.slug)
+ result.append(_backup_details_to_agent_backup(details))
+ return result
+
+ async def async_get_backup(
+ self,
+ backup_id: str,
+ **kwargs: Any,
+ ) -> AgentBackup | None:
+ """Return a backup."""
+ details = await self._client.backups.backup_info(backup_id)
+ if self.location not in details.locations:
+ return None
+ return _backup_details_to_agent_backup(details)
+
+ async def async_delete_backup(self, backup_id: str, **kwargs: Any) -> None:
+ """Remove a backup."""
+ try:
+ await self._client.backups.remove_backup(
+ backup_id,
+ options=supervisor_backups.RemoveBackupOptions(
+ 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)
+
+
+class SupervisorBackupReaderWriter(BackupReaderWriter):
+ """Class for reading and writing backups in supervised installations."""
+
+ def __init__(self, hass: HomeAssistant) -> None:
+ """Initialize the backup reader/writer."""
+ self._hass = hass
+ self._client = get_supervisor_client(hass)
+
+ async def async_create_backup(
+ self,
+ *,
+ agent_ids: list[str],
+ backup_name: str,
+ extra_metadata: dict[str, bool | str],
+ include_addons: list[str] | None,
+ include_all_addons: bool,
+ include_database: bool,
+ include_folders: list[Folder] | None,
+ include_homeassistant: bool,
+ on_progress: Callable[[CreateBackupEvent], None],
+ password: str | None,
+ ) -> tuple[NewBackup, asyncio.Task[WrittenBackup]]:
+ """Create a backup."""
+ if not include_homeassistant and include_database:
+ raise HomeAssistantError(
+ "Cannot create a backup with database but without Home Assistant"
+ )
+ manager = self._hass.data[DATA_MANAGER]
+
+ include_addons_set: supervisor_backups.AddonSet | set[str] | None = None
+ if include_all_addons:
+ include_addons_set = supervisor_backups.AddonSet.ALL
+ elif include_addons:
+ include_addons_set = set(include_addons)
+ include_folders_set = (
+ {supervisor_backups.Folder(folder) for folder in include_folders}
+ if include_folders
+ else None
+ )
+
+ hassio_agents: list[SupervisorBackupAgent] = [
+ cast(SupervisorBackupAgent, manager.backup_agents[agent_id])
+ for agent_id in agent_ids
+ if manager.backup_agents[agent_id].domain == DOMAIN
+ ]
+ locations = [agent.location for agent in hassio_agents]
+
+ try:
+ backup = await self._client.backups.partial_backup(
+ supervisor_backups.PartialBackupOptions(
+ addons=include_addons_set,
+ folders=include_folders_set,
+ homeassistant=include_homeassistant,
+ name=backup_name,
+ password=password,
+ compressed=True,
+ location=locations or LOCATION_CLOUD_BACKUP,
+ homeassistant_exclude_database=not include_database,
+ background=True,
+ extra=extra_metadata,
+ )
+ )
+ 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)
+ ),
+ 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)
+
+ async def _async_wait_for_backup(
+ self, backup: supervisor_backups.NewBackup, *, remove_after_upload: bool
+ ) -> WrittenBackup:
+ """Wait for a backup to complete."""
+ backup_complete = asyncio.Event()
+ backup_id: str | None = None
+
+ @callback
+ def on_progress(data: Mapping[str, Any]) -> None:
+ """Handle backup progress."""
+ nonlocal backup_id
+ if data.get("done") is True:
+ backup_id = data.get("reference")
+ backup_complete.set()
+
+ try:
+ unsub = self._async_listen_job_events(backup.job_id, on_progress)
+ await backup_complete.wait()
+ finally:
+ unsub()
+ if not backup_id:
+ raise BackupReaderWriterError("Backup failed")
+
+ async def open_backup() -> AsyncIterator[bytes]:
+ try:
+ return await self._client.backups.download_backup(backup_id)
+ except SupervisorError as err:
+ raise BackupReaderWriterError(
+ f"Error downloading backup: {err}"
+ ) from err
+
+ async def remove_backup() -> None:
+ if not remove_after_upload:
+ return
+ try:
+ await self._client.backups.remove_backup(
+ backup_id,
+ options=supervisor_backups.RemoveBackupOptions(
+ location={LOCATION_CLOUD_BACKUP}
+ ),
+ )
+ except SupervisorError as err:
+ raise BackupReaderWriterError(f"Error removing backup: {err}") from err
+
+ try:
+ details = await self._client.backups.backup_info(backup_id)
+ except SupervisorError as err:
+ raise BackupReaderWriterError(
+ f"Error getting backup details: {err}"
+ ) from err
+
+ return WrittenBackup(
+ backup=_backup_details_to_agent_backup(details),
+ open_stream=open_backup,
+ release_stream=remove_backup,
+ )
+
+ async def async_receive_backup(
+ self,
+ *,
+ agent_ids: list[str],
+ stream: AsyncIterator[bytes],
+ suggested_filename: str,
+ ) -> WrittenBackup:
+ """Receive a backup."""
+ manager = self._hass.data[DATA_MANAGER]
+
+ hassio_agents: list[SupervisorBackupAgent] = [
+ cast(SupervisorBackupAgent, manager.backup_agents[agent_id])
+ for agent_id in agent_ids
+ if manager.backup_agents[agent_id].domain == DOMAIN
+ ]
+ locations = {agent.location for agent in hassio_agents}
+
+ backup_id = await self._client.backups.upload_backup(
+ stream,
+ supervisor_backups.UploadBackupOptions(
+ location=locations or {LOCATION_CLOUD_BACKUP}
+ ),
+ )
+
+ async def open_backup() -> AsyncIterator[bytes]:
+ return await self._client.backups.download_backup(backup_id)
+
+ async def remove_backup() -> None:
+ if locations:
+ return
+ await self._client.backups.remove_backup(
+ backup_id,
+ options=supervisor_backups.RemoveBackupOptions(
+ location={LOCATION_CLOUD_BACKUP}
+ ),
+ )
+
+ details = await self._client.backups.backup_info(backup_id)
+
+ return WrittenBackup(
+ backup=_backup_details_to_agent_backup(details),
+ open_stream=open_backup,
+ release_stream=remove_backup,
+ )
+
+ async def async_restore_backup(
+ self,
+ backup_id: str,
+ *,
+ agent_id: str,
+ open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]],
+ password: str | None,
+ restore_addons: list[str] | None,
+ restore_database: bool,
+ restore_folders: list[Folder] | None,
+ restore_homeassistant: bool,
+ ) -> None:
+ """Restore a backup."""
+ manager = self._hass.data[DATA_MANAGER]
+ # The backup manager has already checked that the backup exists so we don't need to
+ # check that here.
+ backup = await manager.backup_agents[agent_id].async_get_backup(backup_id)
+ if (
+ backup
+ and restore_homeassistant
+ and restore_database != backup.database_included
+ ):
+ raise HomeAssistantError("Restore database must match backup")
+ if not restore_homeassistant and restore_database:
+ raise HomeAssistantError("Cannot restore database without Home Assistant")
+ restore_addons_set = set(restore_addons) if restore_addons else None
+ restore_folders_set = (
+ {supervisor_backups.Folder(folder) for folder in restore_folders}
+ if restore_folders
+ else None
+ )
+
+ restore_location: str | None
+ 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.
+ await self.async_receive_backup(
+ agent_ids=[],
+ stream=await open_stream(),
+ suggested_filename=f"{backup_id}.tar",
+ )
+ restore_location = LOCATION_CLOUD_BACKUP
+ else:
+ agent = cast(SupervisorBackupAgent, manager.backup_agents[agent_id])
+ restore_location = agent.location
+
+ try:
+ job = await self._client.backups.partial_restore(
+ backup_id,
+ supervisor_backups.PartialRestoreOptions(
+ addons=restore_addons_set,
+ folders=restore_folders_set,
+ homeassistant=restore_homeassistant,
+ password=password,
+ background=True,
+ location=restore_location,
+ ),
+ )
+ except SupervisorBadRequestError as err:
+ # Supervisor currently does not transmit machine parsable error types
+ message = err.args[0]
+ if message.startswith("Invalid password for backup"):
+ raise IncorrectPasswordError(message) from err
+ raise HomeAssistantError(message) from err
+
+ restore_complete = asyncio.Event()
+
+ @callback
+ def on_progress(data: Mapping[str, Any]) -> None:
+ """Handle backup progress."""
+ if data.get("done") is True:
+ restore_complete.set()
+
+ try:
+ unsub = self._async_listen_job_events(job.job_id, on_progress)
+ await restore_complete.wait()
+ finally:
+ unsub()
+
+ @callback
+ def _async_listen_job_events(
+ self, job_id: str, on_event: Callable[[Mapping[str, Any]], None]
+ ) -> Callable[[], None]:
+ """Listen for job events."""
+
+ @callback
+ def unsub() -> None:
+ """Unsubscribe from job events."""
+ unsub_signal()
+
+ @callback
+ def handle_signal(data: Mapping[str, Any]) -> None:
+ """Handle a job signal."""
+ if (
+ data.get("event") != "job"
+ or not (event_data := data.get("data"))
+ or event_data.get("uuid") != job_id
+ ):
+ return
+ on_event(event_data)
+
+ unsub_signal = async_dispatcher_connect(
+ self._hass, EVENT_SUPERVISOR_EVENT, handle_signal
+ )
+ return unsub
diff --git a/homeassistant/components/hassio/handler.py b/homeassistant/components/hassio/handler.py
index 58f2aa8c144..254c392462c 100644
--- a/homeassistant/components/hassio/handler.py
+++ b/homeassistant/components/hassio/handler.py
@@ -133,16 +133,6 @@ async def async_set_yellow_settings(
)
-@api_data
-async def async_reboot_host(hass: HomeAssistant) -> dict:
- """Reboot the host.
-
- Returns an empty dict.
- """
- hassio: HassIO = hass.data[DOMAIN]
- return await hassio.send_command("/host/reboot", method="post", timeout=60)
-
-
class HassIO:
"""Small API wrapper for Hass.io."""
diff --git a/homeassistant/components/hassio/manifest.json b/homeassistant/components/hassio/manifest.json
index 31fa27a92c4..c9ecf6657e8 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.1"],
+ "requirements": ["aiohasupervisor==0.2.2b5"],
"single_config_entry": true
}
diff --git a/homeassistant/components/hassio/strings.json b/homeassistant/components/hassio/strings.json
index 09ed45bd5bc..799067b8215 100644
--- a/homeassistant/components/hassio/strings.json
+++ b/homeassistant/components/hassio/strings.json
@@ -274,60 +274,60 @@
"fields": {
"addon": {
"name": "Add-on",
- "description": "The add-on slug."
+ "description": "The add-on to start."
}
}
},
"addon_restart": {
- "name": "Restart add-on.",
+ "name": "Restart add-on",
"description": "Restarts an add-on.",
"fields": {
"addon": {
"name": "[%key:component::hassio::services::addon_start::fields::addon::name%]",
- "description": "[%key:component::hassio::services::addon_start::fields::addon::description%]"
+ "description": "The add-on to restart."
}
}
},
"addon_stdin": {
- "name": "Write data to add-on stdin.",
- "description": "Writes data to add-on stdin.",
+ "name": "Write data to add-on stdin",
+ "description": "Writes data to the add-on's standard input.",
"fields": {
"addon": {
"name": "[%key:component::hassio::services::addon_start::fields::addon::name%]",
- "description": "[%key:component::hassio::services::addon_start::fields::addon::description%]"
+ "description": "The add-on to write to."
}
}
},
"addon_stop": {
- "name": "Stop add-on.",
+ "name": "Stop add-on",
"description": "Stops an add-on.",
"fields": {
"addon": {
"name": "[%key:component::hassio::services::addon_start::fields::addon::name%]",
- "description": "[%key:component::hassio::services::addon_start::fields::addon::description%]"
+ "description": "The add-on to stop."
}
}
},
"addon_update": {
- "name": "Update add-on.",
+ "name": "Update add-on",
"description": "Updates an add-on. This action should be used with caution since add-on updates can contain breaking changes. It is highly recommended that you review release notes/change logs before updating an add-on.",
"fields": {
"addon": {
"name": "[%key:component::hassio::services::addon_start::fields::addon::name%]",
- "description": "[%key:component::hassio::services::addon_start::fields::addon::description%]"
+ "description": "The add-on to update."
}
}
},
"host_reboot": {
- "name": "Reboot the host system.",
+ "name": "Reboot the host system",
"description": "Reboots the host system."
},
"host_shutdown": {
- "name": "Power off the host system.",
+ "name": "Power off the host system",
"description": "Powers off the host system."
},
"backup_full": {
- "name": "Create a full backup.",
+ "name": "Create a full backup",
"description": "Creates a full backup.",
"fields": {
"name": {
@@ -353,7 +353,7 @@
}
},
"backup_partial": {
- "name": "Create a partial backup.",
+ "name": "Create a partial backup",
"description": "Creates a partial backup.",
"fields": {
"homeassistant": {
@@ -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",
@@ -391,7 +391,7 @@
}
},
"restore_full": {
- "name": "Restore from full backup.",
+ "name": "Restore from full backup",
"description": "Restores from full backup.",
"fields": {
"slug": {
@@ -405,7 +405,7 @@
}
},
"restore_partial": {
- "name": "Restore from partial backup.",
+ "name": "Restore from partial backup",
"description": "Restores from a partial backup.",
"fields": {
"slug": {
@@ -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/haveibeenpwned/manifest.json b/homeassistant/components/haveibeenpwned/manifest.json
index 2451871f0c8..eb9ad4c356f 100644
--- a/homeassistant/components/haveibeenpwned/manifest.json
+++ b/homeassistant/components/haveibeenpwned/manifest.json
@@ -3,5 +3,6 @@
"name": "HaveIBeenPwned",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/haveibeenpwned",
- "iot_class": "cloud_polling"
+ "iot_class": "cloud_polling",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/hddtemp/manifest.json b/homeassistant/components/hddtemp/manifest.json
index 8dd2676596c..4fe23233870 100644
--- a/homeassistant/components/hddtemp/manifest.json
+++ b/homeassistant/components/hddtemp/manifest.json
@@ -3,5 +3,6 @@
"name": "hddtemp",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/hddtemp",
- "iot_class": "local_polling"
+ "iot_class": "local_polling",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/hdmi_cec/entity.py b/homeassistant/components/hdmi_cec/entity.py
index b1bcb2720d4..bdb796e6a36 100644
--- a/homeassistant/components/hdmi_cec/entity.py
+++ b/homeassistant/components/hdmi_cec/entity.py
@@ -36,7 +36,7 @@ class CecEntity(Entity):
"""Initialize the device."""
self._device = device
self._logical_address = logical
- self.entity_id = "%s.%d" % (DOMAIN, self._logical_address)
+ self.entity_id = f"{DOMAIN}.{self._logical_address}"
self._set_attr_name()
self._attr_icon = ICONS_BY_TYPE.get(self._device.type, ICON_UNKNOWN)
diff --git a/homeassistant/components/hdmi_cec/manifest.json b/homeassistant/components/hdmi_cec/manifest.json
index fbd9e2304d9..2e37e908e16 100644
--- a/homeassistant/components/hdmi_cec/manifest.json
+++ b/homeassistant/components/hdmi_cec/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/hdmi_cec",
"iot_class": "local_push",
"loggers": ["pycec"],
+ "quality_scale": "legacy",
"requirements": ["pyCEC==0.5.2"]
}
diff --git a/homeassistant/components/heatmiser/climate.py b/homeassistant/components/heatmiser/climate.py
index 1102dbc0c74..de66315a467 100644
--- a/homeassistant/components/heatmiser/climate.py
+++ b/homeassistant/components/heatmiser/climate.py
@@ -82,7 +82,6 @@ class HeatmiserV3Thermostat(ClimateEntity):
| ClimateEntityFeature.TURN_OFF
| ClimateEntityFeature.TURN_ON
)
- _enable_turn_on_off_backwards_compatibility = False
def __init__(self, therm, device, uh1):
"""Initialize the thermostat."""
diff --git a/homeassistant/components/heatmiser/manifest.json b/homeassistant/components/heatmiser/manifest.json
index f3f33f79b04..c7ffeb237ed 100644
--- a/homeassistant/components/heatmiser/manifest.json
+++ b/homeassistant/components/heatmiser/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/heatmiser",
"iot_class": "local_polling",
"loggers": ["heatmiserV3"],
+ "quality_scale": "legacy",
"requirements": ["heatmiserV3==2.0.3"]
}
diff --git a/homeassistant/components/heos/__init__.py b/homeassistant/components/heos/__init__.py
index 1573ff3f23e..9fd276c244e 100644
--- a/homeassistant/components/heos/__init__.py
+++ b/homeassistant/components/heos/__init__.py
@@ -3,15 +3,28 @@
from __future__ import annotations
import asyncio
+from dataclasses import dataclass
from datetime import timedelta
import logging
-from pyheos import Heos, HeosError, const as heos_const
-import voluptuous as vol
+from pyheos import (
+ Credentials,
+ Heos,
+ HeosError,
+ HeosOptions,
+ HeosPlayer,
+ const as heos_const,
+)
-from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
-from homeassistant.const import CONF_HOST, EVENT_HOMEASSISTANT_STOP, Platform
-from homeassistant.core import HomeAssistant, callback
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import (
+ CONF_HOST,
+ CONF_PASSWORD,
+ CONF_USERNAME,
+ 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
import homeassistant.helpers.config_validation as cv
@@ -23,14 +36,9 @@ from homeassistant.helpers.typing import ConfigType
from homeassistant.util import Throttle
from . import services
-from .config_flow import format_title
from .const import (
COMMAND_RETRY_ATTEMPTS,
COMMAND_RETRY_DELAY,
- DATA_CONTROLLER_MANAGER,
- DATA_ENTITY_ID_MAP,
- DATA_GROUP_MANAGER,
- DATA_SOURCE_MANAGER,
DOMAIN,
SIGNAL_HEOS_PLAYER_ADDED,
SIGNAL_HEOS_UPDATED,
@@ -38,56 +46,70 @@ from .const import (
PLATFORMS = [Platform.MEDIA_PLAYER]
-CONFIG_SCHEMA = vol.Schema(
- vol.All(
- cv.deprecated(DOMAIN),
- {DOMAIN: vol.Schema({vol.Required(CONF_HOST): cv.string})},
- ),
- extra=vol.ALLOW_EXTRA,
-)
-
MIN_UPDATE_SOURCES = timedelta(seconds=1)
+CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
+
_LOGGER = logging.getLogger(__name__)
+@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."""
- if DOMAIN not in config:
- return True
- host = config[DOMAIN][CONF_HOST]
- entries = hass.config_entries.async_entries(DOMAIN)
- if not entries:
- # Create new entry based on config
- hass.async_create_task(
- hass.config_entries.flow.async_init(
- DOMAIN, context={"source": SOURCE_IMPORT}, data={CONF_HOST: host}
- )
- )
- else:
- # Check if host needs to be updated
- entry = entries[0]
- if entry.data[CONF_HOST] != host:
- hass.config_entries.async_update_entry(
- entry, title=format_title(host), data={**entry.data, CONF_HOST: host}
- )
-
+ services.register(hass)
return True
-async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_setup_entry(hass: HomeAssistant, entry: HeosConfigEntry) -> bool:
"""Initialize config entry which represents the HEOS controller."""
# For backwards compat
if entry.unique_id is None:
hass.config_entries.async_update_entry(entry, unique_id=DOMAIN)
host = entry.data[CONF_HOST]
+ credentials: Credentials | None = None
+ if entry.options:
+ credentials = Credentials(
+ entry.options[CONF_USERNAME], 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
- controller = Heos(host, all_progress_events=False)
+ controller = Heos(
+ HeosOptions(
+ host,
+ all_progress_events=False,
+ auto_reconnect=True,
+ credentials=credentials,
+ )
+ )
+
+ # Auth failure handler must be added before connecting to the host, otherwise
+ # the event will be missed when login fails during connection.
+ async def auth_failure(event: str) -> None:
+ """Handle authentication failure."""
+ if event == heos_const.EVENT_USER_CREDENTIALS_INVALID:
+ entry.async_start_reauth(hass)
+
+ entry.async_on_unload(
+ controller.dispatcher.connect(heos_const.SIGNAL_HEOS_EVENT, auth_failure)
+ )
+
try:
- await controller.connect(auto_reconnect=True)
- # Auto reconnect only operates if initial connection was successful.
+ # Auto reconnect only operates if initial connection was successful.
+ await controller.connect()
except HeosError as error:
await controller.disconnect()
_LOGGER.debug("Unable to connect to controller %s: %s", host, error)
@@ -109,12 +131,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
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,
+ "The HEOS System is not logged in: Enter credentials in the integration options to access favorites and streaming services"
)
inputs = await controller.get_input_sources()
except HeosError as error:
@@ -128,19 +145,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
source_manager = SourceManager(favorites, inputs)
source_manager.connect_update(hass, controller)
- group_manager = GroupManager(hass, controller)
+ group_manager = GroupManager(hass, controller, players)
- hass.data[DOMAIN] = {
- DATA_CONTROLLER_MANAGER: controller_manager,
- DATA_GROUP_MANAGER: group_manager,
- DATA_SOURCE_MANAGER: source_manager,
- Platform.MEDIA_PLAYER: players,
- # Maps player_id to entity_id. Populated by the individual
- # HeosMediaPlayer entities.
- DATA_ENTITY_ID_MAP: {},
- }
+ 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)
@@ -149,14 +159,9 @@ 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: HeosConfigEntry) -> bool:
"""Unload a config entry."""
- controller_manager = hass.data[DOMAIN][DATA_CONTROLLER_MANAGER]
- await controller_manager.disconnect()
- hass.data.pop(DOMAIN)
-
- services.remove(hass)
-
+ await entry.runtime_data.controller_manager.disconnect()
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
@@ -169,7 +174,6 @@ class ControllerManager:
self._device_registry = None
self._entity_registry = None
self.controller = controller
- self._signals = []
async def connect_listeners(self):
"""Subscribe to events of interest."""
@@ -177,23 +181,17 @@ class ControllerManager:
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
- )
+ 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
- )
+ 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()
@@ -246,21 +244,25 @@ class ControllerManager:
class GroupManager:
"""Class that manages HEOS groups."""
- def __init__(self, hass, controller):
+ def __init__(
+ self, hass: HomeAssistant, controller: Heos, players: dict[int, HeosPlayer]
+ ) -> None:
"""Init group manager."""
self._hass = hass
- self._group_membership = {}
+ 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._hass.data[DOMAIN][DATA_ENTITY_ID_MAP].items()}
+ return {v: k for k, v in self.entity_id_map.items()}
- async def async_get_group_membership(self):
+ 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 = {
+ 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()
}
@@ -271,39 +273,37 @@ class GroupManager:
_LOGGER.error("Unable to get HEOS group info: %s", err)
return group_info_by_entity_id
- player_id_to_entity_id_map = self._hass.data[DOMAIN][DATA_ENTITY_ID_MAP]
+ 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)
+ leader_entity_id = player_id_to_entity_id_map.get(group.lead_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
+ player_id_to_entity_id_map[member]
+ for member in group.member_player_ids
+ if member 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
+ 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
+ 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_entity_id: str, member_entity_ids: list[str]
+ 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()
- leader_id = entity_id_to_player_id_map.get(leader_entity_id)
- if not leader_id:
- raise HomeAssistantError(
- f"The group leader {leader_entity_id} could not be resolved to a HEOS"
- " player."
- )
- member_ids = [
- entity_id_to_player_id_map[member]
- for member in member_entity_ids
- if member in 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)
@@ -315,14 +315,8 @@ class GroupManager:
err,
)
- async def async_unjoin_player(self, player_entity_id: str):
+ async def async_unjoin_player(self, player_id: int, player_entity_id: str):
"""Remove `player_entity_id` from any group."""
- player_id = self._get_entity_id_to_player_id_map().get(player_entity_id)
- if not player_id:
- raise HomeAssistantError(
- f"The player {player_entity_id} could not be resolved to a HEOS player."
- )
-
try:
await self.controller.create_group(player_id, [])
except HeosError as err:
@@ -358,13 +352,9 @@ class GroupManager:
# When adding a new HEOS player we need to update the groups.
async def _async_handle_player_added():
- # Avoid calling async_update_groups when `DATA_ENTITY_ID_MAP` has not been
+ # 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._hass.data[DOMAIN][Platform.MEDIA_PLAYER])
- <= len(self._hass.data[DOMAIN][DATA_ENTITY_ID_MAP])
- and not self._initialized
- ):
+ 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)
@@ -379,6 +369,17 @@ class GroupManager:
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."""
@@ -421,7 +422,7 @@ class SourceManager:
None,
)
if index is not None:
- await player.play_favorite(index)
+ await player.play_preset_station(index)
return
input_source = next(
@@ -433,7 +434,7 @@ class SourceManager:
None,
)
if input_source is not None:
- await player.play_input_source(input_source)
+ await player.play_input_source(input_source.media_id)
return
_LOGGER.error("Unknown source: %s", source)
@@ -446,7 +447,7 @@ class SourceManager:
(
input_source.name
for input_source in self.inputs
- if input_source.input_name == now_playing_media.media_id
+ if input_source.media_id == now_playing_media.media_id
),
None,
)
diff --git a/homeassistant/components/heos/config_flow.py b/homeassistant/components/heos/config_flow.py
index 57ed51a3c05..c47d83d3475 100644
--- a/homeassistant/components/heos/config_flow.py
+++ b/homeassistant/components/heos/config_flow.py
@@ -1,21 +1,102 @@
"""Config flow to configure Heos."""
-from typing import TYPE_CHECKING, Any
+from collections.abc import Mapping
+import logging
+from typing import TYPE_CHECKING, Any, cast
from urllib.parse import urlparse
-from pyheos import Heos, HeosError
+from pyheos import CommandFailedError, 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 (
+ ConfigEntry,
+ ConfigFlow,
+ ConfigFlowResult,
+ OptionsFlow,
+)
+from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
+from homeassistant.core import callback
+from homeassistant.helpers import selector
-from .const import DATA_DISCOVERED_HOSTS, DOMAIN
+from .const import DOMAIN
+
+_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:
"""Format the title for config entries."""
- return f"Controller ({host})"
+ return f"HEOS System (via {host})"
+
+
+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(HeosOptions(host, events=False, heart_beat=False))
+ try:
+ await heos.connect()
+ except HeosError:
+ errors[CONF_HOST] = "cannot_connect"
+ return False
+ finally:
+ await heos.disconnect()
+ 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 CommandFailedError as err:
+ if err.error_id in (6, 8, 10): # Auth-specific errors
+ errors["base"] = "invalid_auth"
+ _LOGGER.warning("Failed to sign-in to HEOS Account: %s", err)
+ else:
+ errors["base"] = "unknown"
+ _LOGGER.exception("Unexpected error occurred during sign-in")
+ 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):
@@ -23,6 +104,12 @@ class HeosFlowHandler(ConfigFlow, domain=DOMAIN):
VERSION = 1
+ @staticmethod
+ @callback
+ def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow:
+ """Create the options flow."""
+ return HeosOptionsFlowHandler()
+
async def async_step_ssdp(
self, discovery_info: ssdp.SsdpServiceInfo
) -> ConfigFlowResult:
@@ -34,56 +121,105 @@ class HeosFlowHandler(ConfigFlow, domain=DOMAIN):
friendly_name = (
f"{discovery_info.upnp[ssdp.ATTR_UPNP_FRIENDLY_NAME]} ({hostname})"
)
- self.hass.data.setdefault(DATA_DISCOVERED_HOSTS, {})
- self.hass.data[DATA_DISCOVERED_HOSTS][friendly_name] = hostname
- # Abort if other flows in progress or an entry already exists
- if self._async_in_progress() or self._async_current_entries():
- return self.async_abort(reason="single_instance_allowed")
+ self.hass.data.setdefault(DOMAIN, {})
+ self.hass.data[DOMAIN][friendly_name] = hostname
await self.async_set_unique_id(DOMAIN)
# Show selection form
return self.async_show_form(step_id="user")
- async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult:
- """Occurs when an entry is setup through config."""
- host = import_data[CONF_HOST]
- # raise_on_progress is False here in case ssdp discovers
- # heos first which would block the import
- await self.async_set_unique_id(DOMAIN, raise_on_progress=False)
- return self.async_create_entry(title=format_title(host), data={CONF_HOST: host})
-
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Obtain host and validate connection."""
- self.hass.data.setdefault(DATA_DISCOVERED_HOSTS, {})
- # Only a single entry is needed for all devices
- if self._async_current_entries():
- return self.async_abort(reason="single_instance_allowed")
+ self.hass.data.setdefault(DOMAIN, {})
+ await self.async_set_unique_id(DOMAIN)
# Try connecting to host if provided
- errors = {}
+ errors: dict[str, str] = {}
host = None
if user_input is not None:
host = user_input[CONF_HOST]
# Map host from friendly name if in discovered hosts
- host = self.hass.data[DATA_DISCOVERED_HOSTS].get(host, host)
- heos = Heos(host)
- try:
- await heos.connect()
- self.hass.data.pop(DATA_DISCOVERED_HOSTS)
- return await self.async_step_import({CONF_HOST: host})
- except HeosError:
- errors[CONF_HOST] = "cannot_connect"
- finally:
- await heos.disconnect()
+ host = self.hass.data[DOMAIN].get(host, host)
+ if await _validate_host(host, errors):
+ self.hass.data.pop(DOMAIN) # Remove discovery data
+ return self.async_create_entry(
+ title=format_title(host), data={CONF_HOST: host}
+ )
# Return form
host_type = (
- str
- if not self.hass.data[DATA_DISCOVERED_HOSTS]
- else vol.In(list(self.hass.data[DATA_DISCOVERED_HOSTS]))
+ str if not self.hass.data[DOMAIN] else vol.In(list(self.hass.data[DOMAIN]))
)
return self.async_show_form(
step_id="user",
data_schema=vol.Schema({vol.Required(CONF_HOST, default=host): host_type}),
errors=errors,
)
+
+ async def async_step_reconfigure(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Allow reconfiguration of entry."""
+ entry = self._get_reconfigure_entry()
+ host = entry.data[CONF_HOST] # Get current host value
+ errors: dict[str, str] = {}
+ if user_input is not None:
+ host = user_input[CONF_HOST]
+ if await _validate_host(host, errors):
+ return self.async_update_reload_and_abort(
+ entry, data_updates={CONF_HOST: host}
+ )
+ return self.async_show_form(
+ step_id="reconfigure",
+ 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 = self._get_reauth_entry()
+ if user_input is not None:
+ heos = cast(Heos, entry.runtime_data.controller_manager.controller)
+ if await _validate_auth(user_input, 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:
+ heos = cast(
+ Heos, self.config_entry.runtime_data.controller_manager.controller
+ )
+ if await _validate_auth(user_input, 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 636751d150b..5b2df2b5ebf 100644
--- a/homeassistant/components/heos/const.py
+++ b/homeassistant/components/heos/const.py
@@ -4,11 +4,6 @@ ATTR_PASSWORD = "password"
ATTR_USERNAME = "username"
COMMAND_RETRY_ATTEMPTS = 2
COMMAND_RETRY_DELAY = 1
-DATA_CONTROLLER_MANAGER = "controller"
-DATA_ENTITY_ID_MAP = "entity_id_map"
-DATA_GROUP_MANAGER = "group_manager"
-DATA_SOURCE_MANAGER = "source_manager"
-DATA_DISCOVERED_HOSTS = "heos_discovered_hosts"
DOMAIN = "heos"
SERVICE_SIGN_IN = "sign_in"
SERVICE_SIGN_OUT = "sign_out"
diff --git a/homeassistant/components/heos/manifest.json b/homeassistant/components/heos/manifest.json
index a90f0aebaae..d14ad71ff49 100644
--- a/homeassistant/components/heos/manifest.json
+++ b/homeassistant/components/heos/manifest.json
@@ -6,7 +6,8 @@
"documentation": "https://www.home-assistant.io/integrations/heos",
"iot_class": "local_push",
"loggers": ["pyheos"],
- "requirements": ["pyheos==0.7.2"],
+ "requirements": ["pyheos==0.9.0"],
+ "single_config_entry": true,
"ssdp": [
{
"st": "urn:schemas-denon-com:device:ACT-Denon:1"
diff --git a/homeassistant/components/heos/media_player.py b/homeassistant/components/heos/media_player.py
index 0f9f7facd33..924dcbe6b92 100644
--- a/homeassistant/components/heos/media_player.py
+++ b/homeassistant/components/heos/media_player.py
@@ -13,7 +13,6 @@ from pyheos import HeosError, const as heos_const
from homeassistant.components import media_source
from homeassistant.components.media_player import (
ATTR_MEDIA_ENQUEUE,
- DOMAIN as MEDIA_PLAYER_DOMAIN,
BrowseMedia,
MediaPlayerEnqueue,
MediaPlayerEntity,
@@ -22,7 +21,6 @@ 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.dispatcher import (
@@ -32,14 +30,8 @@ from homeassistant.helpers.dispatcher import (
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util.dt import utcnow
-from .const import (
- DATA_ENTITY_ID_MAP,
- DATA_GROUP_MANAGER,
- DATA_SOURCE_MANAGER,
- DOMAIN as HEOS_DOMAIN,
- SIGNAL_HEOS_PLAYER_ADDED,
- SIGNAL_HEOS_UPDATED,
-)
+from . import GroupManager, HeosConfigEntry, SourceManager
+from .const import DOMAIN as HEOS_DOMAIN, SIGNAL_HEOS_PLAYER_ADDED, SIGNAL_HEOS_UPDATED
BASE_SUPPORTED_FEATURES = (
MediaPlayerEntityFeature.VOLUME_MUTE
@@ -55,9 +47,9 @@ 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,
+ heos_const.PlayState.PLAY: MediaPlayerState.PLAYING,
+ heos_const.PlayState.STOP: MediaPlayerState.IDLE,
+ heos_const.PlayState.PAUSE: MediaPlayerState.PAUSED,
}
CONTROL_TO_SUPPORT = {
@@ -69,22 +61,27 @@ CONTROL_TO_SUPPORT = {
}
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: heos_const.AddCriteriaType.REPLACE_AND_PLAY,
+ MediaPlayerEnqueue.ADD: heos_const.AddCriteriaType.ADD_TO_END,
+ MediaPlayerEnqueue.REPLACE: heos_const.AddCriteriaType.REPLACE_AND_PLAY,
+ MediaPlayerEnqueue.NEXT: heos_const.AddCriteriaType.PLAY_NEXT,
+ MediaPlayerEnqueue.PLAY: heos_const.AddCriteriaType.PLAY_NOW,
}
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant, entry: HeosConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Add media players for a config entry."""
- players = hass.data[HEOS_DOMAIN][MEDIA_PLAYER_DOMAIN]
- devices = [HeosMediaPlayer(player) for player in players.values()]
+ players = entry.runtime_data.players
+ devices = [
+ HeosMediaPlayer(
+ player, entry.runtime_data.source_manager, entry.runtime_data.group_manager
+ )
+ for player in players.values()
+ ]
async_add_entities(devices, True)
@@ -120,13 +117,14 @@ class HeosMediaPlayer(MediaPlayerEntity):
_attr_has_entity_name = True
_attr_name = None
- def __init__(self, player):
+ def __init__(
+ self, player, source_manager: SourceManager, group_manager: GroupManager
+ ) -> None:
"""Initialize."""
self._media_position_updated_at = None
self._player = player
- self._signals = []
- self._source_manager = None
- self._group_manager = None
+ self._source_manager = source_manager
+ self._group_manager = group_manager
self._attr_unique_id = str(player.player_id)
self._attr_device_info = DeviceInfo(
identifiers={(HEOS_DOMAIN, player.player_id)},
@@ -151,18 +149,20 @@ class HeosMediaPlayer(MediaPlayerEntity):
async def async_added_to_hass(self) -> None:
"""Device added to hass."""
# Update state when attributes of the player change
- self._signals.append(
+ self.async_on_remove(
self._player.heos.dispatcher.connect(
heos_const.SIGNAL_PLAYER_EVENT, self._player_update
)
)
# Update state when heos changes
- self._signals.append(
+ self.async_on_remove(
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.hass.data[HEOS_DOMAIN][DATA_ENTITY_ID_MAP][self._player.player_id] = (
- self.entity_id
+ 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)
@@ -174,7 +174,9 @@ class HeosMediaPlayer(MediaPlayerEntity):
@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.entity_id, group_members)
+ await self._group_manager.async_join_players(
+ self._player.player_id, self.entity_id, group_members
+ )
@log_command_error("pause")
async def async_media_pause(self) -> None:
@@ -266,7 +268,7 @@ class HeosMediaPlayer(MediaPlayerEntity):
)
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}'")
@@ -294,22 +296,12 @@ class HeosMediaPlayer(MediaPlayerEntity):
ior, current_support, BASE_SUPPORTED_FEATURES
)
- if self._group_manager is None:
- self._group_manager = self.hass.data[HEOS_DOMAIN][DATA_GROUP_MANAGER]
-
- if self._source_manager is None:
- self._source_manager = self.hass.data[HEOS_DOMAIN][DATA_SOURCE_MANAGER]
-
@log_command_error("unjoin_player")
async def async_unjoin_player(self) -> None:
"""Remove this player from any group."""
- await self._group_manager.async_unjoin_player(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()
+ await self._group_manager.async_unjoin_player(
+ self._player.player_id, self.entity_id
+ )
@property
def available(self) -> bool:
diff --git a/homeassistant/components/heos/quality_scale.yaml b/homeassistant/components/heos/quality_scale.yaml
new file mode 100644
index 00000000000..4cd39434521
--- /dev/null
+++ b/homeassistant/components/heos/quality_scale.yaml
@@ -0,0 +1,85 @@
+rules:
+ # Bronze
+ action-setup: done
+ appropriate-polling:
+ status: done
+ comment: Integration is a local push integration
+ brands: done
+ common-modules: todo
+ config-flow-test-coverage: done
+ config-flow:
+ status: done
+ comment: Consider enhnacement to automatically select a host when multiple are discovered.
+ 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:
+ status: todo
+ comment: Actions currently only log and instead should raise exceptions.
+ config-entry-unloading: done
+ 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: 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.
+ # 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
+ discovery-update-info:
+ status: todo
+ comment: Explore if this is possible.
+ 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: todo
+ entity-category: done
+ entity-device-class: done
+ entity-disabled-by-default: done
+ entity-translations: done
+ exception-translations: todo
+ icon-translations: done
+ reconfiguration-flow: done
+ repair-issues: todo
+ stale-devices: todo
+ # Platinum
+ async-dependency: done
+ inject-websession:
+ status: done
+ comment: The integration does not use websession
+ strict-typing: todo
diff --git a/homeassistant/components/heos/services.py b/homeassistant/components/heos/services.py
index 2ef80b6efd9..edd9cf37714 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
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
+from homeassistant.helpers import config_validation as cv, issue_registry as ir
from .const import (
ATTR_PASSWORD,
@@ -26,30 +27,50 @@ HEOS_SIGN_IN_SCHEMA = vol.Schema(
HEOS_SIGN_OUT_SCHEMA = vol.Schema({})
-def register(hass: HomeAssistant, controller: Heos):
+def register(hass: HomeAssistant):
"""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 = 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.controller_manager.controller
-async def _sign_in_handler(controller: Heos, service: ServiceCall) -> None:
+async def _sign_in_handler(service: ServiceCall) -> None:
"""Sign in to the HEOS account."""
+
+ controller = _get_controller(service.hass)
if controller.connection_state != const.STATE_CONNECTED:
_LOGGER.error("Unable to sign in because HEOS is not connected")
return
@@ -63,8 +84,10 @@ async def _sign_in_handler(controller: Heos, service: ServiceCall) -> None:
_LOGGER.error("Unable to sign in: %s", 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."""
+
+ controller = _get_controller(service.hass)
if controller.connection_state != const.STATE_CONNECTED:
_LOGGER.error("Unable to sign out because HEOS is not connected")
return
diff --git a/homeassistant/components/heos/strings.json b/homeassistant/components/heos/strings.json
index df18fc7834a..0506c37fa77 100644
--- a/homeassistant/components/heos/strings.json
+++ b/homeassistant/components/heos/strings.json
@@ -2,27 +2,78 @@
"config": {
"step": {
"user": {
- "title": "Connect to Heos",
- "description": "Please enter the host name or IP address of a Heos device (preferably one connected via wire to the network).",
+ "title": "Connect to HEOS",
+ "description": "Please enter the host name or IP address of a HEOS-capable product to access your HEOS System.",
"data": {
"host": "[%key:common::config_flow::data::host%]"
},
"data_description": {
- "host": "The hostname or IP address of your HEOS device."
+ "host": "Host name or IP address of a HEOS-capable product (preferrably one connected via wire to the network)."
+ }
+ },
+ "reconfigure": {
+ "title": "Reconfigure HEOS",
+ "description": "Change the host name or IP address of the HEOS-capable product used to access your HEOS System.",
+ "data": {
+ "host": "[%key:common::config_flow::data::host%]"
+ },
+ "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",
- "description": "Signs the controller in to a HEOS account.",
+ "description": "Signs in to a HEOS account.",
"fields": {
"username": {
"name": "[%key:common::config_flow::data::username%]",
@@ -36,7 +87,18 @@
},
"sign_out": {
"name": "Sign out",
- "description": "Signs the controller out of the HEOS account."
+ "description": "Signs out of the HEOS account."
+ }
+ },
+ "exceptions": {
+ "integration_not_loaded": {
+ "message": "The HEOS integration is not loaded"
+ }
+ },
+ "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/hikvision/manifest.json b/homeassistant/components/hikvision/manifest.json
index e37e149ccda..a0832732105 100644
--- a/homeassistant/components/hikvision/manifest.json
+++ b/homeassistant/components/hikvision/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/hikvision",
"iot_class": "local_push",
"loggers": ["pyhik"],
+ "quality_scale": "legacy",
"requirements": ["pyHik==0.3.2"]
}
diff --git a/homeassistant/components/hikvisioncam/manifest.json b/homeassistant/components/hikvisioncam/manifest.json
index 28f677512b7..badb38a52d5 100644
--- a/homeassistant/components/hikvisioncam/manifest.json
+++ b/homeassistant/components/hikvisioncam/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/hikvisioncam",
"iot_class": "local_polling",
"loggers": ["hikvision"],
+ "quality_scale": "legacy",
"requirements": ["hikvision==0.4"]
}
diff --git a/homeassistant/components/hisense_aehw4a1/climate.py b/homeassistant/components/hisense_aehw4a1/climate.py
index 656ba6c68c0..68f79439162 100644
--- a/homeassistant/components/hisense_aehw4a1/climate.py
+++ b/homeassistant/components/hisense_aehw4a1/climate.py
@@ -155,7 +155,6 @@ class ClimateAehW4a1(ClimateEntity):
_attr_target_temperature_step = 1
_previous_state: HVACMode | str | None = None
_on: str | None = None
- _enable_turn_on_off_backwards_compatibility = False
def __init__(self, device):
"""Initialize the climate device."""
diff --git a/homeassistant/components/history/__init__.py b/homeassistant/components/history/__init__.py
index 365be06fd2d..7241e1fac9a 100644
--- a/homeassistant/components/history/__init__.py
+++ b/homeassistant/components/history/__init__.py
@@ -22,7 +22,7 @@ import homeassistant.util.dt as dt_util
from . import websocket_api
from .const import DOMAIN
-from .helpers import entities_may_have_state_changes_after, has_recorder_run_after
+from .helpers import entities_may_have_state_changes_after, has_states_before
CONF_ORDER = "use_include_order"
@@ -107,7 +107,10 @@ class HistoryPeriodView(HomeAssistantView):
no_attributes = "no_attributes" in request.query
if (
- (end_time and not has_recorder_run_after(hass, end_time))
+ # has_states_before will return True if there are states older than
+ # 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(
diff --git a/homeassistant/components/history/helpers.py b/homeassistant/components/history/helpers.py
index bd477e7e4ed..2010b7373ff 100644
--- a/homeassistant/components/history/helpers.py
+++ b/homeassistant/components/history/helpers.py
@@ -6,7 +6,6 @@ from collections.abc import Iterable
from datetime import datetime as dt
from homeassistant.components.recorder import get_instance
-from homeassistant.components.recorder.models import process_timestamp
from homeassistant.core import HomeAssistant
@@ -26,8 +25,10 @@ def entities_may_have_state_changes_after(
return False
-def has_recorder_run_after(hass: HomeAssistant, run_time: dt) -> bool:
- """Check if the recorder has any runs after a specific time."""
- return run_time >= process_timestamp(
- get_instance(hass).recorder_runs_manager.first.start
- )
+def has_states_before(hass: HomeAssistant, run_time: dt) -> bool:
+ """Check if the recorder has states as old or older than run_time.
+
+ Returns True if there may be such states.
+ """
+ oldest_ts = get_instance(hass).states_manager.oldest_ts
+ return oldest_ts is not None and run_time.timestamp() >= oldest_ts
diff --git a/homeassistant/components/history/websocket_api.py b/homeassistant/components/history/websocket_api.py
index c85d975c3c9..35f8ed5f1ac 100644
--- a/homeassistant/components/history/websocket_api.py
+++ b/homeassistant/components/history/websocket_api.py
@@ -39,7 +39,7 @@ 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_recorder_run_after
+from .helpers import entities_may_have_state_changes_after, has_states_before
_LOGGER = logging.getLogger(__name__)
@@ -142,7 +142,10 @@ async def ws_get_history_during_period(
no_attributes = msg["no_attributes"]
if (
- (end_time and not has_recorder_run_after(hass, end_time))
+ # has_states_before will return True if there are states older than
+ # 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(
diff --git a/homeassistant/components/history_stats/data.py b/homeassistant/components/history_stats/data.py
index 544e1772b01..83528b73f6f 100644
--- a/homeassistant/components/history_stats/data.py
+++ b/homeassistant/components/history_stats/data.py
@@ -4,6 +4,8 @@ from __future__ import annotations
from dataclasses import dataclass
import datetime
+import logging
+import math
from homeassistant.components.recorder import get_instance, history
from homeassistant.core import Event, EventStateChangedData, HomeAssistant, State
@@ -14,6 +16,8 @@ from .helpers import async_calculate_period, floored_timestamp
MIN_TIME_UTC = datetime.datetime.min.replace(tzinfo=dt_util.UTC)
+_LOGGER = logging.getLogger(__name__)
+
@dataclass
class HistoryStatsState:
@@ -114,9 +118,7 @@ class HistoryStats:
<= current_period_end_timestamp
):
self._history_current_period.append(
- HistoryState(
- new_state.state, new_state.last_changed.timestamp()
- )
+ HistoryState(new_state.state, new_state.last_changed_timestamp)
)
new_data = True
if not new_data and current_period_end_timestamp < now_timestamp:
@@ -127,6 +129,16 @@ class HistoryStats:
await self._async_history_from_db(
current_period_start_timestamp, current_period_end_timestamp
)
+ if event and (new_state := event.data["new_state"]) is not None:
+ if (
+ current_period_start_timestamp
+ <= floored_timestamp(new_state.last_changed)
+ <= current_period_end_timestamp
+ ):
+ self._history_current_period.append(
+ HistoryState(new_state.state, new_state.last_changed_timestamp)
+ )
+
self._previous_run_before_start = False
seconds_matched, match_count = self._async_compute_seconds_and_changes(
@@ -176,26 +188,32 @@ class HistoryStats:
# state_changes_during_period is called with include_start_time_state=True
# which is the default and always provides the state at the start
# of the period
- previous_state_matches = (
- self._history_current_period
- and self._history_current_period[0].state in self._entity_states
- )
- last_state_change_timestamp = start_timestamp
+ previous_state_matches = False
+ last_state_change_timestamp = 0.0
elapsed = 0.0
- match_count = 1 if previous_state_matches else 0
+ match_count = 0
# Make calculations
for history_state in self._history_current_period:
current_state_matches = history_state.state in self._entity_states
state_change_timestamp = history_state.last_changed
+ if math.floor(state_change_timestamp) > now_timestamp:
+ # Shouldn't count states that are in the future
+ _LOGGER.debug(
+ "Skipping future timestamp %s (now %s)",
+ state_change_timestamp,
+ now_timestamp,
+ )
+ continue
+
if previous_state_matches:
elapsed += state_change_timestamp - last_state_change_timestamp
elif current_state_matches:
match_count += 1
previous_state_matches = current_state_matches
- last_state_change_timestamp = state_change_timestamp
+ last_state_change_timestamp = max(start_timestamp, state_change_timestamp)
# Count time elapsed between last history state and end of measure
if previous_state_matches:
diff --git a/homeassistant/components/history_stats/strings.json b/homeassistant/components/history_stats/strings.json
index 8961d66118d..aff2ac50bef 100644
--- a/homeassistant/components/history_stats/strings.json
+++ b/homeassistant/components/history_stats/strings.json
@@ -9,7 +9,7 @@
},
"step": {
"user": {
- "description": "Add a history stats sensor",
+ "description": "Create a history stats sensor",
"data": {
"name": "[%key:common::config_flow::data::name%]",
"entity_id": "Entity",
diff --git a/homeassistant/components/hitron_coda/manifest.json b/homeassistant/components/hitron_coda/manifest.json
index 2f18707c95e..15f71b62cf3 100644
--- a/homeassistant/components/hitron_coda/manifest.json
+++ b/homeassistant/components/hitron_coda/manifest.json
@@ -3,5 +3,6 @@
"name": "Rogers Hitron CODA",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/hitron_coda",
- "iot_class": "local_polling"
+ "iot_class": "local_polling",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/hive/__init__.py b/homeassistant/components/hive/__init__.py
index 1c11ccad595..ac008b857af 100644
--- a/homeassistant/components/hive/__init__.py
+++ b/homeassistant/components/hive/__init__.py
@@ -10,65 +10,24 @@ from typing import Any, Concatenate
from aiohttp.web_exceptions import HTTPException
from apyhiveapi import Auth, Hive
from apyhiveapi.helper.hive_exceptions import HiveReauthRequired
-import voluptuous as vol
-from homeassistant import config_entries
from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import CONF_PASSWORD, CONF_SCAN_INTERVAL, CONF_USERNAME
+from homeassistant.const import CONF_SCAN_INTERVAL
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
-from homeassistant.helpers import aiohttp_client, config_validation as cv
+from homeassistant.helpers import aiohttp_client
from homeassistant.helpers.device_registry import DeviceEntry
from homeassistant.helpers.dispatcher import async_dispatcher_send
-from homeassistant.helpers.typing import ConfigType
from .const import DOMAIN, PLATFORM_LOOKUP, PLATFORMS
from .entity import HiveEntity
_LOGGER = logging.getLogger(__name__)
-CONFIG_SCHEMA = vol.Schema(
- vol.All(
- cv.deprecated(DOMAIN),
- {
- DOMAIN: vol.Schema(
- {
- vol.Required(CONF_PASSWORD): cv.string,
- vol.Required(CONF_USERNAME): cv.string,
- vol.Optional(CONF_SCAN_INTERVAL, default=2): cv.positive_int,
- },
- )
- },
- ),
- extra=vol.ALLOW_EXTRA,
-)
-
-
-async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
- """Hive configuration setup."""
- hass.data[DOMAIN] = {}
-
- if DOMAIN not in config:
- return True
-
- conf = config[DOMAIN]
-
- if not hass.config_entries.async_entries(DOMAIN):
- hass.async_create_task(
- hass.config_entries.flow.async_init(
- DOMAIN,
- context={"source": config_entries.SOURCE_IMPORT},
- data={
- CONF_USERNAME: conf[CONF_USERNAME],
- CONF_PASSWORD: conf[CONF_PASSWORD],
- },
- )
- )
- return True
-
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Hive from a config entry."""
+ hass.data.setdefault(DOMAIN, {})
web_session = aiohttp_client.async_get_clientsession(hass)
hive_config = dict(entry.data)
diff --git a/homeassistant/components/hive/binary_sensor.py b/homeassistant/components/hive/binary_sensor.py
index d14d98bcf50..d2938896f92 100644
--- a/homeassistant/components/hive/binary_sensor.py
+++ b/homeassistant/components/hive/binary_sensor.py
@@ -113,12 +113,17 @@ class HiveBinarySensorEntity(HiveEntity, BinarySensorEntity):
await self.hive.session.updateData(self.device)
self.device = await self.hive.sensor.getSensor(self.device)
self.attributes = self.device.get("attributes", {})
- self._attr_is_on = self.device["status"]["state"]
+
if self.device["hiveType"] != "Connectivity":
- self._attr_available = self.device["deviceData"].get("online")
+ self._attr_available = (
+ self.device["deviceData"].get("online") and "status" in self.device
+ )
else:
self._attr_available = True
+ if self._attr_available:
+ self._attr_is_on = self.device["status"].get("state")
+
class HiveSensorEntity(HiveEntity, BinarySensorEntity):
"""Hive Sensor Entity."""
diff --git a/homeassistant/components/hive/climate.py b/homeassistant/components/hive/climate.py
index 4e5ea95f2fa..c76379cf940 100644
--- a/homeassistant/components/hive/climate.py
+++ b/homeassistant/components/hive/climate.py
@@ -100,7 +100,6 @@ class HiveClimateEntity(HiveEntity, ClimateEntity):
| ClimateEntityFeature.TURN_OFF
| ClimateEntityFeature.TURN_ON
)
- _enable_turn_on_off_backwards_compatibility = False
def __init__(self, hive: Hive, hive_device: dict[str, Any]) -> None:
"""Initialize the Climate device."""
diff --git a/homeassistant/components/hive/config_flow.py b/homeassistant/components/hive/config_flow.py
index a997954f4cc..e3180dc9734 100644
--- a/homeassistant/components/hive/config_flow.py
+++ b/homeassistant/components/hive/config_flow.py
@@ -104,7 +104,7 @@ class HiveFlowHandler(ConfigFlow, domain=DOMAIN):
errors["base"] = "no_internet_available"
if not errors:
- if self.context["source"] == SOURCE_REAUTH:
+ if self.source == SOURCE_REAUTH:
return await self.async_setup_hive_entry()
self.device_registration = True
return await self.async_step_configuration()
@@ -144,7 +144,7 @@ class HiveFlowHandler(ConfigFlow, domain=DOMAIN):
# Setup the config entry
self.data["tokens"] = self.tokens
- if self.context["source"] == SOURCE_REAUTH:
+ if self.source == SOURCE_REAUTH:
assert self.entry
self.hass.config_entries.async_update_entry(
self.entry, title=self.data["username"], data=self.data
@@ -163,10 +163,6 @@ class HiveFlowHandler(ConfigFlow, domain=DOMAIN):
}
return await self.async_step_user(data)
- async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult:
- """Import user."""
- return await self.async_step_user(import_data)
-
@staticmethod
@callback
def async_get_options_flow(
diff --git a/homeassistant/components/hive/light.py b/homeassistant/components/hive/light.py
index 10de781bf1d..8d09c902f36 100644
--- a/homeassistant/components/hive/light.py
+++ b/homeassistant/components/hive/light.py
@@ -7,7 +7,7 @@ from typing import TYPE_CHECKING, Any
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
- ATTR_COLOR_TEMP,
+ ATTR_COLOR_TEMP_KELVIN,
ATTR_HS_COLOR,
ColorMode,
LightEntity,
@@ -43,6 +43,9 @@ async def async_setup_entry(
class HiveDeviceLight(HiveEntity, LightEntity):
"""Hive Active Light Device."""
+ _attr_min_color_temp_kelvin = 2700 # 370 Mireds
+ _attr_max_color_temp_kelvin = 6500 # 153 Mireds
+
def __init__(self, hive: Hive, hive_device: dict[str, Any]) -> None:
"""Initialise hive light."""
super().__init__(hive, hive_device)
@@ -56,9 +59,6 @@ class HiveDeviceLight(HiveEntity, LightEntity):
self._attr_supported_color_modes = {ColorMode.COLOR_TEMP, ColorMode.HS}
self._attr_color_mode = ColorMode.UNKNOWN
- self._attr_min_mireds = 153
- self._attr_max_mireds = 370
-
@refresh_system
async def async_turn_on(self, **kwargs: Any) -> None:
"""Instruct the light to turn on."""
@@ -71,9 +71,8 @@ class HiveDeviceLight(HiveEntity, LightEntity):
new_brightness = int(round(percentage_brightness / 5.0) * 5.0)
if new_brightness == 0:
new_brightness = 5
- if ATTR_COLOR_TEMP in kwargs:
- tmp_new_color_temp = kwargs[ATTR_COLOR_TEMP]
- new_color_temp = round(1000000 / tmp_new_color_temp)
+ if ATTR_COLOR_TEMP_KELVIN in kwargs:
+ new_color_temp = kwargs[ATTR_COLOR_TEMP_KELVIN]
if ATTR_HS_COLOR in kwargs:
get_new_color = kwargs[ATTR_HS_COLOR]
hue = int(get_new_color[0])
@@ -102,12 +101,23 @@ class HiveDeviceLight(HiveEntity, LightEntity):
self._attr_is_on = self.device["status"]["state"]
self._attr_brightness = self.device["status"]["brightness"]
if self.device["hiveType"] == "tuneablelight":
- self._attr_color_temp = self.device["status"].get("color_temp")
+ color_temp = self.device["status"].get("color_temp")
+ self._attr_color_temp_kelvin = (
+ None
+ if color_temp is None
+ else color_util.color_temperature_mired_to_kelvin(color_temp)
+ )
+
if self.device["hiveType"] == "colourtuneablelight":
if self.device["status"]["mode"] == "COLOUR":
rgb = self.device["status"]["hs_color"]
self._attr_hs_color = color_util.color_RGB_to_hs(*rgb)
self._attr_color_mode = ColorMode.HS
else:
- self._attr_color_temp = self.device["status"].get("color_temp")
+ color_temp = self.device["status"].get("color_temp")
+ self._attr_color_temp_kelvin = (
+ None
+ if color_temp is None
+ else color_util.color_temperature_mired_to_kelvin(color_temp)
+ )
self._attr_color_mode = ColorMode.COLOR_TEMP
diff --git a/homeassistant/components/holiday/__init__.py b/homeassistant/components/holiday/__init__.py
index c9a58f29215..b364f2c67a4 100644
--- a/homeassistant/components/holiday/__init__.py
+++ b/homeassistant/components/holiday/__init__.py
@@ -11,7 +11,7 @@ from homeassistant.const import CONF_COUNTRY, Platform
from homeassistant.core import HomeAssistant
from homeassistant.setup import SetupPhases, async_pause_setup
-from .const import CONF_PROVINCE
+from .const import CONF_CATEGORIES, CONF_PROVINCE
PLATFORMS: list[Platform] = [Platform.CALENDAR]
@@ -20,6 +20,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Holiday from a config entry."""
country: str = entry.data[CONF_COUNTRY]
province: str | None = entry.data.get(CONF_PROVINCE)
+ categories: list[str] | None = entry.options.get(CONF_CATEGORIES)
# We only import here to ensure that that its not imported later
# in the event loop since the platforms will call country_holidays
@@ -29,14 +30,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
# the holidays library and it is not thread safe to import it in parallel
# https://github.com/python/cpython/issues/83065
await hass.async_add_import_executor_job(
- partial(country_holidays, country, subdiv=province)
+ partial(country_holidays, country, subdiv=province, categories=categories)
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
+ entry.async_on_unload(entry.add_update_listener(update_listener))
return True
+async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
+ """Handle options update."""
+ await hass.config_entries.async_reload(entry.entry_id)
+
+
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/holiday/calendar.py b/homeassistant/components/holiday/calendar.py
index 6a336870857..6dccd972164 100644
--- a/homeassistant/components/holiday/calendar.py
+++ b/homeassistant/components/holiday/calendar.py
@@ -4,7 +4,7 @@ from __future__ import annotations
from datetime import datetime, timedelta
-from holidays import HolidayBase, country_holidays
+from holidays import PUBLIC, HolidayBase, country_holidays
from homeassistant.components.calendar import CalendarEntity, CalendarEvent
from homeassistant.config_entries import ConfigEntry
@@ -15,18 +15,27 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.event import async_track_point_in_utc_time
from homeassistant.util import dt as dt_util
-from .const import CONF_PROVINCE, DOMAIN
+from .const import CONF_CATEGORIES, CONF_PROVINCE, DOMAIN
def _get_obj_holidays_and_language(
- country: str, province: str | None, language: str
+ country: str,
+ province: str | None,
+ language: str,
+ selected_categories: list[str] | None,
) -> tuple[HolidayBase, str]:
"""Get the object for the requested country and year."""
+ if selected_categories is None:
+ categories = [PUBLIC]
+ else:
+ categories = [PUBLIC, *selected_categories]
+
obj_holidays = country_holidays(
country,
subdiv=province,
years={dt_util.now().year, dt_util.now().year + 1},
language=language,
+ categories=categories,
)
if language == "en":
for lang in obj_holidays.supported_languages:
@@ -36,6 +45,7 @@ def _get_obj_holidays_and_language(
subdiv=province,
years={dt_util.now().year, dt_util.now().year + 1},
language=lang,
+ categories=categories,
)
language = lang
break
@@ -49,6 +59,7 @@ def _get_obj_holidays_and_language(
subdiv=province,
years={dt_util.now().year, dt_util.now().year + 1},
language=default_language,
+ categories=categories,
)
language = default_language
@@ -63,10 +74,11 @@ async def async_setup_entry(
"""Set up the Holiday Calendar config entry."""
country: str = config_entry.data[CONF_COUNTRY]
province: str | None = config_entry.data.get(CONF_PROVINCE)
+ categories: list[str] | None = config_entry.options.get(CONF_CATEGORIES)
language = hass.config.language
obj_holidays, language = await hass.async_add_executor_job(
- _get_obj_holidays_and_language, country, province, language
+ _get_obj_holidays_and_language, country, province, language, categories
)
async_add_entities(
@@ -76,6 +88,7 @@ async def async_setup_entry(
country,
province,
language,
+ categories,
obj_holidays,
config_entry.entry_id,
)
@@ -99,6 +112,7 @@ class HolidayCalendarEntity(CalendarEntity):
country: str,
province: str | None,
language: str,
+ categories: list[str] | None,
obj_holidays: HolidayBase,
unique_id: str,
) -> None:
@@ -107,6 +121,7 @@ class HolidayCalendarEntity(CalendarEntity):
self._province = province
self._location = name
self._language = language
+ self._categories = categories
self._attr_unique_id = unique_id
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, unique_id)},
@@ -172,6 +187,7 @@ class HolidayCalendarEntity(CalendarEntity):
subdiv=self._province,
years=list({start_date.year, end_date.year}),
language=self._language,
+ categories=self._categories,
)
event_list: list[CalendarEvent] = []
diff --git a/homeassistant/components/holiday/config_flow.py b/homeassistant/components/holiday/config_flow.py
index 27b13e34851..6d29e09c0f8 100644
--- a/homeassistant/components/holiday/config_flow.py
+++ b/homeassistant/components/holiday/config_flow.py
@@ -5,24 +5,90 @@ from __future__ import annotations
from typing import Any
from babel import Locale, UnknownLocaleError
-from holidays import list_supported_countries
+from holidays import PUBLIC, country_holidays, list_supported_countries
import voluptuous as vol
-from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
+from homeassistant.config_entries import (
+ ConfigEntry,
+ ConfigFlow,
+ ConfigFlowResult,
+ OptionsFlow,
+)
from homeassistant.const import CONF_COUNTRY
+from homeassistant.core import callback
from homeassistant.helpers.selector import (
CountrySelector,
CountrySelectorConfig,
+ SelectOptionDict,
SelectSelector,
SelectSelectorConfig,
SelectSelectorMode,
)
+from homeassistant.util import dt as dt_util
-from .const import CONF_PROVINCE, DOMAIN
+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.
+
+ public holidays are always included so they
+ don't need to be presented to the user.
+ """
+ country_data = country_holidays(country, years=dt_util.utcnow().year)
+ return [
+ category for category in country_data.supported_categories if category != PUBLIC
+ ]
+
+
+def get_options_schema(country: str) -> vol.Schema:
+ """Return the options schema."""
+ schema = {}
+ if provinces := get_optional_provinces(country):
+ schema[vol.Optional(CONF_PROVINCE)] = SelectSelector(
+ SelectSelectorConfig(
+ options=provinces,
+ mode=SelectSelectorMode.DROPDOWN,
+ )
+ )
+ if categories := get_optional_categories(country):
+ schema[vol.Optional(CONF_CATEGORIES)] = SelectSelector(
+ SelectSelectorConfig(
+ options=categories,
+ multiple=True,
+ mode=SelectSelectorMode.DROPDOWN,
+ translation_key="categories",
+ )
+ )
+ return vol.Schema(schema)
+
+
class HolidayConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Holiday."""
@@ -32,6 +98,12 @@ class HolidayConfigFlow(ConfigFlow, domain=DOMAIN):
"""Initialize the config flow."""
self.data: dict[str, Any] = {}
+ @staticmethod
+ @callback
+ def async_get_options_flow(config_entry: ConfigEntry) -> HolidayOptionsFlowHandler:
+ """Get the options flow for this handler."""
+ return HolidayOptionsFlowHandler()
+
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
@@ -41,8 +113,11 @@ class HolidayConfigFlow(ConfigFlow, domain=DOMAIN):
selected_country = user_input[CONF_COUNTRY]
- if SUPPORTED_COUNTRIES[selected_country]:
- return await self.async_step_province()
+ options_schema = await self.hass.async_add_executor_job(
+ get_options_schema, selected_country
+ )
+ if options_schema.schema:
+ return await self.async_step_options()
self._async_abort_entries_match({CONF_COUNTRY: user_input[CONF_COUNTRY]})
@@ -67,24 +142,22 @@ class HolidayConfigFlow(ConfigFlow, domain=DOMAIN):
}
)
- return self.async_show_form(step_id="user", data_schema=user_schema)
+ return self.async_show_form(data_schema=user_schema)
- async def async_step_province(
+ async def async_step_options(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
- """Handle the province step."""
+ """Handle the options step."""
if user_input is not None:
- combined_input: dict[str, Any] = {**self.data, **user_input}
+ country = self.data[CONF_COUNTRY]
+ data = {CONF_COUNTRY: country}
+ options: dict[str, Any] | None = None
+ if province := user_input.get(CONF_PROVINCE):
+ data[CONF_PROVINCE] = province
+ if categories := user_input.get(CONF_CATEGORIES):
+ options = {CONF_CATEGORIES: categories}
- country = combined_input[CONF_COUNTRY]
- province = combined_input.get(CONF_PROVINCE)
-
- self._async_abort_entries_match(
- {
- CONF_COUNTRY: country,
- CONF_PROVINCE: province,
- }
- )
+ self._async_abort_entries_match({**data, **(options or {})})
try:
locale = Locale.parse(self.hass.config.language, sep="-")
@@ -95,38 +168,33 @@ class HolidayConfigFlow(ConfigFlow, domain=DOMAIN):
province_str = f", {province}" if province else ""
name = f"{locale.territories[country]}{province_str}"
- return self.async_create_entry(title=name, data=combined_input)
+ return self.async_create_entry(title=name, data=data, options=options)
- province_schema = vol.Schema(
- {
- vol.Optional(CONF_PROVINCE): SelectSelector(
- SelectSelectorConfig(
- options=SUPPORTED_COUNTRIES[self.data[CONF_COUNTRY]],
- mode=SelectSelectorMode.DROPDOWN,
- )
- ),
- }
+ options_schema = await self.hass.async_add_executor_job(
+ get_options_schema, self.data[CONF_COUNTRY]
+ )
+ return self.async_show_form(
+ step_id="options",
+ data_schema=options_schema,
+ description_placeholders={CONF_COUNTRY: self.data[CONF_COUNTRY]},
)
-
- return self.async_show_form(step_id="province", data_schema=province_schema)
async def async_step_reconfigure(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
- """Handle the re-configuration of a province."""
+ """Handle the re-configuration of the options."""
reconfigure_entry = self._get_reconfigure_entry()
+
if user_input is not None:
- combined_input: dict[str, Any] = {**reconfigure_entry.data, **user_input}
+ country = reconfigure_entry.data[CONF_COUNTRY]
+ data = {CONF_COUNTRY: country}
+ options: dict[str, Any] | None = None
+ if province := user_input.get(CONF_PROVINCE):
+ data[CONF_PROVINCE] = province
+ if categories := user_input.get(CONF_CATEGORIES):
+ options = {CONF_CATEGORIES: categories}
- country = combined_input[CONF_COUNTRY]
- province = combined_input.get(CONF_PROVINCE)
-
- self._async_abort_entries_match(
- {
- CONF_COUNTRY: country,
- CONF_PROVINCE: province,
- }
- )
+ self._async_abort_entries_match({**data, **(options or {})})
try:
locale = Locale.parse(self.hass.config.language, sep="-")
@@ -137,21 +205,60 @@ class HolidayConfigFlow(ConfigFlow, domain=DOMAIN):
province_str = f", {province}" if province else ""
name = f"{locale.territories[country]}{province_str}"
+ if options:
+ return self.async_update_reload_and_abort(
+ reconfigure_entry, title=name, data=data, options=options
+ )
return self.async_update_reload_and_abort(
- reconfigure_entry, title=name, data=combined_input
+ reconfigure_entry, title=name, data=data
)
- province_schema = vol.Schema(
+ options_schema = await self.hass.async_add_executor_job(
+ get_options_schema, reconfigure_entry.data[CONF_COUNTRY]
+ )
+
+ return self.async_show_form(
+ data_schema=options_schema,
+ description_placeholders={
+ CONF_COUNTRY: reconfigure_entry.data[CONF_COUNTRY]
+ },
+ )
+
+
+class HolidayOptionsFlowHandler(OptionsFlow):
+ """Handle Holiday options."""
+
+ async def async_step_init(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Manage Holiday options."""
+ if user_input is not None:
+ return self.async_create_entry(data=user_input)
+
+ categories = await self.hass.async_add_executor_job(
+ get_optional_categories, self.config_entry.data[CONF_COUNTRY]
+ )
+ if not categories:
+ return self.async_abort(reason="no_categories")
+
+ schema = vol.Schema(
{
- vol.Optional(CONF_PROVINCE): SelectSelector(
+ vol.Optional(CONF_CATEGORIES): SelectSelector(
SelectSelectorConfig(
- options=SUPPORTED_COUNTRIES[
- reconfigure_entry.data[CONF_COUNTRY]
- ],
+ options=categories,
+ multiple=True,
mode=SelectSelectorMode.DROPDOWN,
+ translation_key="categories",
)
)
}
)
- return self.async_show_form(step_id="reconfigure", data_schema=province_schema)
+ return self.async_show_form(
+ data_schema=self.add_suggested_values_to_schema(
+ schema, self.config_entry.options
+ ),
+ description_placeholders={
+ CONF_COUNTRY: self.config_entry.data[CONF_COUNTRY]
+ },
+ )
diff --git a/homeassistant/components/holiday/const.py b/homeassistant/components/holiday/const.py
index ed283f82412..6a28ae1ffec 100644
--- a/homeassistant/components/holiday/const.py
+++ b/homeassistant/components/holiday/const.py
@@ -5,3 +5,4 @@ from typing import Final
DOMAIN: Final = "holiday"
CONF_PROVINCE: Final = "province"
+CONF_CATEGORIES: Final = "categories"
diff --git a/homeassistant/components/holiday/manifest.json b/homeassistant/components/holiday/manifest.json
index 8c64f492d42..09943faf0a2 100644
--- a/homeassistant/components/holiday/manifest.json
+++ b/homeassistant/components/holiday/manifest.json
@@ -5,5 +5,5 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/holiday",
"iot_class": "local_polling",
- "requirements": ["holidays==0.60", "babel==2.15.0"]
+ "requirements": ["holidays==0.64", "babel==2.15.0"]
}
diff --git a/homeassistant/components/holiday/strings.json b/homeassistant/components/holiday/strings.json
index ae4930ecdb4..d464f9e8bfd 100644
--- a/homeassistant/components/holiday/strings.json
+++ b/homeassistant/components/holiday/strings.json
@@ -2,7 +2,7 @@
"title": "Holiday",
"config": {
"abort": {
- "already_configured": "Already configured. Only a single configuration for country/province combination possible.",
+ "already_configured": "Already configured. Only a single configuration for country/province/categories combination is possible.",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
},
"step": {
@@ -11,16 +11,62 @@
"country": "Country"
}
},
- "province": {
+ "options": {
"data": {
- "province": "Province"
+ "province": "Province",
+ "categories": "Categories"
+ },
+ "data_description": {
+ "province": "Optionally choose a province / subdivision of {country}",
+ "categories": "Optionally choose additional holiday categories, public holidays are already included"
}
},
"reconfigure": {
"data": {
- "province": "[%key:component::holiday::config::step::province::data::province%]"
+ "province": "[%key:component::holiday::config::step::options::data::province%]",
+ "categories": "[%key:component::holiday::config::step::options::data::categories%]"
+ },
+ "data_description": {
+ "province": "[%key:component::holiday::config::step::options::data_description::province%]",
+ "categories": "[%key:component::holiday::config::step::options::data_description::categories%]"
}
}
}
+ },
+ "options": {
+ "abort": {
+ "already_configured": "[%key:component::holiday::config::abort::already_configured%]",
+ "no_categories": "The country has no additional categories to configure."
+ },
+ "step": {
+ "init": {
+ "data": {
+ "categories": "[%key:component::holiday::config::step::options::data::categories%]"
+ },
+ "data_description": {
+ "categories": "[%key:component::holiday::config::step::options::data_description::categories%]"
+ }
+ }
+ }
+ },
+ "selector": {
+ "device_class": {
+ "options": {
+ "armed_forces": "Armed forces",
+ "bank": "Bank",
+ "catholic": "Catholic",
+ "chinese": "Chinese",
+ "christian": "Christian",
+ "government": "Government",
+ "half_day": "Half day",
+ "hebrew": "Hebrew",
+ "hindu": "Hindu",
+ "islamic": "Islamic",
+ "optional": "Optional",
+ "school": "School",
+ "unofficial": "Unofficial",
+ "workday": "Workday"
+ }
+ }
}
}
diff --git a/homeassistant/components/home_connect/__init__.py b/homeassistant/components/home_connect/__init__.py
index c60515eb57f..d7c042c2a91 100644
--- a/homeassistant/components/home_connect/__init__.py
+++ b/homeassistant/components/home_connect/__init__.py
@@ -4,7 +4,8 @@ from __future__ import annotations
from datetime import timedelta
import logging
-from typing import Any
+import re
+from typing import Any, cast
from requests import HTTPError
import voluptuous as vol
@@ -12,6 +13,7 @@ import voluptuous as vol
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_DEVICE_ID, Platform
from homeassistant.core import HomeAssistant, callback
+from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import (
config_entry_oauth2_flow,
config_validation as cv,
@@ -38,10 +40,17 @@ from .const import (
SERVICE_SELECT_PROGRAM,
SERVICE_SETTING,
SERVICE_START_PROGRAM,
+ SVE_TRANSLATION_PLACEHOLDER_KEY,
+ SVE_TRANSLATION_PLACEHOLDER_PROGRAM,
+ SVE_TRANSLATION_PLACEHOLDER_VALUE,
)
+type HomeConnectConfigEntry = ConfigEntry[api.ConfigEntryAuth]
+
_LOGGER = logging.getLogger(__name__)
+RE_CAMEL_CASE = re.compile(r"(? api.HomeConnectAppliance:
+ """Return a Home Connect appliance instance given a device id or a device entry."""
+ if device_id is not None and device_entry is None:
+ device_registry = dr.async_get(hass)
+ device_entry = device_registry.async_get(device_id)
+ assert device_entry, "Either a device id or a device entry must be provided"
+
+ ha_id = next(
+ (
+ identifier[1]
+ for identifier in device_entry.identifiers
+ if identifier[0] == DOMAIN
+ ),
+ None,
+ )
+ assert ha_id
+
+ def find_appliance(
+ entry: HomeConnectConfigEntry,
+ ) -> api.HomeConnectAppliance | None:
+ for device in entry.runtime_data.devices:
+ appliance = device.appliance
+ if appliance.haId == ha_id:
+ return appliance
+ return None
+
+ if entry is None:
+ for entry_id in device_entry.config_entries:
+ entry = hass.config_entries.async_get_entry(entry_id)
+ assert entry
+ if entry.domain == DOMAIN:
+ entry = cast(HomeConnectConfigEntry, entry)
+ if (appliance := find_appliance(entry)) is not None:
+ return appliance
+ elif (appliance := find_appliance(entry)) is not None:
+ return appliance
+ raise ValueError(f"Appliance for device id {device_entry.id} not found")
+
+
+def _get_appliance_or_raise_service_validation_error(
hass: HomeAssistant, device_id: str
-) -> api.HomeConnectDevice:
- """Return a Home Connect appliance instance given an device_id."""
- for hc_api in hass.data[DOMAIN].values():
- for device in hc_api.devices:
- if device.device_id == device_id:
- return device.appliance
- raise ValueError(f"Appliance for device id {device_id} not found")
+) -> api.HomeConnectAppliance:
+ """Return a Home Connect appliance instance or raise a service validation error."""
+ try:
+ return _get_appliance(hass, device_id)
+ except (ValueError, AssertionError) as err:
+ raise ServiceValidationError(
+ translation_domain=DOMAIN,
+ translation_key="appliance_not_found",
+ translation_placeholders={
+ "device_id": device_id,
+ },
+ ) from err
+
+
+async def _run_appliance_service[*_Ts](
+ hass: HomeAssistant,
+ appliance: api.HomeConnectAppliance,
+ method: str,
+ *args: *_Ts,
+ error_translation_key: str,
+ error_translation_placeholders: dict[str, str],
+) -> None:
+ try:
+ await hass.async_add_executor_job(getattr(appliance, method), *args)
+ except api.HomeConnectError as err:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key=error_translation_key,
+ translation_placeholders={
+ **get_dict_from_home_connect_error(err),
+ **error_translation_placeholders,
+ },
+ ) from err
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up Home Connect component."""
- hass.data[DOMAIN] = {}
async def _async_service_program(call, method):
"""Execute calls to services taking a program."""
@@ -120,16 +199,31 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
option[ATTR_UNIT] = option_unit
options.append(option)
-
- appliance = _get_appliance_by_device_id(hass, device_id)
- await hass.async_add_executor_job(getattr(appliance, method), program, options)
+ await _run_appliance_service(
+ hass,
+ _get_appliance_or_raise_service_validation_error(hass, device_id),
+ method,
+ program,
+ options,
+ error_translation_key=method,
+ error_translation_placeholders={
+ SVE_TRANSLATION_PLACEHOLDER_PROGRAM: program,
+ },
+ )
async def _async_service_command(call, command):
"""Execute calls to services executing a command."""
device_id = call.data[ATTR_DEVICE_ID]
- appliance = _get_appliance_by_device_id(hass, device_id)
- await hass.async_add_executor_job(appliance.execute_command, command)
+ appliance = _get_appliance_or_raise_service_validation_error(hass, device_id)
+ await _run_appliance_service(
+ hass,
+ appliance,
+ "execute_command",
+ command,
+ error_translation_key="execute_command",
+ error_translation_placeholders={"command": command},
+ )
async def _async_service_key_value(call, method):
"""Execute calls to services taking a key and value."""
@@ -138,20 +232,17 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
unit = call.data.get(ATTR_UNIT)
device_id = call.data[ATTR_DEVICE_ID]
- appliance = _get_appliance_by_device_id(hass, device_id)
- if unit is not None:
- await hass.async_add_executor_job(
- getattr(appliance, method),
- key,
- value,
- unit,
- )
- else:
- await hass.async_add_executor_job(
- getattr(appliance, method),
- key,
- value,
- )
+ await _run_appliance_service(
+ hass,
+ _get_appliance_or_raise_service_validation_error(hass, device_id),
+ method,
+ *((key, value) if unit is None else (key, value, unit)),
+ error_translation_key=method,
+ error_translation_placeholders={
+ SVE_TRANSLATION_PLACEHOLDER_KEY: key,
+ SVE_TRANSLATION_PLACEHOLDER_VALUE: str(value),
+ },
+ )
async def async_service_option_active(call):
"""Service for setting an option for an active program."""
@@ -224,7 +315,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: HomeConnectConfigEntry) -> bool:
"""Set up Home Connect from a config entry."""
implementation = (
await config_entry_oauth2_flow.async_get_config_entry_implementation(
@@ -232,9 +323,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
)
)
- hc_api = api.ConfigEntryAuth(hass, entry, implementation)
-
- hass.data[DOMAIN][entry.entry_id] = hc_api
+ entry.runtime_data = api.ConfigEntryAuth(hass, entry, implementation)
await update_all_devices(hass, entry)
@@ -243,45 +332,35 @@ 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: HomeConnectConfigEntry
+) -> 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)
@Throttle(SCAN_INTERVAL)
-async def update_all_devices(hass: HomeAssistant, entry: ConfigEntry) -> None:
+async def update_all_devices(
+ hass: HomeAssistant, entry: HomeConnectConfigEntry
+) -> None:
"""Update all the devices."""
- data = hass.data[DOMAIN]
- hc_api = data[entry.entry_id]
+ hc_api = entry.runtime_data
- device_registry = dr.async_get(hass)
try:
await hass.async_add_executor_job(hc_api.get_devices)
for device in hc_api.devices:
- device_entry = device_registry.async_get_or_create(
- config_entry_id=entry.entry_id,
- identifiers={(DOMAIN, device.appliance.haId)},
- name=device.appliance.name,
- manufacturer=device.appliance.brand,
- model=device.appliance.vib,
- )
-
- device.device_id = device_entry.id
-
await hass.async_add_executor_job(device.initialize)
except HTTPError as err:
_LOGGER.warning("Cannot update devices: %s", err.response.status_code)
-async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
+async def async_migrate_entry(
+ hass: HomeAssistant, entry: HomeConnectConfigEntry
+) -> bool:
"""Migrate old entry."""
- _LOGGER.debug("Migrating from version %s", config_entry.version)
+ _LOGGER.debug("Migrating from version %s", entry.version)
- if config_entry.version == 1 and config_entry.minor_version == 1:
+ if entry.version == 1 and entry.minor_version == 1:
@callback
def update_unique_id(
@@ -297,20 +376,31 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
}
return None
- await async_migrate_entries(hass, config_entry.entry_id, update_unique_id)
+ await async_migrate_entries(hass, entry.entry_id, update_unique_id)
- hass.config_entries.async_update_entry(config_entry, minor_version=2)
+ hass.config_entries.async_update_entry(entry, minor_version=2)
- _LOGGER.debug("Migration to version %s successful", config_entry.version)
+ _LOGGER.debug("Migration to version %s successful", entry.version)
return True
def get_dict_from_home_connect_error(err: api.HomeConnectError) -> dict[str, Any]:
"""Return a dict from a Home Connect error."""
- return (
- err.args[0]
+ return {
+ "description": cast(dict[str, Any], err.args[0]).get("description", "?")
if len(err.args) > 0 and isinstance(err.args[0], dict)
- else {"description": err.args[0]}
+ else err.args[0]
if len(err.args) > 0 and isinstance(err.args[0], str)
- else {}
- )
+ else "?",
+ }
+
+
+def bsh_key_to_translation_key(bsh_key: str) -> str:
+ """Convert a BSH key to a translation key format.
+
+ This function takes a BSH key, such as `Dishcare.Dishwasher.Program.Eco50`,
+ and converts it to a translation key format, such as `dishcare_dishwasher_bsh_key_eco50`.
+ """
+ return "_".join(
+ RE_CAMEL_CASE.sub("_", split) for split in bsh_key.split(".")
+ ).lower()
diff --git a/homeassistant/components/home_connect/binary_sensor.py b/homeassistant/components/home_connect/binary_sensor.py
index f044a3fdfb4..f9775918f16 100644
--- a/homeassistant/components/home_connect/binary_sensor.py
+++ b/homeassistant/components/home_connect/binary_sensor.py
@@ -10,8 +10,8 @@ from homeassistant.components.binary_sensor import (
BinarySensorEntityDescription,
)
from homeassistant.components.script import scripts_with_entity
-from homeassistant.config_entries import ConfigEntry
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,
@@ -19,6 +19,7 @@ from homeassistant.helpers.issue_registry import (
async_delete_issue,
)
+from . import HomeConnectConfigEntry
from .api import HomeConnectDevice
from .const import (
ATTR_VALUE,
@@ -117,15 +118,14 @@ BINARY_SENSORS = (
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ entry: HomeConnectConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Home Connect binary sensor."""
def get_entities() -> list[BinarySensorEntity]:
entities: list[BinarySensorEntity] = []
- hc_api = hass.data[DOMAIN][config_entry.entry_id]
- for device in hc_api.devices:
+ for device in entry.runtime_data.devices:
entities.extend(
HomeConnectBinarySensor(device, description)
for description in BINARY_SENSORS
@@ -192,11 +192,32 @@ class HomeConnectDoorBinarySensor(HomeConnectBinarySensor):
async def async_added_to_hass(self) -> None:
"""Call when entity is added to hass."""
await super().async_added_to_hass()
- entity_automations = automations_with_entity(self.hass, self.entity_id)
- entity_scripts = scripts_with_entity(self.hass, self.entity_id)
- items = entity_automations + entity_scripts
+ automations = automations_with_entity(self.hass, self.entity_id)
+ scripts = scripts_with_entity(self.hass, self.entity_id)
+ items = automations + scripts
if not items:
return
+
+ entity_reg: er.EntityRegistry = er.async_get(self.hass)
+ entity_automations = [
+ automation_entity
+ for automation_id in automations
+ if (automation_entity := entity_reg.async_get(automation_id))
+ ]
+ entity_scripts = [
+ script_entity
+ for script_id in scripts
+ if (script_entity := entity_reg.async_get(script_id))
+ ]
+
+ items_list = [
+ f"- [{item.original_name}](/config/automation/edit/{item.unique_id})"
+ for item in entity_automations
+ ] + [
+ f"- [{item.original_name}](/config/script/edit/{item.unique_id})"
+ for item in entity_scripts
+ ]
+
async_create_issue(
self.hass,
DOMAIN,
@@ -207,7 +228,7 @@ class HomeConnectDoorBinarySensor(HomeConnectBinarySensor):
translation_key="deprecated_binary_common_door_sensor",
translation_placeholders={
"entity": self.entity_id,
- "items": "\n".join([f"- {item}" for item in items]),
+ "items": "\n".join(items_list),
},
)
diff --git a/homeassistant/components/home_connect/const.py b/homeassistant/components/home_connect/const.py
index e49a56b9b97..e20cf3b1fa0 100644
--- a/homeassistant/components/home_connect/const.py
+++ b/homeassistant/components/home_connect/const.py
@@ -5,10 +5,23 @@ DOMAIN = "home_connect"
OAUTH2_AUTHORIZE = "https://api.home-connect.com/security/oauth/authorize"
OAUTH2_TOKEN = "https://api.home-connect.com/security/oauth/token"
+APPLIANCES_WITH_PROGRAMS = (
+ "CleaningRobot",
+ "CoffeeMaker",
+ "Dishwasher",
+ "Dryer",
+ "Hood",
+ "Oven",
+ "WarmingDrawer",
+ "Washer",
+ "WasherDryer",
+)
+
BSH_POWER_STATE = "BSH.Common.Setting.PowerState"
BSH_POWER_ON = "BSH.Common.EnumType.PowerState.On"
BSH_POWER_OFF = "BSH.Common.EnumType.PowerState.Off"
BSH_POWER_STANDBY = "BSH.Common.EnumType.PowerState.Standby"
+BSH_SELECTED_PROGRAM = "BSH.Common.Root.SelectedProgram"
BSH_ACTIVE_PROGRAM = "BSH.Common.Root.ActiveProgram"
BSH_REMOTE_CONTROL_ACTIVATION_STATE = "BSH.Common.Status.RemoteControlActive"
BSH_REMOTE_START_ALLOWANCE_STATE = "BSH.Common.Status.RemoteControlStartAllowed"
@@ -114,9 +127,12 @@ ATTR_STEPSIZE = "stepsize"
ATTR_UNIT = "unit"
ATTR_VALUE = "value"
+SVE_TRANSLATION_KEY_SET_SETTING = "set_setting_entity"
+
SVE_TRANSLATION_PLACEHOLDER_APPLIANCE_NAME = "appliance_name"
SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID = "entity_id"
-SVE_TRANSLATION_PLACEHOLDER_SETTING_KEY = "setting_key"
+SVE_TRANSLATION_PLACEHOLDER_PROGRAM = "program"
+SVE_TRANSLATION_PLACEHOLDER_KEY = "key"
SVE_TRANSLATION_PLACEHOLDER_VALUE = "value"
OLD_NEW_UNIQUE_ID_SUFFIX_MAP = {
diff --git a/homeassistant/components/home_connect/diagnostics.py b/homeassistant/components/home_connect/diagnostics.py
new file mode 100644
index 00000000000..e095bc503ab
--- /dev/null
+++ b/homeassistant/components/home_connect/diagnostics.py
@@ -0,0 +1,51 @@
+"""Diagnostics support for Home Connect Diagnostics."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from homeconnect.api import HomeConnectAppliance, HomeConnectError
+
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.device_registry import DeviceEntry
+
+from . import HomeConnectConfigEntry, _get_appliance
+from .api import HomeConnectDevice
+
+
+def _generate_appliance_diagnostics(appliance: HomeConnectAppliance) -> dict[str, Any]:
+ try:
+ programs = appliance.get_programs_available()
+ except HomeConnectError:
+ programs = None
+ return {
+ "connected": appliance.connected,
+ "status": appliance.status,
+ "programs": programs,
+ }
+
+
+def _generate_entry_diagnostics(
+ devices: list[HomeConnectDevice],
+) -> dict[str, dict[str, Any]]:
+ return {
+ device.appliance.haId: _generate_appliance_diagnostics(device.appliance)
+ for device in devices
+ }
+
+
+async def async_get_config_entry_diagnostics(
+ hass: HomeAssistant, entry: HomeConnectConfigEntry
+) -> dict[str, Any]:
+ """Return diagnostics for a config entry."""
+ return await hass.async_add_executor_job(
+ _generate_entry_diagnostics, entry.runtime_data.devices
+ )
+
+
+async def async_get_device_diagnostics(
+ hass: HomeAssistant, entry: HomeConnectConfigEntry, device: DeviceEntry
+) -> dict[str, Any]:
+ """Return diagnostics for a device."""
+ appliance = _get_appliance(hass, device_entry=device, entry=entry)
+ return await hass.async_add_executor_job(_generate_appliance_diagnostics, appliance)
diff --git a/homeassistant/components/home_connect/light.py b/homeassistant/components/home_connect/light.py
index 873e7d24f93..e33017cd51f 100644
--- a/homeassistant/components/home_connect/light.py
+++ b/homeassistant/components/home_connect/light.py
@@ -15,14 +15,13 @@ from homeassistant.components.light import (
LightEntity,
LightEntityDescription,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.exceptions import ServiceValidationError
+from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddEntitiesCallback
import homeassistant.util.color as color_util
-from . import get_dict_from_home_connect_error
-from .api import ConfigEntryAuth, HomeConnectDevice
+from . import HomeConnectConfigEntry, get_dict_from_home_connect_error
+from .api import HomeConnectDevice
from .const import (
ATTR_VALUE,
BSH_AMBIENT_LIGHT_BRIGHTNESS,
@@ -88,18 +87,17 @@ LIGHTS: tuple[HomeConnectLightEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ entry: HomeConnectConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Home Connect light."""
def get_entities() -> list[LightEntity]:
"""Get a list of entities."""
- hc_api: ConfigEntryAuth = hass.data[DOMAIN][config_entry.entry_id]
return [
HomeConnectLight(device, description)
for description in LIGHTS
- for device in hc_api.devices
+ for device in entry.runtime_data.devices
if description.key in device.appliance.status
]
@@ -152,7 +150,7 @@ class HomeConnectLight(HomeConnectEntity, LightEntity):
self.device.appliance.set_setting, self.bsh_key, True
)
except HomeConnectError as err:
- raise ServiceValidationError(
+ raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="turn_on_light",
translation_placeholders={
@@ -171,7 +169,7 @@ class HomeConnectLight(HomeConnectEntity, LightEntity):
self._enable_custom_color_value_key,
)
except HomeConnectError as err:
- raise ServiceValidationError(
+ raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="select_light_custom_color",
translation_placeholders={
@@ -189,7 +187,7 @@ class HomeConnectLight(HomeConnectEntity, LightEntity):
f"#{hex_val}",
)
except HomeConnectError as err:
- raise ServiceValidationError(
+ raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="set_light_color",
translation_placeholders={
@@ -221,7 +219,7 @@ class HomeConnectLight(HomeConnectEntity, LightEntity):
f"#{hex_val}",
)
except HomeConnectError as err:
- raise ServiceValidationError(
+ raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="set_light_color",
translation_placeholders={
@@ -246,7 +244,7 @@ class HomeConnectLight(HomeConnectEntity, LightEntity):
self.device.appliance.set_setting, self._brightness_key, brightness
)
except HomeConnectError as err:
- raise ServiceValidationError(
+ raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="set_light_brightness",
translation_placeholders={
@@ -265,7 +263,7 @@ class HomeConnectLight(HomeConnectEntity, LightEntity):
self.device.appliance.set_setting, self.bsh_key, False
)
except HomeConnectError as err:
- raise ServiceValidationError(
+ raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="turn_off_light",
translation_placeholders={
diff --git a/homeassistant/components/home_connect/number.py b/homeassistant/components/home_connect/number.py
index ad853df77d0..0703b4772bb 100644
--- a/homeassistant/components/home_connect/number.py
+++ b/homeassistant/components/home_connect/number.py
@@ -11,21 +11,20 @@ from homeassistant.components.number import (
NumberEntity,
NumberEntityDescription,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.exceptions import ServiceValidationError
+from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from . import get_dict_from_home_connect_error
-from .api import ConfigEntryAuth
+from . import HomeConnectConfigEntry, get_dict_from_home_connect_error
from .const import (
ATTR_CONSTRAINTS,
ATTR_STEPSIZE,
ATTR_UNIT,
ATTR_VALUE,
DOMAIN,
+ SVE_TRANSLATION_KEY_SET_SETTING,
SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID,
- SVE_TRANSLATION_PLACEHOLDER_SETTING_KEY,
+ SVE_TRANSLATION_PLACEHOLDER_KEY,
SVE_TRANSLATION_PLACEHOLDER_VALUE,
)
from .entity import HomeConnectEntity
@@ -84,18 +83,17 @@ NUMBERS = (
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ entry: HomeConnectConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Home Connect number."""
def get_entities() -> list[HomeConnectNumberEntity]:
"""Get a list of entities."""
- hc_api: ConfigEntryAuth = hass.data[DOMAIN][config_entry.entry_id]
return [
HomeConnectNumberEntity(device, description)
for description in NUMBERS
- for device in hc_api.devices
+ for device in entry.runtime_data.devices
if description.key in device.appliance.status
]
@@ -120,13 +118,13 @@ class HomeConnectNumberEntity(HomeConnectEntity, NumberEntity):
value,
)
except HomeConnectError as err:
- raise ServiceValidationError(
+ raise HomeAssistantError(
translation_domain=DOMAIN,
- translation_key="set_setting",
+ translation_key=SVE_TRANSLATION_KEY_SET_SETTING,
translation_placeholders={
**get_dict_from_home_connect_error(err),
SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID: self.entity_id,
- SVE_TRANSLATION_PLACEHOLDER_SETTING_KEY: self.bsh_key,
+ SVE_TRANSLATION_PLACEHOLDER_KEY: self.bsh_key,
SVE_TRANSLATION_PLACEHOLDER_VALUE: str(value),
},
) from err
diff --git a/homeassistant/components/home_connect/select.py b/homeassistant/components/home_connect/select.py
new file mode 100644
index 00000000000..a4a5861afbe
--- /dev/null
+++ b/homeassistant/components/home_connect/select.py
@@ -0,0 +1,301 @@
+"""Provides a select platform for Home Connect."""
+
+import contextlib
+import logging
+
+from homeconnect.api import HomeConnectError
+
+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 (
+ HomeConnectConfigEntry,
+ bsh_key_to_translation_key,
+ get_dict_from_home_connect_error,
+)
+from .api import HomeConnectDevice
+from .const import (
+ APPLIANCES_WITH_PROGRAMS,
+ ATTR_VALUE,
+ BSH_ACTIVE_PROGRAM,
+ BSH_SELECTED_PROGRAM,
+ DOMAIN,
+ SVE_TRANSLATION_PLACEHOLDER_PROGRAM,
+)
+from .entity import HomeConnectEntity
+
+_LOGGER = logging.getLogger(__name__)
+
+TRANSLATION_KEYS_PROGRAMS_MAP = {
+ bsh_key_to_translation_key(program): program
+ for program in (
+ "ConsumerProducts.CleaningRobot.Program.Cleaning.CleanAll",
+ "ConsumerProducts.CleaningRobot.Program.Cleaning.CleanMap",
+ "ConsumerProducts.CleaningRobot.Program.Basic.GoHome",
+ "ConsumerProducts.CoffeeMaker.Program.Beverage.Ristretto",
+ "ConsumerProducts.CoffeeMaker.Program.Beverage.Espresso",
+ "ConsumerProducts.CoffeeMaker.Program.Beverage.EspressoDoppio",
+ "ConsumerProducts.CoffeeMaker.Program.Beverage.Coffee",
+ "ConsumerProducts.CoffeeMaker.Program.Beverage.XLCoffee",
+ "ConsumerProducts.CoffeeMaker.Program.Beverage.CaffeGrande",
+ "ConsumerProducts.CoffeeMaker.Program.Beverage.EspressoMacchiato",
+ "ConsumerProducts.CoffeeMaker.Program.Beverage.Cappuccino",
+ "ConsumerProducts.CoffeeMaker.Program.Beverage.LatteMacchiato",
+ "ConsumerProducts.CoffeeMaker.Program.Beverage.CaffeLatte",
+ "ConsumerProducts.CoffeeMaker.Program.Beverage.MilkFroth",
+ "ConsumerProducts.CoffeeMaker.Program.Beverage.WarmMilk",
+ "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.KleinerBrauner",
+ "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.GrosserBrauner",
+ "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.Verlaengerter",
+ "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.VerlaengerterBraun",
+ "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.WienerMelange",
+ "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.FlatWhite",
+ "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.Cortado",
+ "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.CafeCortado",
+ "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.CafeConLeche",
+ "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.CafeAuLait",
+ "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.Doppio",
+ "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.Kaapi",
+ "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.KoffieVerkeerd",
+ "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.Galao",
+ "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.Garoto",
+ "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.Americano",
+ "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.RedEye",
+ "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.BlackEye",
+ "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.DeadEye",
+ "ConsumerProducts.CoffeeMaker.Program.Beverage.HotWater",
+ "Dishcare.Dishwasher.Program.PreRinse",
+ "Dishcare.Dishwasher.Program.Auto1",
+ "Dishcare.Dishwasher.Program.Auto2",
+ "Dishcare.Dishwasher.Program.Auto3",
+ "Dishcare.Dishwasher.Program.Eco50",
+ "Dishcare.Dishwasher.Program.Quick45",
+ "Dishcare.Dishwasher.Program.Intensiv70",
+ "Dishcare.Dishwasher.Program.Normal65",
+ "Dishcare.Dishwasher.Program.Glas40",
+ "Dishcare.Dishwasher.Program.GlassCare",
+ "Dishcare.Dishwasher.Program.NightWash",
+ "Dishcare.Dishwasher.Program.Quick65",
+ "Dishcare.Dishwasher.Program.Normal45",
+ "Dishcare.Dishwasher.Program.Intensiv45",
+ "Dishcare.Dishwasher.Program.AutoHalfLoad",
+ "Dishcare.Dishwasher.Program.IntensivPower",
+ "Dishcare.Dishwasher.Program.MagicDaily",
+ "Dishcare.Dishwasher.Program.Super60",
+ "Dishcare.Dishwasher.Program.Kurz60",
+ "Dishcare.Dishwasher.Program.ExpressSparkle65",
+ "Dishcare.Dishwasher.Program.MachineCare",
+ "Dishcare.Dishwasher.Program.SteamFresh",
+ "Dishcare.Dishwasher.Program.MaximumCleaning",
+ "Dishcare.Dishwasher.Program.MixedLoad",
+ "LaundryCare.Dryer.Program.Cotton",
+ "LaundryCare.Dryer.Program.Synthetic",
+ "LaundryCare.Dryer.Program.Mix",
+ "LaundryCare.Dryer.Program.Blankets",
+ "LaundryCare.Dryer.Program.BusinessShirts",
+ "LaundryCare.Dryer.Program.DownFeathers",
+ "LaundryCare.Dryer.Program.Hygiene",
+ "LaundryCare.Dryer.Program.Jeans",
+ "LaundryCare.Dryer.Program.Outdoor",
+ "LaundryCare.Dryer.Program.SyntheticRefresh",
+ "LaundryCare.Dryer.Program.Towels",
+ "LaundryCare.Dryer.Program.Delicates",
+ "LaundryCare.Dryer.Program.Super40",
+ "LaundryCare.Dryer.Program.Shirts15",
+ "LaundryCare.Dryer.Program.Pillow",
+ "LaundryCare.Dryer.Program.AntiShrink",
+ "LaundryCare.Dryer.Program.MyTime.MyDryingTime",
+ "LaundryCare.Dryer.Program.TimeCold",
+ "LaundryCare.Dryer.Program.TimeWarm",
+ "LaundryCare.Dryer.Program.InBasket",
+ "LaundryCare.Dryer.Program.TimeColdFix.TimeCold20",
+ "LaundryCare.Dryer.Program.TimeColdFix.TimeCold30",
+ "LaundryCare.Dryer.Program.TimeColdFix.TimeCold60",
+ "LaundryCare.Dryer.Program.TimeWarmFix.TimeWarm30",
+ "LaundryCare.Dryer.Program.TimeWarmFix.TimeWarm40",
+ "LaundryCare.Dryer.Program.TimeWarmFix.TimeWarm60",
+ "LaundryCare.Dryer.Program.Dessous",
+ "Cooking.Common.Program.Hood.Automatic",
+ "Cooking.Common.Program.Hood.Venting",
+ "Cooking.Common.Program.Hood.DelayedShutOff",
+ "Cooking.Oven.Program.HeatingMode.PreHeating",
+ "Cooking.Oven.Program.HeatingMode.HotAir",
+ "Cooking.Oven.Program.HeatingMode.HotAirEco",
+ "Cooking.Oven.Program.HeatingMode.HotAirGrilling",
+ "Cooking.Oven.Program.HeatingMode.TopBottomHeating",
+ "Cooking.Oven.Program.HeatingMode.TopBottomHeatingEco",
+ "Cooking.Oven.Program.HeatingMode.BottomHeating",
+ "Cooking.Oven.Program.HeatingMode.PizzaSetting",
+ "Cooking.Oven.Program.HeatingMode.SlowCook",
+ "Cooking.Oven.Program.HeatingMode.IntensiveHeat",
+ "Cooking.Oven.Program.HeatingMode.KeepWarm",
+ "Cooking.Oven.Program.HeatingMode.PreheatOvenware",
+ "Cooking.Oven.Program.HeatingMode.FrozenHeatupSpecial",
+ "Cooking.Oven.Program.HeatingMode.Desiccation",
+ "Cooking.Oven.Program.HeatingMode.Defrost",
+ "Cooking.Oven.Program.HeatingMode.Proof",
+ "Cooking.Oven.Program.HeatingMode.HotAir30Steam",
+ "Cooking.Oven.Program.HeatingMode.HotAir60Steam",
+ "Cooking.Oven.Program.HeatingMode.HotAir80Steam",
+ "Cooking.Oven.Program.HeatingMode.HotAir100Steam",
+ "Cooking.Oven.Program.HeatingMode.SabbathProgramme",
+ "Cooking.Oven.Program.Microwave.90Watt",
+ "Cooking.Oven.Program.Microwave.180Watt",
+ "Cooking.Oven.Program.Microwave.360Watt",
+ "Cooking.Oven.Program.Microwave.600Watt",
+ "Cooking.Oven.Program.Microwave.900Watt",
+ "Cooking.Oven.Program.Microwave.1000Watt",
+ "Cooking.Oven.Program.Microwave.Max",
+ "Cooking.Oven.Program.HeatingMode.WarmingDrawer",
+ "LaundryCare.Washer.Program.Cotton",
+ "LaundryCare.Washer.Program.Cotton.CottonEco",
+ "LaundryCare.Washer.Program.Cotton.Eco4060",
+ "LaundryCare.Washer.Program.Cotton.Colour",
+ "LaundryCare.Washer.Program.EasyCare",
+ "LaundryCare.Washer.Program.Mix",
+ "LaundryCare.Washer.Program.Mix.NightWash",
+ "LaundryCare.Washer.Program.DelicatesSilk",
+ "LaundryCare.Washer.Program.Wool",
+ "LaundryCare.Washer.Program.Sensitive",
+ "LaundryCare.Washer.Program.Auto30",
+ "LaundryCare.Washer.Program.Auto40",
+ "LaundryCare.Washer.Program.Auto60",
+ "LaundryCare.Washer.Program.Chiffon",
+ "LaundryCare.Washer.Program.Curtains",
+ "LaundryCare.Washer.Program.DarkWash",
+ "LaundryCare.Washer.Program.Dessous",
+ "LaundryCare.Washer.Program.Monsoon",
+ "LaundryCare.Washer.Program.Outdoor",
+ "LaundryCare.Washer.Program.PlushToy",
+ "LaundryCare.Washer.Program.ShirtsBlouses",
+ "LaundryCare.Washer.Program.SportFitness",
+ "LaundryCare.Washer.Program.Towels",
+ "LaundryCare.Washer.Program.WaterProof",
+ "LaundryCare.Washer.Program.PowerSpeed59",
+ "LaundryCare.Washer.Program.Super153045.Super15",
+ "LaundryCare.Washer.Program.Super153045.Super1530",
+ "LaundryCare.Washer.Program.DownDuvet.Duvet",
+ "LaundryCare.Washer.Program.Rinse.RinseSpinDrain",
+ "LaundryCare.Washer.Program.DrumClean",
+ "LaundryCare.WasherDryer.Program.Cotton",
+ "LaundryCare.WasherDryer.Program.Cotton.Eco4060",
+ "LaundryCare.WasherDryer.Program.Mix",
+ "LaundryCare.WasherDryer.Program.EasyCare",
+ "LaundryCare.WasherDryer.Program.WashAndDry60",
+ "LaundryCare.WasherDryer.Program.WashAndDry90",
+ )
+}
+
+PROGRAMS_TRANSLATION_KEYS_MAP = {
+ value: key for key, value in TRANSLATION_KEYS_PROGRAMS_MAP.items()
+}
+
+PROGRAM_SELECT_ENTITY_DESCRIPTIONS = (
+ SelectEntityDescription(
+ key=BSH_ACTIVE_PROGRAM,
+ translation_key="active_program",
+ ),
+ SelectEntityDescription(
+ key=BSH_SELECTED_PROGRAM,
+ translation_key="selected_program",
+ ),
+)
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: HomeConnectConfigEntry,
+ async_add_entities: AddEntitiesCallback,
+) -> None:
+ """Set up the Home Connect select entities."""
+
+ def get_entities() -> list[HomeConnectProgramSelectEntity]:
+ """Get a list of entities."""
+ entities: list[HomeConnectProgramSelectEntity] = []
+ programs_not_found = set()
+ for device in entry.runtime_data.devices:
+ if device.appliance.type in APPLIANCES_WITH_PROGRAMS:
+ with contextlib.suppress(HomeConnectError):
+ programs = device.appliance.get_programs_available()
+ if programs:
+ for program in programs.copy():
+ if program not in PROGRAMS_TRANSLATION_KEYS_MAP:
+ programs.remove(program)
+ if program not in programs_not_found:
+ _LOGGER.info(
+ 'The program "%s" is not part of the official Home Connect API specification',
+ program,
+ )
+ programs_not_found.add(program)
+ entities.extend(
+ HomeConnectProgramSelectEntity(device, programs, desc)
+ for desc in PROGRAM_SELECT_ENTITY_DESCRIPTIONS
+ )
+ return entities
+
+ async_add_entities(await hass.async_add_executor_job(get_entities), True)
+
+
+class HomeConnectProgramSelectEntity(HomeConnectEntity, SelectEntity):
+ """Select class for Home Connect programs."""
+
+ def __init__(
+ self,
+ device: HomeConnectDevice,
+ programs: list[str],
+ desc: SelectEntityDescription,
+ ) -> None:
+ """Initialize the entity."""
+ super().__init__(
+ device,
+ desc,
+ )
+ self._attr_options = [
+ PROGRAMS_TRANSLATION_KEYS_MAP[program] for program in programs
+ ]
+ self.start_on_select = desc.key == BSH_ACTIVE_PROGRAM
+
+ async def async_update(self) -> None:
+ """Update the program selection status."""
+ program = self.device.appliance.status.get(self.bsh_key, {}).get(ATTR_VALUE)
+ if not program:
+ program_translation_key = None
+ elif not (
+ program_translation_key := PROGRAMS_TRANSLATION_KEYS_MAP.get(program)
+ ):
+ _LOGGER.debug(
+ 'The program "%s" is not part of the official Home Connect API specification',
+ program,
+ )
+ self._attr_current_option = program_translation_key
+ _LOGGER.debug("Updated, new program: %s", self._attr_current_option)
+
+ async def async_select_option(self, option: str) -> None:
+ """Select new program."""
+ bsh_key = TRANSLATION_KEYS_PROGRAMS_MAP[option]
+ _LOGGER.debug(
+ "Starting program: %s" if self.start_on_select else "Selecting program: %s",
+ bsh_key,
+ )
+ if self.start_on_select:
+ target = self.device.appliance.start_program
+ else:
+ target = self.device.appliance.select_program
+ try:
+ await self.hass.async_add_executor_job(target, bsh_key)
+ except HomeConnectError as err:
+ if self.start_on_select:
+ translation_key = "start_program"
+ else:
+ translation_key = "select_program"
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key=translation_key,
+ translation_placeholders={
+ **get_dict_from_home_connect_error(err),
+ SVE_TRANSLATION_PLACEHOLDER_PROGRAM: bsh_key,
+ },
+ ) from err
+ self.async_entity_update()
diff --git a/homeassistant/components/home_connect/sensor.py b/homeassistant/components/home_connect/sensor.py
index 70096313d86..3ccf55bac6e 100644
--- a/homeassistant/components/home_connect/sensor.py
+++ b/homeassistant/components/home_connect/sensor.py
@@ -14,14 +14,13 @@ from homeassistant.components.sensor import (
SensorEntityDescription,
SensorStateClass,
)
-from homeassistant.config_entries import ConfigEntry
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 .api import ConfigEntryAuth
+from . import HomeConnectConfigEntry
from .const import (
ATTR_VALUE,
BSH_DOOR_STATE,
@@ -34,7 +33,6 @@ from .const import (
COFFEE_EVENT_WATER_TANK_EMPTY,
DISHWASHER_EVENT_RINSE_AID_NEARLY_EMPTY,
DISHWASHER_EVENT_SALT_NEARLY_EMPTY,
- DOMAIN,
REFRIGERATION_EVENT_DOOR_ALARM_FREEZER,
REFRIGERATION_EVENT_DOOR_ALARM_REFRIGERATOR,
REFRIGERATION_EVENT_TEMP_ALARM_FREEZER,
@@ -253,7 +251,7 @@ EVENT_SENSORS = (
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ entry: HomeConnectConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Home Connect sensor."""
@@ -261,8 +259,7 @@ async def async_setup_entry(
def get_entities() -> list[SensorEntity]:
"""Get a list of entities."""
entities: list[SensorEntity] = []
- hc_api: ConfigEntryAuth = hass.data[DOMAIN][config_entry.entry_id]
- for device in hc_api.devices:
+ for device in entry.runtime_data.devices:
entities.extend(
HomeConnectSensor(
device,
diff --git a/homeassistant/components/home_connect/strings.json b/homeassistant/components/home_connect/strings.json
index eb57d822b15..7ededaae5b7 100644
--- a/homeassistant/components/home_connect/strings.json
+++ b/homeassistant/components/home_connect/strings.json
@@ -22,41 +22,62 @@
}
},
"exceptions": {
+ "appliance_not_found": {
+ "message": "Appliance for device ID {device_id} not found"
+ },
"turn_on_light": {
- "message": "Error while trying to turn on {entity_id}: {description}"
+ "message": "Error turning on {entity_id}: {description}"
},
"turn_off_light": {
- "message": "Error while trying to turn off {entity_id}: {description}"
+ "message": "Error turning off {entity_id}: {description}"
},
"set_light_brightness": {
- "message": "Error while trying to set brightness of {entity_id}: {description}"
+ "message": "Error setting brightness of {entity_id}: {description}"
},
"select_light_custom_color": {
- "message": "Error while trying to select custom color of {entity_id}: {description}"
+ "message": "Error selecting custom color of {entity_id}: {description}"
},
"set_light_color": {
- "message": "Error while trying to set color of {entity_id}: {description}"
+ "message": "Error setting color of {entity_id}: {description}"
+ },
+ "set_setting_entity": {
+ "message": "Error assigning the value \"{value}\" to the setting \"{key}\" for {entity_id}: {description}"
},
"set_setting": {
- "message": "Error while trying to assign the value \"{value}\" to the setting \"{key}\" for {entity_id}: {description}"
+ "message": "Error assigning the value \"{value}\" to the setting \"{key}\": {description}"
},
"turn_on": {
- "message": "Error while trying to turn on {entity_id} ({key}): {description}"
+ "message": "Error turning on {entity_id} ({key}): {description}"
},
"turn_off": {
- "message": "Error while trying to turn off {entity_id} ({key}): {description}"
+ "message": "Error turning off {entity_id} ({key}): {description}"
+ },
+ "select_program": {
+ "message": "Error selecting program {program}: {description}"
},
"start_program": {
- "message": "Error while trying to start program {program}: {description}"
+ "message": "Error starting program {program}: {description}"
+ },
+ "pause_program": {
+ "message": "Error pausing program: {description}"
},
"stop_program": {
- "message": "Error while trying to stop program {program}: {description}"
+ "message": "Error stopping program: {description}"
+ },
+ "set_options_active_program": {
+ "message": "Error setting options for the active program: {description}"
+ },
+ "set_options_selected_program": {
+ "message": "Error setting options for the selected program: {description}"
+ },
+ "execute_command": {
+ "message": "Error executing command {command}: {description}"
},
"power_on": {
- "message": "Error while trying to turn on {appliance_name}: {description}"
+ "message": "Error turning on {appliance_name}: {description}"
},
"power_off": {
- "message": "Error while trying to turn off {appliance_name} with value \"{value}\": {description}"
+ "message": "Error turning off {appliance_name} with value \"{value}\": {description}"
},
"turn_off_not_supported": {
"message": "{appliance_name} does not support turning off or entering standby mode."
@@ -69,6 +90,10 @@
"deprecated_binary_common_door_sensor": {
"title": "Deprecated binary door sensor detected in some automations or scripts",
"description": "The binary door sensor `{entity}`, which is deprecated, is used in the following automations or scripts:\n{items}\n\nA sensor entity with additional possible states is available and should be used going forward; Please use it on the above automations or scripts to fix this issue."
+ },
+ "deprecated_program_switch": {
+ "title": "Deprecated program switch detected in some automations or scripts",
+ "description": "Program switch are deprecated and {entity_id} is used in the following automations or scripts:\n{items}\n\nYou can use active program select entity to run the program without any additional option and get the current running program on the above automations or scripts to fix this issue."
}
},
"services": {
@@ -78,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." },
@@ -267,6 +292,326 @@
"name": "Wine compartment 3 temperature"
}
},
+ "select": {
+ "selected_program": {
+ "name": "Selected program",
+ "state": {
+ "consumer_products_cleaning_robot_program_cleaning_clean_all": "Clean all",
+ "consumer_products_cleaning_robot_program_cleaning_clean_map": "Clean map",
+ "consumer_products_cleaning_robot_program_basic_go_home": "Go home",
+ "consumer_products_coffee_maker_program_beverage_ristretto": "Ristretto",
+ "consumer_products_coffee_maker_program_beverage_espresso": "Espresso",
+ "consumer_products_coffee_maker_program_beverage_espresso_doppio": "Espresso doppio",
+ "consumer_products_coffee_maker_program_beverage_coffee": "Coffee",
+ "consumer_products_coffee_maker_program_beverage_x_l_coffee": "XL coffee",
+ "consumer_products_coffee_maker_program_beverage_caffe_grande": "Caffe grande",
+ "consumer_products_coffee_maker_program_beverage_espresso_macchiato": "Espresso macchiato",
+ "consumer_products_coffee_maker_program_beverage_cappuccino": "Cappuccino",
+ "consumer_products_coffee_maker_program_beverage_latte_macchiato": "Latte macchiato",
+ "consumer_products_coffee_maker_program_beverage_caffe_latte": "Caffe latte",
+ "consumer_products_coffee_maker_program_beverage_milk_froth": "Milk froth",
+ "consumer_products_coffee_maker_program_beverage_warm_milk": "Warm milk",
+ "consumer_products_coffee_maker_program_coffee_world_kleiner_brauner": "Kleiner brauner",
+ "consumer_products_coffee_maker_program_coffee_world_grosser_brauner": "Grosser brauner",
+ "consumer_products_coffee_maker_program_coffee_world_verlaengerter": "Verlaengerter",
+ "consumer_products_coffee_maker_program_coffee_world_verlaengerter_braun": "Verlaengerter braun",
+ "consumer_products_coffee_maker_program_coffee_world_wiener_melange": "Wiener melange",
+ "consumer_products_coffee_maker_program_coffee_world_flat_white": "Flat white",
+ "consumer_products_coffee_maker_program_coffee_world_cortado": "Cortado",
+ "consumer_products_coffee_maker_program_coffee_world_cafe_cortado": "Cafe cortado",
+ "consumer_products_coffee_maker_program_coffee_world_cafe_con_leche": "Cafe con leche",
+ "consumer_products_coffee_maker_program_coffee_world_cafe_au_lait": "Cafe au lait",
+ "consumer_products_coffee_maker_program_coffee_world_doppio": "Doppio",
+ "consumer_products_coffee_maker_program_coffee_world_kaapi": "Kaapi",
+ "consumer_products_coffee_maker_program_coffee_world_koffie_verkeerd": "Koffie verkeerd",
+ "consumer_products_coffee_maker_program_coffee_world_galao": "Galao",
+ "consumer_products_coffee_maker_program_coffee_world_garoto": "Garoto",
+ "consumer_products_coffee_maker_program_coffee_world_americano": "Americano",
+ "consumer_products_coffee_maker_program_coffee_world_red_eye": "Red eye",
+ "consumer_products_coffee_maker_program_coffee_world_black_eye": "Black eye",
+ "consumer_products_coffee_maker_program_coffee_world_dead_eye": "Dead eye",
+ "consumer_products_coffee_maker_program_beverage_hot_water": "Hot water",
+ "dishcare_dishwasher_program_pre_rinse": "Pre_rinse",
+ "dishcare_dishwasher_program_auto_1": "Auto 1",
+ "dishcare_dishwasher_program_auto_2": "Auto 2",
+ "dishcare_dishwasher_program_auto_3": "Auto 3",
+ "dishcare_dishwasher_program_eco_50": "Eco 50ºC",
+ "dishcare_dishwasher_program_quick_45": "Quick 45ºC",
+ "dishcare_dishwasher_program_intensiv_70": "Intensive 70ºC",
+ "dishcare_dishwasher_program_normal_65": "Normal 65ºC",
+ "dishcare_dishwasher_program_glas_40": "Glass 40ºC",
+ "dishcare_dishwasher_program_glass_care": "Glass care",
+ "dishcare_dishwasher_program_night_wash": "Night wash",
+ "dishcare_dishwasher_program_quick_65": "Quick 65ºC",
+ "dishcare_dishwasher_program_normal_45": "Normal 45ºC",
+ "dishcare_dishwasher_program_intensiv_45": "Intensive 45ºC",
+ "dishcare_dishwasher_program_auto_half_load": "Auto half load",
+ "dishcare_dishwasher_program_intensiv_power": "Intensive power",
+ "dishcare_dishwasher_program_magic_daily": "Magic daily",
+ "dishcare_dishwasher_program_super_60": "Super 60ºC",
+ "dishcare_dishwasher_program_kurz_60": "Kurz 60ºC",
+ "dishcare_dishwasher_program_express_sparkle_65": "Express sparkle 65ºC",
+ "dishcare_dishwasher_program_machine_care": "Machine care",
+ "dishcare_dishwasher_program_steam_fresh": "Steam fresh",
+ "dishcare_dishwasher_program_maximum_cleaning": "Maximum cleaning",
+ "dishcare_dishwasher_program_mixed_load": "Mixed load",
+ "laundry_care_dryer_program_cotton": "Cotton",
+ "laundry_care_dryer_program_synthetic": "Synthetic",
+ "laundry_care_dryer_program_mix": "Mix",
+ "laundry_care_dryer_program_blankets": "Blankets",
+ "laundry_care_dryer_program_business_shirts": "Business shirts",
+ "laundry_care_dryer_program_down_feathers": "Down feathers",
+ "laundry_care_dryer_program_hygiene": "Hygiene",
+ "laundry_care_dryer_program_jeans": "Jeans",
+ "laundry_care_dryer_program_outdoor": "Outdoor",
+ "laundry_care_dryer_program_synthetic_refresh": "Synthetic refresh",
+ "laundry_care_dryer_program_towels": "Towels",
+ "laundry_care_dryer_program_delicates": "Delicates",
+ "laundry_care_dryer_program_super_40": "Super 40ºC",
+ "laundry_care_dryer_program_shirts_15": "Shirts 15ºC",
+ "laundry_care_dryer_program_pillow": "Pillow",
+ "laundry_care_dryer_program_anti_shrink": "Anti shrink",
+ "laundry_care_dryer_program_my_time_my_drying_time": "My drying time",
+ "laundry_care_dryer_program_time_cold": "Cold (variable time)",
+ "laundry_care_dryer_program_time_warm": "Warm (variable time)",
+ "laundry_care_dryer_program_in_basket": "In basket",
+ "laundry_care_dryer_program_time_cold_fix_time_cold_20": "Cold (20 min)",
+ "laundry_care_dryer_program_time_cold_fix_time_cold_30": "Cold (30 min)",
+ "laundry_care_dryer_program_time_cold_fix_time_cold_60": "Cold (60 min)",
+ "laundry_care_dryer_program_time_warm_fix_time_warm_30": "Warm (30 min)",
+ "laundry_care_dryer_program_time_warm_fix_time_warm_40": "Warm (40 min)",
+ "laundry_care_dryer_program_time_warm_fix_time_warm_60": "Warm (60 min)",
+ "laundry_care_dryer_program_dessous": "Dessous",
+ "cooking_common_program_hood_automatic": "Automatic",
+ "cooking_common_program_hood_venting": "Venting",
+ "cooking_common_program_hood_delayed_shut_off": "Delayed shut off",
+ "cooking_oven_program_heating_mode_pre_heating": "Pre-heating",
+ "cooking_oven_program_heating_mode_hot_air": "Hot air",
+ "cooking_oven_program_heating_mode_hot_air_eco": "Hot air eco",
+ "cooking_oven_program_heating_mode_hot_air_grilling": "Hot air grilling",
+ "cooking_oven_program_heating_mode_top_bottom_heating": "Top bottom heating",
+ "cooking_oven_program_heating_mode_top_bottom_heating_eco": "Top bottom heating eco",
+ "cooking_oven_program_heating_mode_bottom_heating": "Bottom heating",
+ "cooking_oven_program_heating_mode_pizza_setting": "Pizza setting",
+ "cooking_oven_program_heating_mode_slow_cook": "Slow cook",
+ "cooking_oven_program_heating_mode_intensive_heat": "Intensive heat",
+ "cooking_oven_program_heating_mode_keep_warm": "Keep warm",
+ "cooking_oven_program_heating_mode_preheat_ovenware": "Preheat ovenware",
+ "cooking_oven_program_heating_mode_frozen_heatup_special": "Special Heat-Up for frozen products",
+ "cooking_oven_program_heating_mode_desiccation": "Desiccation",
+ "cooking_oven_program_heating_mode_defrost": "Defrost",
+ "cooking_oven_program_heating_mode_proof": "Proof",
+ "cooking_oven_program_heating_mode_hot_air_30_steam": "Hot air + 30 RH",
+ "cooking_oven_program_heating_mode_hot_air_60_steam": "Hot air + 60 RH",
+ "cooking_oven_program_heating_mode_hot_air_80_steam": "Hot air + 80 RH",
+ "cooking_oven_program_heating_mode_hot_air_100_steam": "Hot air + 100 RH",
+ "cooking_oven_program_heating_mode_sabbath_programme": "Sabbath programme",
+ "cooking_oven_program_microwave_90_watt": "90 Watt",
+ "cooking_oven_program_microwave_180_watt": "180 Watt",
+ "cooking_oven_program_microwave_360_watt": "360 Watt",
+ "cooking_oven_program_microwave_600_watt": "600 Watt",
+ "cooking_oven_program_microwave_900_watt": "900 Watt",
+ "cooking_oven_program_microwave_1000_watt": "1000 Watt",
+ "cooking_oven_program_microwave_max": "Max",
+ "cooking_oven_program_heating_mode_warming_drawer": "Warming drawer",
+ "laundry_care_washer_program_cotton": "Cotton",
+ "laundry_care_washer_program_cotton_cotton_eco": "Cotton eco",
+ "laundry_care_washer_program_cotton_eco_4060": "Cotton eco 40/60ºC",
+ "laundry_care_washer_program_cotton_colour": "Cotton color",
+ "laundry_care_washer_program_easy_care": "Easy care",
+ "laundry_care_washer_program_mix": "Mix",
+ "laundry_care_washer_program_mix_night_wash": "Mix night wash",
+ "laundry_care_washer_program_delicates_silk": "Delicates silk",
+ "laundry_care_washer_program_wool": "Wool",
+ "laundry_care_washer_program_sensitive": "Sensitive",
+ "laundry_care_washer_program_auto_30": "Auto 30ºC",
+ "laundry_care_washer_program_auto_40": "Auto 40ºC",
+ "laundry_care_washer_program_auto_60": "Auto 60ºC",
+ "laundry_care_washer_program_chiffon": "Chiffon",
+ "laundry_care_washer_program_curtains": "Curtains",
+ "laundry_care_washer_program_dark_wash": "Dark wash",
+ "laundry_care_washer_program_dessous": "Dessous",
+ "laundry_care_washer_program_monsoon": "Monsoon",
+ "laundry_care_washer_program_outdoor": "Outdoor",
+ "laundry_care_washer_program_plush_toy": "Plush toy",
+ "laundry_care_washer_program_shirts_blouses": "Shirts blouses",
+ "laundry_care_washer_program_sport_fitness": "Sport fitness",
+ "laundry_care_washer_program_towels": "Towels",
+ "laundry_care_washer_program_water_proof": "Water proof",
+ "laundry_care_washer_program_power_speed_59": "Power speed <60 min",
+ "laundry_care_washer_program_super_153045_super_15": "Super 15 min",
+ "laundry_care_washer_program_super_153045_super_1530": "Super 15/30 min",
+ "laundry_care_washer_program_down_duvet_duvet": "Down duvet",
+ "laundry_care_washer_program_rinse_rinse_spin_drain": "Rinse spin drain",
+ "laundry_care_washer_program_drum_clean": "Drum clean",
+ "laundry_care_washer_dryer_program_cotton": "Cotton",
+ "laundry_care_washer_dryer_program_cotton_eco_4060": "Cotton eco 40/60 ºC",
+ "laundry_care_washer_dryer_program_mix": "Mix",
+ "laundry_care_washer_dryer_program_easy_care": "Easy care",
+ "laundry_care_washer_dryer_program_wash_and_dry_60": "Wash and dry (60 min)",
+ "laundry_care_washer_dryer_program_wash_and_dry_90": "Wash and dry (90 min)"
+ }
+ },
+ "active_program": {
+ "name": "Active program",
+ "state": {
+ "consumer_products_cleaning_robot_program_cleaning_clean_all": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_cleaning_robot_program_cleaning_clean_all%]",
+ "consumer_products_cleaning_robot_program_cleaning_clean_map": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_cleaning_robot_program_cleaning_clean_map%]",
+ "consumer_products_cleaning_robot_program_basic_go_home": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_cleaning_robot_program_basic_go_home%]",
+ "consumer_products_coffee_maker_program_beverage_ristretto": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_beverage_ristretto%]",
+ "consumer_products_coffee_maker_program_beverage_espresso": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_beverage_espresso%]",
+ "consumer_products_coffee_maker_program_beverage_espresso_doppio": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_beverage_espresso_doppio%]",
+ "consumer_products_coffee_maker_program_beverage_coffee": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_beverage_coffee%]",
+ "consumer_products_coffee_maker_program_beverage_x_l_coffee": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_beverage_x_l_coffee%]",
+ "consumer_products_coffee_maker_program_beverage_caffe_grande": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_beverage_caffe_grande%]",
+ "consumer_products_coffee_maker_program_beverage_espresso_macchiato": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_beverage_espresso_macchiato%]",
+ "consumer_products_coffee_maker_program_beverage_cappuccino": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_beverage_cappuccino%]",
+ "consumer_products_coffee_maker_program_beverage_latte_macchiato": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_beverage_latte_macchiato%]",
+ "consumer_products_coffee_maker_program_beverage_caffe_latte": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_beverage_caffe_latte%]",
+ "consumer_products_coffee_maker_program_beverage_milk_froth": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_beverage_milk_froth%]",
+ "consumer_products_coffee_maker_program_beverage_warm_milk": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_beverage_warm_milk%]",
+ "consumer_products_coffee_maker_program_coffee_world_kleiner_brauner": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_kleiner_brauner%]",
+ "consumer_products_coffee_maker_program_coffee_world_grosser_brauner": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_grosser_brauner%]",
+ "consumer_products_coffee_maker_program_coffee_world_verlaengerter": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_verlaengerter%]",
+ "consumer_products_coffee_maker_program_coffee_world_verlaengerter_braun": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_verlaengerter_braun%]",
+ "consumer_products_coffee_maker_program_coffee_world_wiener_melange": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_wiener_melange%]",
+ "consumer_products_coffee_maker_program_coffee_world_flat_white": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_flat_white%]",
+ "consumer_products_coffee_maker_program_coffee_world_cortado": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_cortado%]",
+ "consumer_products_coffee_maker_program_coffee_world_cafe_cortado": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_cafe_cortado%]",
+ "consumer_products_coffee_maker_program_coffee_world_cafe_con_leche": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_cafe_con_leche%]",
+ "consumer_products_coffee_maker_program_coffee_world_cafe_au_lait": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_cafe_au_lait%]",
+ "consumer_products_coffee_maker_program_coffee_world_doppio": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_doppio%]",
+ "consumer_products_coffee_maker_program_coffee_world_kaapi": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_kaapi%]",
+ "consumer_products_coffee_maker_program_coffee_world_koffie_verkeerd": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_koffie_verkeerd%]",
+ "consumer_products_coffee_maker_program_coffee_world_galao": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_galao%]",
+ "consumer_products_coffee_maker_program_coffee_world_garoto": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_garoto%]",
+ "consumer_products_coffee_maker_program_coffee_world_americano": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_americano%]",
+ "consumer_products_coffee_maker_program_coffee_world_red_eye": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_red_eye%]",
+ "consumer_products_coffee_maker_program_coffee_world_black_eye": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_black_eye%]",
+ "consumer_products_coffee_maker_program_coffee_world_dead_eye": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_dead_eye%]",
+ "consumer_products_coffee_maker_program_beverage_hot_water": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_beverage_hot_water%]",
+ "dishcare_dishwasher_program_pre_rinse": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_pre_rinse%]",
+ "dishcare_dishwasher_program_auto_1": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_auto_1%]",
+ "dishcare_dishwasher_program_auto_2": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_auto_2%]",
+ "dishcare_dishwasher_program_auto_3": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_auto_3%]",
+ "dishcare_dishwasher_program_eco_50": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_eco_50%]",
+ "dishcare_dishwasher_program_quick_45": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_quick_45%]",
+ "dishcare_dishwasher_program_intensiv_70": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_intensiv_70%]",
+ "dishcare_dishwasher_program_normal_65": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_normal_65%]",
+ "dishcare_dishwasher_program_glas_40": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_glas_40%]",
+ "dishcare_dishwasher_program_glass_care": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_glass_care%]",
+ "dishcare_dishwasher_program_night_wash": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_night_wash%]",
+ "dishcare_dishwasher_program_quick_65": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_quick_65%]",
+ "dishcare_dishwasher_program_normal_45": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_normal_45%]",
+ "dishcare_dishwasher_program_intensiv_45": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_intensiv_45%]",
+ "dishcare_dishwasher_program_auto_half_load": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_auto_half_load%]",
+ "dishcare_dishwasher_program_intensiv_power": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_intensiv_power%]",
+ "dishcare_dishwasher_program_magic_daily": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_magic_daily%]",
+ "dishcare_dishwasher_program_super_60": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_super_60%]",
+ "dishcare_dishwasher_program_kurz_60": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_kurz_60%]",
+ "dishcare_dishwasher_program_express_sparkle_65": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_express_sparkle_65%]",
+ "dishcare_dishwasher_program_machine_care": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_machine_care%]",
+ "dishcare_dishwasher_program_steam_fresh": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_steam_fresh%]",
+ "dishcare_dishwasher_program_maximum_cleaning": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_maximum_cleaning%]",
+ "dishcare_dishwasher_program_mixed_load": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_mixed_load%]",
+ "laundry_care_dryer_program_cotton": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_cotton%]",
+ "laundry_care_dryer_program_synthetic": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_synthetic%]",
+ "laundry_care_dryer_program_mix": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_mix%]",
+ "laundry_care_dryer_program_blankets": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_blankets%]",
+ "laundry_care_dryer_program_business_shirts": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_business_shirts%]",
+ "laundry_care_dryer_program_down_feathers": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_down_feathers%]",
+ "laundry_care_dryer_program_hygiene": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_hygiene%]",
+ "laundry_care_dryer_program_jeans": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_jeans%]",
+ "laundry_care_dryer_program_outdoor": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_outdoor%]",
+ "laundry_care_dryer_program_synthetic_refresh": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_synthetic_refresh%]",
+ "laundry_care_dryer_program_towels": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_towels%]",
+ "laundry_care_dryer_program_delicates": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_delicates%]",
+ "laundry_care_dryer_program_super_40": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_super_40%]",
+ "laundry_care_dryer_program_shirts_15": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_shirts_15%]",
+ "laundry_care_dryer_program_pillow": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_pillow%]",
+ "laundry_care_dryer_program_anti_shrink": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_anti_shrink%]",
+ "laundry_care_dryer_program_my_time_my_drying_time": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_my_time_my_drying_time%]",
+ "laundry_care_dryer_program_time_cold": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_time_cold%]",
+ "laundry_care_dryer_program_time_warm": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_time_warm%]",
+ "laundry_care_dryer_program_in_basket": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_in_basket%]",
+ "laundry_care_dryer_program_time_cold_fix_time_cold_20": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_time_cold_fix_time_cold_20%]",
+ "laundry_care_dryer_program_time_cold_fix_time_cold_30": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_time_cold_fix_time_cold_30%]",
+ "laundry_care_dryer_program_time_cold_fix_time_cold_60": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_time_cold_fix_time_cold_60%]",
+ "laundry_care_dryer_program_time_warm_fix_time_warm_30": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_time_warm_fix_time_warm_30%]",
+ "laundry_care_dryer_program_time_warm_fix_time_warm_40": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_time_warm_fix_time_warm_40%]",
+ "laundry_care_dryer_program_time_warm_fix_time_warm_60": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_time_warm_fix_time_warm_60%]",
+ "laundry_care_dryer_program_dessous": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_dessous%]",
+ "cooking_common_program_hood_automatic": "[%key:component::home_connect::entity::select::selected_program::state::cooking_common_program_hood_automatic%]",
+ "cooking_common_program_hood_venting": "[%key:component::home_connect::entity::select::selected_program::state::cooking_common_program_hood_venting%]",
+ "cooking_common_program_hood_delayed_shut_off": "[%key:component::home_connect::entity::select::selected_program::state::cooking_common_program_hood_delayed_shut_off%]",
+ "cooking_oven_program_heating_mode_pre_heating": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_pre_heating%]",
+ "cooking_oven_program_heating_mode_hot_air": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_hot_air%]",
+ "cooking_oven_program_heating_mode_hot_air_eco": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_hot_air_eco%]",
+ "cooking_oven_program_heating_mode_hot_air_grilling": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_hot_air_grilling%]",
+ "cooking_oven_program_heating_mode_top_bottom_heating": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_top_bottom_heating%]",
+ "cooking_oven_program_heating_mode_top_bottom_heating_eco": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_top_bottom_heating_eco%]",
+ "cooking_oven_program_heating_mode_bottom_heating": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_bottom_heating%]",
+ "cooking_oven_program_heating_mode_pizza_setting": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_pizza_setting%]",
+ "cooking_oven_program_heating_mode_slow_cook": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_slow_cook%]",
+ "cooking_oven_program_heating_mode_intensive_heat": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_intensive_heat%]",
+ "cooking_oven_program_heating_mode_keep_warm": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_keep_warm%]",
+ "cooking_oven_program_heating_mode_preheat_ovenware": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_preheat_ovenware%]",
+ "cooking_oven_program_heating_mode_frozen_heatup_special": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_frozen_heatup_special%]",
+ "cooking_oven_program_heating_mode_desiccation": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_desiccation%]",
+ "cooking_oven_program_heating_mode_defrost": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_defrost%]",
+ "cooking_oven_program_heating_mode_proof": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_proof%]",
+ "cooking_oven_program_heating_mode_hot_air_30_steam": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_hot_air_30_steam%]",
+ "cooking_oven_program_heating_mode_hot_air_60_steam": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_hot_air_60_steam%]",
+ "cooking_oven_program_heating_mode_hot_air_80_steam": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_hot_air_80_steam%]",
+ "cooking_oven_program_heating_mode_hot_air_100_steam": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_hot_air_100_steam%]",
+ "cooking_oven_program_heating_mode_sabbath_programme": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_sabbath_programme%]",
+ "cooking_oven_program_microwave_90_watt": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_microwave_90_watt%]",
+ "cooking_oven_program_microwave_180_watt": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_microwave_180_watt%]",
+ "cooking_oven_program_microwave_360_watt": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_microwave_360_watt%]",
+ "cooking_oven_program_microwave_600_watt": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_microwave_600_watt%]",
+ "cooking_oven_program_microwave_900_watt": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_microwave_900_watt%]",
+ "cooking_oven_program_microwave_1000_watt": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_microwave_1000_watt%]",
+ "cooking_oven_program_microwave_max": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_microwave_max%]",
+ "cooking_oven_program_heating_mode_warming_drawer": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_warming_drawer%]",
+ "laundry_care_washer_program_cotton": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_cotton%]",
+ "laundry_care_washer_program_cotton_cotton_eco": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_cotton_cotton_eco%]",
+ "laundry_care_washer_program_cotton_eco_4060": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_cotton_eco_4060%]",
+ "laundry_care_washer_program_cotton_colour": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_cotton_colour%]",
+ "laundry_care_washer_program_easy_care": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_easy_care%]",
+ "laundry_care_washer_program_mix": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_mix%]",
+ "laundry_care_washer_program_mix_night_wash": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_mix_night_wash%]",
+ "laundry_care_washer_program_delicates_silk": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_delicates_silk%]",
+ "laundry_care_washer_program_wool": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_wool%]",
+ "laundry_care_washer_program_sensitive": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_sensitive%]",
+ "laundry_care_washer_program_auto_30": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_auto_30%]",
+ "laundry_care_washer_program_auto_40": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_auto_40%]",
+ "laundry_care_washer_program_auto_60": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_auto_60%]",
+ "laundry_care_washer_program_chiffon": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_chiffon%]",
+ "laundry_care_washer_program_curtains": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_curtains%]",
+ "laundry_care_washer_program_dark_wash": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_dark_wash%]",
+ "laundry_care_washer_program_dessous": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_dessous%]",
+ "laundry_care_washer_program_monsoon": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_monsoon%]",
+ "laundry_care_washer_program_outdoor": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_outdoor%]",
+ "laundry_care_washer_program_plush_toy": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_plush_toy%]",
+ "laundry_care_washer_program_shirts_blouses": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_shirts_blouses%]",
+ "laundry_care_washer_program_sport_fitness": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_sport_fitness%]",
+ "laundry_care_washer_program_towels": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_towels%]",
+ "laundry_care_washer_program_water_proof": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_water_proof%]",
+ "laundry_care_washer_program_power_speed_59": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_power_speed_59%]",
+ "laundry_care_washer_program_super_153045_super_15": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_super_153045_super_15%]",
+ "laundry_care_washer_program_super_153045_super_1530": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_super_153045_super_1530%]",
+ "laundry_care_washer_program_down_duvet_duvet": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_down_duvet_duvet%]",
+ "laundry_care_washer_program_rinse_rinse_spin_drain": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_rinse_rinse_spin_drain%]",
+ "laundry_care_washer_program_drum_clean": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_drum_clean%]",
+ "laundry_care_washer_dryer_program_cotton": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_dryer_program_cotton%]",
+ "laundry_care_washer_dryer_program_cotton_eco_4060": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_dryer_program_cotton_eco_4060%]",
+ "laundry_care_washer_dryer_program_mix": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_dryer_program_mix%]",
+ "laundry_care_washer_dryer_program_easy_care": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_dryer_program_easy_care%]",
+ "laundry_care_washer_dryer_program_wash_and_dry_60": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_dryer_program_wash_and_dry_60%]",
+ "laundry_care_washer_dryer_program_wash_and_dry_90": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_dryer_program_wash_and_dry_90%]"
+ }
+ }
+ },
"sensor": {
"program_progress": {
"name": "Program progress"
diff --git a/homeassistant/components/home_connect/switch.py b/homeassistant/components/home_connect/switch.py
index 25bbb85278a..305077bfb86 100644
--- a/homeassistant/components/home_connect/switch.py
+++ b/homeassistant/components/home_connect/switch.py
@@ -6,15 +6,22 @@ from typing import Any
from homeconnect.api import HomeConnectError
+from homeassistant.components.automation import automations_with_entity
+from homeassistant.components.script import scripts_with_entity
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.exceptions import ServiceValidationError
+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 get_dict_from_home_connect_error
-from .api import ConfigEntryAuth
+from . import HomeConnectConfigEntry, get_dict_from_home_connect_error
from .const import (
+ APPLIANCES_WITH_PROGRAMS,
ATTR_ALLOWED_VALUES,
ATTR_CONSTRAINTS,
ATTR_VALUE,
@@ -31,25 +38,13 @@ from .const import (
REFRIGERATION_SUPERMODEREFRIGERATOR,
SVE_TRANSLATION_PLACEHOLDER_APPLIANCE_NAME,
SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID,
- SVE_TRANSLATION_PLACEHOLDER_SETTING_KEY,
+ SVE_TRANSLATION_PLACEHOLDER_KEY,
SVE_TRANSLATION_PLACEHOLDER_VALUE,
)
from .entity import HomeConnectDevice, HomeConnectEntity
_LOGGER = logging.getLogger(__name__)
-APPLIANCES_WITH_PROGRAMS = (
- "CleaningRobot",
- "CoffeeMaker",
- "Dishwasher",
- "Dryer",
- "Hood",
- "Oven",
- "WarmingDrawer",
- "Washer",
- "WasherDryer",
-)
-
SWITCHES = (
SwitchEntityDescription(
@@ -105,7 +100,7 @@ SWITCHES = (
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ entry: HomeConnectConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Home Connect switch."""
@@ -113,8 +108,7 @@ async def async_setup_entry(
def get_entities() -> list[SwitchEntity]:
"""Get a list of entities."""
entities: list[SwitchEntity] = []
- hc_api: ConfigEntryAuth = hass.data[DOMAIN][config_entry.entry_id]
- for device in hc_api.devices:
+ for device in entry.runtime_data.devices:
if device.appliance.type in APPLIANCES_WITH_PROGRAMS:
with contextlib.suppress(HomeConnectError):
programs = device.appliance.get_programs_available()
@@ -148,13 +142,13 @@ class HomeConnectSwitch(HomeConnectEntity, SwitchEntity):
)
except HomeConnectError as err:
self._attr_available = False
- raise ServiceValidationError(
+ raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="turn_on",
translation_placeholders={
**get_dict_from_home_connect_error(err),
SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID: self.entity_id,
- SVE_TRANSLATION_PLACEHOLDER_SETTING_KEY: self.bsh_key,
+ SVE_TRANSLATION_PLACEHOLDER_KEY: self.bsh_key,
},
) from err
@@ -172,13 +166,13 @@ class HomeConnectSwitch(HomeConnectEntity, SwitchEntity):
except HomeConnectError as err:
_LOGGER.error("Error while trying to turn off: %s", err)
self._attr_available = False
- raise ServiceValidationError(
+ raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="turn_off",
translation_placeholders={
**get_dict_from_home_connect_error(err),
SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID: self.entity_id,
- SVE_TRANSLATION_PLACEHOLDER_SETTING_KEY: self.bsh_key,
+ SVE_TRANSLATION_PLACEHOLDER_KEY: self.bsh_key,
},
) from err
@@ -215,6 +209,55 @@ class HomeConnectProgramSwitch(HomeConnectEntity, SwitchEntity):
self._attr_has_entity_name = False
self.program_name = program_name
+ async def async_added_to_hass(self) -> None:
+ """Call when entity is added to hass."""
+ await super().async_added_to_hass()
+ automations = automations_with_entity(self.hass, self.entity_id)
+ scripts = scripts_with_entity(self.hass, self.entity_id)
+ items = automations + scripts
+ if not items:
+ return
+
+ entity_reg: er.EntityRegistry = er.async_get(self.hass)
+ entity_automations = [
+ automation_entity
+ for automation_id in automations
+ if (automation_entity := entity_reg.async_get(automation_id))
+ ]
+ entity_scripts = [
+ script_entity
+ for script_id in scripts
+ if (script_entity := entity_reg.async_get(script_id))
+ ]
+
+ items_list = [
+ f"- [{item.original_name}](/config/automation/edit/{item.unique_id})"
+ for item in entity_automations
+ ] + [
+ f"- [{item.original_name}](/config/script/edit/{item.unique_id})"
+ for item in entity_scripts
+ ]
+
+ async_create_issue(
+ self.hass,
+ DOMAIN,
+ f"deprecated_program_switch_{self.entity_id}",
+ breaks_in_ha_version="2025.6.0",
+ is_fixable=False,
+ severity=IssueSeverity.WARNING,
+ translation_key="deprecated_program_switch",
+ translation_placeholders={
+ "entity_id": self.entity_id,
+ "items": "\n".join(items_list),
+ },
+ )
+
+ async def async_will_remove_from_hass(self) -> None:
+ """Call when entity will be removed from hass."""
+ async_delete_issue(
+ self.hass, DOMAIN, f"deprecated_program_switch_{self.entity_id}"
+ )
+
async def async_turn_on(self, **kwargs: Any) -> None:
"""Start the program."""
_LOGGER.debug("Tried to turn on program %s", self.program_name)
@@ -223,7 +266,7 @@ class HomeConnectProgramSwitch(HomeConnectEntity, SwitchEntity):
self.device.appliance.start_program, self.program_name
)
except HomeConnectError as err:
- raise ServiceValidationError(
+ raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="start_program",
translation_placeholders={
@@ -239,12 +282,11 @@ class HomeConnectProgramSwitch(HomeConnectEntity, SwitchEntity):
try:
await self.hass.async_add_executor_job(self.device.appliance.stop_program)
except HomeConnectError as err:
- raise ServiceValidationError(
+ raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="stop_program",
translation_placeholders={
**get_dict_from_home_connect_error(err),
- "program": self.program_name,
},
) from err
self.async_entity_update()
@@ -292,7 +334,7 @@ class HomeConnectPowerSwitch(HomeConnectEntity, SwitchEntity):
)
except HomeConnectError as err:
self._attr_is_on = False
- raise ServiceValidationError(
+ raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="power_on",
translation_placeholders={
@@ -305,7 +347,7 @@ class HomeConnectPowerSwitch(HomeConnectEntity, SwitchEntity):
async def async_turn_off(self, **kwargs: Any) -> None:
"""Switch the device off."""
if not hasattr(self, "power_off_state"):
- raise ServiceValidationError(
+ raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="unable_to_retrieve_turn_off",
translation_placeholders={
@@ -314,7 +356,7 @@ class HomeConnectPowerSwitch(HomeConnectEntity, SwitchEntity):
)
if self.power_off_state is None:
- raise ServiceValidationError(
+ raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="turn_off_not_supported",
translation_placeholders={
@@ -330,7 +372,7 @@ class HomeConnectPowerSwitch(HomeConnectEntity, SwitchEntity):
)
except HomeConnectError as err:
self._attr_is_on = True
- raise ServiceValidationError(
+ raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="power_off",
translation_placeholders={
diff --git a/homeassistant/components/home_connect/time.py b/homeassistant/components/home_connect/time.py
index 946a2354938..c1f125cd2f7 100644
--- a/homeassistant/components/home_connect/time.py
+++ b/homeassistant/components/home_connect/time.py
@@ -6,18 +6,17 @@ import logging
from homeconnect.api import HomeConnectError
from homeassistant.components.time import TimeEntity, TimeEntityDescription
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.exceptions import ServiceValidationError
+from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from . import get_dict_from_home_connect_error
-from .api import ConfigEntryAuth
+from . import HomeConnectConfigEntry, get_dict_from_home_connect_error
from .const import (
ATTR_VALUE,
DOMAIN,
+ SVE_TRANSLATION_KEY_SET_SETTING,
SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID,
- SVE_TRANSLATION_PLACEHOLDER_SETTING_KEY,
+ SVE_TRANSLATION_PLACEHOLDER_KEY,
SVE_TRANSLATION_PLACEHOLDER_VALUE,
)
from .entity import HomeConnectEntity
@@ -35,18 +34,17 @@ TIME_ENTITIES = (
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ entry: HomeConnectConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Home Connect switch."""
def get_entities() -> list[HomeConnectTimeEntity]:
"""Get a list of entities."""
- hc_api: ConfigEntryAuth = hass.data[DOMAIN][config_entry.entry_id]
return [
HomeConnectTimeEntity(device, description)
for description in TIME_ENTITIES
- for device in hc_api.devices
+ for device in entry.runtime_data.devices
if description.key in device.appliance.status
]
@@ -83,13 +81,13 @@ class HomeConnectTimeEntity(HomeConnectEntity, TimeEntity):
time_to_seconds(value),
)
except HomeConnectError as err:
- raise ServiceValidationError(
+ raise HomeAssistantError(
translation_domain=DOMAIN,
- translation_key="set_setting",
+ translation_key=SVE_TRANSLATION_KEY_SET_SETTING,
translation_placeholders={
**get_dict_from_home_connect_error(err),
SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID: self.entity_id,
- SVE_TRANSLATION_PLACEHOLDER_SETTING_KEY: self.bsh_key,
+ SVE_TRANSLATION_PLACEHOLDER_KEY: self.bsh_key,
SVE_TRANSLATION_PLACEHOLDER_VALUE: str(value),
},
) from err
diff --git a/homeassistant/components/homeassistant/strings.json b/homeassistant/components/homeassistant/strings.json
index 0dd4eff507d..3283d480fdd 100644
--- a/homeassistant/components/homeassistant/strings.json
+++ b/homeassistant/components/homeassistant/strings.json
@@ -10,6 +10,10 @@
"title": "The country has not been configured",
"description": "No country has been configured, please update the configuration by clicking on the \"learn more\" button below."
},
+ "imperial_unit_system": {
+ "title": "The imperial unit system is deprecated",
+ "description": "The imperial unit system is deprecated and your system is currently using us customary. Please update your configuration to use the us customary unit system and reload the core configuration to fix this issue."
+ },
"deprecated_yaml": {
"title": "The {integration_title} YAML configuration is being removed",
"description": "Configuring {integration_title} using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the `{domain}` configuration from your configuration.yaml file and restart Home Assistant to fix this issue."
@@ -134,7 +138,7 @@
},
"elevation": {
"name": "[%key:common::config_flow::data::elevation%]",
- "description": "Elevation of your location."
+ "description": "Elevation of your location above sea level."
}
}
},
@@ -224,6 +228,9 @@
"service_not_found": {
"message": "Action {domain}.{service} not found."
},
+ "service_not_supported": {
+ "message": "Entity {entity_id} does not support action {domain}.{service}."
+ },
"service_does_not_support_response": {
"message": "An action which does not return responses can't be called with {return_response}."
},
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/silabs_multiprotocol_addon.py b/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py
index 14ae57391ef..2b08031405f 100644
--- a/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py
+++ b/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py
@@ -318,7 +318,6 @@ class OptionsFlowHandler(OptionsFlow, ABC):
self.start_task: asyncio.Task | None = None
self.stop_task: asyncio.Task | None = None
self._zha_migration_mgr: ZhaMultiPANMigrationHelper | None = None
- self.config_entry = config_entry
self.original_addon_config: dict[str, Any] | None = None
self.revert_reason: str | None = None
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..2fbf8bcb6bc 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,
diff --git a/homeassistant/components/homeassistant_yellow/config_flow.py b/homeassistant/components/homeassistant_yellow/config_flow.py
index 9edc5009171..502a20db07c 100644
--- a/homeassistant/components/homeassistant_yellow/config_flow.py
+++ b/homeassistant/components/homeassistant_yellow/config_flow.py
@@ -8,14 +8,13 @@ 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 (
HassioAPIError,
async_get_yellow_settings,
- async_reboot_host,
async_set_yellow_settings,
+ get_supervisor_client,
)
from homeassistant.components.homeassistant_hardware.firmware_config_flow import (
BaseFirmwareConfigFlow,
@@ -25,13 +24,14 @@ 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,
ConfigFlowResult,
OptionsFlow,
)
-from homeassistant.core import callback
+from homeassistant.core import HomeAssistant, async_get_hass, callback
from homeassistant.helpers import discovery_flow, selector
from .const import DOMAIN, FIRMWARE, RADIO_DEVICE, ZHA_DOMAIN, ZHA_HW_DISCOVERY_DATA
@@ -67,11 +67,12 @@ class HomeAssistantYellowConfigFlow(BaseFirmwareConfigFlow, domain=DOMAIN):
) -> OptionsFlow:
"""Return the options flow."""
firmware_type = ApplicationType(config_entry.data[FIRMWARE])
+ hass = async_get_hass()
if firmware_type is ApplicationType.CPC:
- return HomeAssistantYellowMultiPanOptionsFlowHandler(config_entry)
+ return HomeAssistantYellowMultiPanOptionsFlowHandler(hass, config_entry)
- return HomeAssistantYellowOptionsFlowHandler(config_entry)
+ return HomeAssistantYellowOptionsFlowHandler(hass, config_entry)
async def async_step_system(
self, data: dict[str, Any] | None = None
@@ -107,6 +108,11 @@ class BaseHomeAssistantYellowOptionsFlow(OptionsFlow, ABC):
_hw_settings: dict[str, bool] | None = None
+ def __init__(self, hass: HomeAssistant, *args: Any, **kwargs: Any) -> None:
+ """Instantiate options flow."""
+ super().__init__(*args, **kwargs)
+ self._supervisor_client = get_supervisor_client(hass)
+
@abstractmethod
async def async_step_main_menu(self, _: None = None) -> ConfigFlowResult:
"""Show the main menu."""
@@ -172,7 +178,7 @@ class BaseHomeAssistantYellowOptionsFlow(OptionsFlow, ABC):
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Reboot now."""
- await async_reboot_host(self.hass)
+ await self._supervisor_client.host.reboot()
return self.async_create_entry(data={})
async def async_step_reboot_later(
@@ -251,9 +257,9 @@ class HomeAssistantYellowOptionsFlowHandler(
):
"""Handle a firmware options flow for Home Assistant Yellow."""
- def __init__(self, *args: Any, **kwargs: Any) -> None:
+ def __init__(self, hass: HomeAssistant, *args: Any, **kwargs: Any) -> None:
"""Instantiate options flow."""
- super().__init__(*args, **kwargs)
+ super().__init__(hass, *args, **kwargs)
self._hardware_name = BOARD_NAME
self._device = RADIO_DEVICE
diff --git a/homeassistant/components/homee/__init__.py b/homeassistant/components/homee/__init__.py
new file mode 100644
index 00000000000..ed5dd69767f
--- /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]
+
+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)
+
+ async 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])
+
+ await 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..c96165ead81
--- /dev/null
+++ b/homeassistant/components/homee/const.py
@@ -0,0 +1,4 @@
+"""Constants for the homee integration."""
+
+# General
+DOMAIN = "homee"
diff --git a/homeassistant/components/homee/cover.py b/homeassistant/components/homee/cover.py
new file mode 100644
index 00000000000..c6546596fa7
--- /dev/null
+++ b/homeassistant/components/homee/cover.py
@@ -0,0 +1,261 @@
+"""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:
+ """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
+) -> CoverEntityFeature:
+ """Determine the supported cover features of a homee node based on the available attributes."""
+ features = CoverEntityFeature(0)
+
+ if 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}"
+
+ @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 self.has_attribute(AttributeType.POSITION):
+ attribute = self._node.get_attribute_by_type(AttributeType.POSITION)
+ homee_min = attribute.minimum
+ homee_max = attribute.maximum
+ homee_position = attribute.current_value
+ position = ((homee_position - homee_min) / (homee_max - homee_min)) * 100
+
+ return 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 self.has_attribute(AttributeType.SHUTTER_SLAT_POSITION):
+ attribute = self._node.get_attribute_by_type(
+ AttributeType.SHUTTER_SLAT_POSITION
+ )
+ homee_min = attribute.minimum
+ homee_max = attribute.maximum
+ homee_position = attribute.current_value
+ position = ((homee_position - homee_min) / (homee_max - homee_min)) * 100
+
+ return 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 self.has_attribute(AttributeType.POSITION):
+ attribute = self._node.get_attribute_by_type(AttributeType.POSITION)
+ 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 self.has_attribute(AttributeType.SHUTTER_SLAT_POSITION):
+ attribute = self._node.get_attribute_by_type(
+ AttributeType.SHUTTER_SLAT_POSITION
+ )
+ return attribute.get_value() == attribute.minimum
+
+ return None
+
+ async def async_open_cover(self, **kwargs: Any) -> None:
+ """Open the cover."""
+ 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."""
+ 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.
+ attribute = self._node.get_attribute_by_type(AttributeType.POSITION)
+ homee_min = attribute.minimum
+ homee_max = attribute.maximum
+ homee_position = (position / 100) * (homee_max - homee_min) + homee_min
+
+ await self.async_set_value(AttributeType.POSITION, homee_position)
+
+ async def async_stop_cover(self, **kwargs: Any) -> None:
+ """Stop the cover."""
+ await self.async_set_value(self._open_close_attribute, 2)
+
+ async def async_open_cover_tilt(self, **kwargs: Any) -> None:
+ """Open the cover tilt."""
+ slat_attribute = self._node.get_attribute_by_type(
+ AttributeType.SLAT_ROTATION_IMPULSE
+ )
+ if not slat_attribute.is_reversed:
+ await self.async_set_value(AttributeType.SLAT_ROTATION_IMPULSE, 2)
+ else:
+ await self.async_set_value(AttributeType.SLAT_ROTATION_IMPULSE, 1)
+
+ async def async_close_cover_tilt(self, **kwargs: Any) -> None:
+ """Close the cover tilt."""
+ slat_attribute = self._node.get_attribute_by_type(
+ AttributeType.SLAT_ROTATION_IMPULSE
+ )
+ if not slat_attribute.is_reversed:
+ await self.async_set_value(AttributeType.SLAT_ROTATION_IMPULSE, 1)
+ else:
+ await self.async_set_value(AttributeType.SLAT_ROTATION_IMPULSE, 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.
+ attribute = self._node.get_attribute_by_type(
+ AttributeType.SHUTTER_SLAT_POSITION
+ )
+ homee_min = attribute.minimum
+ homee_max = attribute.maximum
+ homee_position = (position / 100) * (homee_max - homee_min) + homee_min
+
+ await self.async_set_value(
+ AttributeType.SHUTTER_SLAT_POSITION, homee_position
+ )
diff --git a/homeassistant/components/homee/entity.py b/homeassistant/components/homee/entity.py
new file mode 100644
index 00000000000..c3c2d860cc0
--- /dev/null
+++ b/homeassistant/components/homee/entity.py
@@ -0,0 +1,88 @@
+"""Base Entities for Homee integration."""
+
+from pyHomee.const import AttributeType, NodeProfile, NodeState
+from pyHomee.model import 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 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.runtime_data.settings.uid}-{node.id}"
+ self._entry = entry
+
+ self._attr_device_info = DeviceInfo(
+ identifiers={(DOMAIN, str(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(
+ await 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 self.has_attribute(AttributeType.FIRMWARE_REVISION):
+ return self._node.get_attribute_by_type(
+ AttributeType.FIRMWARE_REVISION
+ ).get_value()
+ if self.has_attribute(AttributeType.SOFTWARE_REVISION):
+ return self._node.get_attribute_by_type(
+ AttributeType.SOFTWARE_REVISION
+ ).get_value()
+ return None
+
+ def has_attribute(self, attribute_type: AttributeType) -> bool:
+ """Check if an attribute of the given type exists."""
+ return attribute_type in self._node.attribute_map
+
+ async def async_set_value(self, attribute_type: int, value: float) -> None:
+ """Set an attribute value on the homee node."""
+ await self.async_set_value_by_id(
+ self._node.get_attribute_by_type(attribute_type).id, value
+ )
+
+ async def async_set_value_by_id(self, attribute_id: int, value: float) -> None:
+ """Set an attribute value on the homee node."""
+ homee = self._entry.runtime_data
+ await homee.set_value(self._node.id, attribute_id, value)
+
+ def _on_node_updated(self, node: HomeeNode) -> None:
+ self.schedule_update_ha_state()
+
+ async 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..30826d7f47c
--- /dev/null
+++ b/homeassistant/components/homee/helpers.py
@@ -0,0 +1,16 @@
+"""Helper functions for the homee custom component."""
+
+import logging
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def get_name_for_enum(att_class, att_id) -> str:
+ """Return the enum item name for a given integer."""
+ try:
+ attribute_name = att_class(att_id).name
+ except ValueError:
+ _LOGGER.warning("Value %s does not exist in %s", att_id, att_class.__name__)
+ return "Unknown"
+
+ return attribute_name
diff --git a/homeassistant/components/homee/manifest.json b/homeassistant/components/homee/manifest.json
new file mode 100644
index 00000000000..5869a9760ea
--- /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.0"]
+}
diff --git a/homeassistant/components/homee/quality_scale.yaml b/homeassistant/components/homee/quality_scale.yaml
new file mode 100644
index 00000000000..96d4678b420
--- /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: todo
+ entity-device-class: todo
+ entity-disabled-by-default: todo
+ entity-translations: todo
+ exception-translations: todo
+ icon-translations: todo
+ reconfiguration-flow: todo
+ repair-issues: todo
+ stale-devices: todo
+
+ # Platinum
+ async-dependency: todo
+ inject-websession: todo
+ strict-typing: todo
diff --git a/homeassistant/components/homee/strings.json b/homeassistant/components/homee/strings.json
new file mode 100644
index 00000000000..54f80ba2977
--- /dev/null
+++ b/homeassistant/components/homee/strings.json
@@ -0,0 +1,28 @@
+{
+ "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."
+ }
+ }
+ }
+ }
+}
diff --git a/homeassistant/components/homekit/__init__.py b/homeassistant/components/homekit/__init__.py
index b85308ffd66..97fb17d7db5 100644
--- a/homeassistant/components/homekit/__init__.py
+++ b/homeassistant/components/homekit/__init__.py
@@ -33,6 +33,7 @@ from homeassistant.components.device_automation.trigger import (
from homeassistant.components.event import DOMAIN as EVENT_DOMAIN, EventDeviceClass
from homeassistant.components.http import KEY_HASS, HomeAssistantView
from homeassistant.components.humidifier import DOMAIN as HUMIDIFIER_DOMAIN
+from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorDeviceClass
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import (
@@ -1133,6 +1134,8 @@ class HomeKit:
config[entity_id].setdefault(
CONF_LINKED_MOTION_SENSOR, motion_binary_sensor_entity_id
)
+
+ if domain in (CAMERA_DOMAIN, LOCK_DOMAIN):
if doorbell_event_entity_id := lookup.get(DOORBELL_EVENT_SENSOR):
config[entity_id].setdefault(
CONF_LINKED_DOORBELL_SENSOR, doorbell_event_entity_id
diff --git a/homeassistant/components/homekit/doorbell.py b/homeassistant/components/homekit/doorbell.py
new file mode 100644
index 00000000000..45bbb2ea0ca
--- /dev/null
+++ b/homeassistant/components/homekit/doorbell.py
@@ -0,0 +1,121 @@
+"""Extend the doorbell functions."""
+
+from __future__ import annotations
+
+import logging
+from typing import Any
+
+from pyhap.util import callback as pyhap_callback
+
+from homeassistant.const import STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN
+from homeassistant.core import (
+ Event,
+ EventStateChangedData,
+ HassJobType,
+ State,
+ callback as ha_callback,
+)
+from homeassistant.helpers.event import async_track_state_change_event
+
+from .accessories import HomeAccessory
+from .const import (
+ CHAR_MUTE,
+ CHAR_PROGRAMMABLE_SWITCH_EVENT,
+ CONF_LINKED_DOORBELL_SENSOR,
+ SERV_DOORBELL,
+ SERV_SPEAKER,
+ SERV_STATELESS_PROGRAMMABLE_SWITCH,
+)
+from .util import state_changed_event_is_same_state
+
+_LOGGER = logging.getLogger(__name__)
+
+DOORBELL_SINGLE_PRESS = 0
+DOORBELL_DOUBLE_PRESS = 1
+DOORBELL_LONG_PRESS = 2
+
+
+class HomeDoorbellAccessory(HomeAccessory):
+ """Accessory with optional doorbell."""
+
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
+ """Initialize an Accessory object with optional attached doorbell."""
+ super().__init__(*args, **kwargs)
+ self._char_doorbell_detected = None
+ self._char_doorbell_detected_switch = None
+ linked_doorbell_sensor: str | None
+ linked_doorbell_sensor = self.config.get(CONF_LINKED_DOORBELL_SENSOR)
+ self.linked_doorbell_sensor = linked_doorbell_sensor
+ self.doorbell_is_event = False
+ if not linked_doorbell_sensor:
+ return
+ self.doorbell_is_event = linked_doorbell_sensor.startswith("event.")
+ if not (state := self.hass.states.get(linked_doorbell_sensor)):
+ return
+ serv_doorbell = self.add_preload_service(SERV_DOORBELL)
+ self.set_primary_service(serv_doorbell)
+ self._char_doorbell_detected = serv_doorbell.configure_char(
+ CHAR_PROGRAMMABLE_SWITCH_EVENT,
+ value=0,
+ )
+ serv_stateless_switch = self.add_preload_service(
+ SERV_STATELESS_PROGRAMMABLE_SWITCH
+ )
+ self._char_doorbell_detected_switch = serv_stateless_switch.configure_char(
+ CHAR_PROGRAMMABLE_SWITCH_EVENT,
+ value=0,
+ valid_values={"SinglePress": DOORBELL_SINGLE_PRESS},
+ )
+ serv_speaker = self.add_preload_service(SERV_SPEAKER)
+ serv_speaker.configure_char(CHAR_MUTE, value=0)
+ self.async_update_doorbell_state(None, state)
+
+ @ha_callback
+ @pyhap_callback # type: ignore[misc]
+ def run(self) -> None:
+ """Handle doorbell event."""
+ if self._char_doorbell_detected:
+ assert self.linked_doorbell_sensor
+ self._subscriptions.append(
+ async_track_state_change_event(
+ self.hass,
+ self.linked_doorbell_sensor,
+ self.async_update_doorbell_state_event,
+ job_type=HassJobType.Callback,
+ )
+ )
+
+ super().run()
+
+ @ha_callback
+ def async_update_doorbell_state_event(
+ self, event: Event[EventStateChangedData]
+ ) -> None:
+ """Handle state change event listener callback."""
+ if not state_changed_event_is_same_state(event) and (
+ new_state := event.data["new_state"]
+ ):
+ self.async_update_doorbell_state(event.data["old_state"], new_state)
+
+ @ha_callback
+ def async_update_doorbell_state(
+ self, old_state: State | None, new_state: State
+ ) -> None:
+ """Handle link doorbell sensor state change to update HomeKit value."""
+ assert self._char_doorbell_detected
+ assert self._char_doorbell_detected_switch
+ state = new_state.state
+ if state == STATE_ON or (
+ self.doorbell_is_event
+ and old_state is not None
+ and old_state.state != STATE_UNAVAILABLE
+ and state not in (STATE_UNKNOWN, STATE_UNAVAILABLE)
+ ):
+ self._char_doorbell_detected.set_value(DOORBELL_SINGLE_PRESS)
+ self._char_doorbell_detected_switch.set_value(DOORBELL_SINGLE_PRESS)
+ _LOGGER.debug(
+ "%s: Set linked doorbell %s sensor to %d",
+ self.entity_id,
+ self.linked_doorbell_sensor,
+ DOORBELL_SINGLE_PRESS,
+ )
diff --git a/homeassistant/components/homekit/type_cameras.py b/homeassistant/components/homekit/type_cameras.py
index 9e076f7d4d7..0fb2c2e7922 100644
--- a/homeassistant/components/homekit/type_cameras.py
+++ b/homeassistant/components/homekit/type_cameras.py
@@ -31,15 +31,12 @@ from homeassistant.helpers.event import (
)
from homeassistant.util.async_ import create_eager_task
-from .accessories import TYPES, HomeAccessory, HomeDriver
+from .accessories import TYPES, HomeDriver
from .const import (
CHAR_MOTION_DETECTED,
- CHAR_MUTE,
- CHAR_PROGRAMMABLE_SWITCH_EVENT,
CONF_AUDIO_CODEC,
CONF_AUDIO_MAP,
CONF_AUDIO_PACKET_SIZE,
- CONF_LINKED_DOORBELL_SENSOR,
CONF_LINKED_MOTION_SENSOR,
CONF_MAX_FPS,
CONF_MAX_HEIGHT,
@@ -64,18 +61,13 @@ from .const import (
DEFAULT_VIDEO_MAP,
DEFAULT_VIDEO_PACKET_SIZE,
DEFAULT_VIDEO_PROFILE_NAMES,
- SERV_DOORBELL,
SERV_MOTION_SENSOR,
- SERV_SPEAKER,
- SERV_STATELESS_PROGRAMMABLE_SWITCH,
)
+from .doorbell import HomeDoorbellAccessory
from .util import pid_is_alive, state_changed_event_is_same_state
_LOGGER = logging.getLogger(__name__)
-DOORBELL_SINGLE_PRESS = 0
-DOORBELL_DOUBLE_PRESS = 1
-DOORBELL_LONG_PRESS = 2
VIDEO_OUTPUT = (
"-map {v_map} -an "
@@ -149,7 +141,7 @@ CONFIG_DEFAULTS = {
@TYPES.register("Camera")
# False-positive on pylint, not a CameraEntity
# pylint: disable-next=hass-enforce-class-module
-class Camera(HomeAccessory, PyhapCamera): # type: ignore[misc]
+class Camera(HomeDoorbellAccessory, PyhapCamera): # type: ignore[misc]
"""Generate a Camera accessory."""
def __init__(
@@ -237,36 +229,6 @@ class Camera(HomeAccessory, PyhapCamera): # type: ignore[misc]
)
self._async_update_motion_state(None, state)
- self._char_doorbell_detected = None
- self._char_doorbell_detected_switch = None
- linked_doorbell_sensor: str | None = self.config.get(
- CONF_LINKED_DOORBELL_SENSOR
- )
- self.linked_doorbell_sensor = linked_doorbell_sensor
- self.doorbell_is_event = False
- if not linked_doorbell_sensor:
- return
- self.doorbell_is_event = linked_doorbell_sensor.startswith("event.")
- if not (state := self.hass.states.get(linked_doorbell_sensor)):
- return
- serv_doorbell = self.add_preload_service(SERV_DOORBELL)
- self.set_primary_service(serv_doorbell)
- self._char_doorbell_detected = serv_doorbell.configure_char(
- CHAR_PROGRAMMABLE_SWITCH_EVENT,
- value=0,
- )
- serv_stateless_switch = self.add_preload_service(
- SERV_STATELESS_PROGRAMMABLE_SWITCH
- )
- self._char_doorbell_detected_switch = serv_stateless_switch.configure_char(
- CHAR_PROGRAMMABLE_SWITCH_EVENT,
- value=0,
- valid_values={"SinglePress": DOORBELL_SINGLE_PRESS},
- )
- serv_speaker = self.add_preload_service(SERV_SPEAKER)
- serv_speaker.configure_char(CHAR_MUTE, value=0)
- self._async_update_doorbell_state(None, state)
-
@pyhap_callback # type: ignore[misc]
@callback
def run(self) -> None:
@@ -285,17 +247,6 @@ class Camera(HomeAccessory, PyhapCamera): # type: ignore[misc]
)
)
- if self._char_doorbell_detected:
- assert self.linked_doorbell_sensor
- self._subscriptions.append(
- async_track_state_change_event(
- self.hass,
- self.linked_doorbell_sensor,
- self._async_update_doorbell_state_event,
- job_type=HassJobType.Callback,
- )
- )
-
super().run()
@callback
@@ -344,39 +295,6 @@ class Camera(HomeAccessory, PyhapCamera): # type: ignore[misc]
detected,
)
- @callback
- def _async_update_doorbell_state_event(
- self, event: Event[EventStateChangedData]
- ) -> None:
- """Handle state change event listener callback."""
- if not state_changed_event_is_same_state(event) and (
- new_state := event.data["new_state"]
- ):
- self._async_update_doorbell_state(event.data["old_state"], new_state)
-
- @callback
- def _async_update_doorbell_state(
- self, old_state: State | None, new_state: State
- ) -> None:
- """Handle link doorbell sensor state change to update HomeKit value."""
- assert self._char_doorbell_detected
- assert self._char_doorbell_detected_switch
- state = new_state.state
- if state == STATE_ON or (
- self.doorbell_is_event
- and old_state is not None
- and old_state.state != STATE_UNAVAILABLE
- and state not in (STATE_UNKNOWN, STATE_UNAVAILABLE)
- ):
- self._char_doorbell_detected.set_value(DOORBELL_SINGLE_PRESS)
- self._char_doorbell_detected_switch.set_value(DOORBELL_SINGLE_PRESS)
- _LOGGER.debug(
- "%s: Set linked doorbell %s sensor to %d",
- self.entity_id,
- self.linked_doorbell_sensor,
- DOORBELL_SINGLE_PRESS,
- )
-
@callback
def async_update_state(self, new_state: State | None) -> None:
"""Handle state change to update HomeKit value."""
diff --git a/homeassistant/components/homekit/type_locks.py b/homeassistant/components/homekit/type_locks.py
index 70570a8fca5..59da802b8b7 100644
--- a/homeassistant/components/homekit/type_locks.py
+++ b/homeassistant/components/homekit/type_locks.py
@@ -9,8 +9,9 @@ from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN, LockState
from homeassistant.const import ATTR_CODE, ATTR_ENTITY_ID, STATE_UNKNOWN
from homeassistant.core import State, callback
-from .accessories import TYPES, HomeAccessory
+from .accessories import TYPES
from .const import CHAR_LOCK_CURRENT_STATE, CHAR_LOCK_TARGET_STATE, SERV_LOCK
+from .doorbell import HomeDoorbellAccessory
_LOGGER = logging.getLogger(__name__)
@@ -53,7 +54,7 @@ STATE_TO_SERVICE = {
@TYPES.register("Lock")
-class Lock(HomeAccessory):
+class Lock(HomeDoorbellAccessory):
"""Generate a Lock accessory for a lock entity.
The lock entity must support: unlock and lock.
diff --git a/homeassistant/components/homekit/type_security_systems.py b/homeassistant/components/homekit/type_security_systems.py
index 9f3f183f11f..8634589cb5f 100644
--- a/homeassistant/components/homekit/type_security_systems.py
+++ b/homeassistant/components/homekit/type_security_systems.py
@@ -18,6 +18,8 @@ from homeassistant.const import (
SERVICE_ALARM_ARM_HOME,
SERVICE_ALARM_ARM_NIGHT,
SERVICE_ALARM_DISARM,
+ STATE_UNAVAILABLE,
+ STATE_UNKNOWN,
)
from homeassistant.core import State, callback
@@ -152,12 +154,12 @@ class SecuritySystem(HomeAccessory):
@callback
def async_update_state(self, new_state: State) -> None:
"""Update security state after state changed."""
- hass_state = None
- if new_state and new_state.state == "None":
- # Bail out early for no state
+ hass_state: str | AlarmControlPanelState = new_state.state
+ if hass_state in {"None", STATE_UNKNOWN, STATE_UNAVAILABLE}:
+ # Bail out early for no state, unknown or unavailable
return
- if new_state and new_state.state is not None:
- hass_state = AlarmControlPanelState(new_state.state)
+ if hass_state is not None:
+ hass_state = AlarmControlPanelState(hass_state)
if (
hass_state
and (current_state := HASS_TO_HOMEKIT_CURRENT.get(hass_state)) is not None
diff --git a/homeassistant/components/homekit/type_switches.py b/homeassistant/components/homekit/type_switches.py
index 68df6c38ad6..0482a5956ac 100644
--- a/homeassistant/components/homekit/type_switches.py
+++ b/homeassistant/components/homekit/type_switches.py
@@ -21,7 +21,7 @@ from homeassistant.components.vacuum import (
DOMAIN as VACUUM_DOMAIN,
SERVICE_RETURN_TO_BASE,
SERVICE_START,
- STATE_CLEANING,
+ VacuumActivity,
VacuumEntityFeature,
)
from homeassistant.const import (
@@ -213,7 +213,7 @@ class Vacuum(Switch):
@callback
def async_update_state(self, new_state: State) -> None:
"""Update switch state after state changed."""
- current_state = new_state.state in (STATE_CLEANING, STATE_ON)
+ current_state = new_state.state in (VacuumActivity.CLEANING, STATE_ON)
_LOGGER.debug("%s: Set current state to %s", self.entity_id, current_state)
self.char_on.set_value(current_state)
diff --git a/homeassistant/components/homekit/util.py b/homeassistant/components/homekit/util.py
index ae7e35030be..d339aa6aded 100644
--- a/homeassistant/components/homekit/util.py
+++ b/homeassistant/components/homekit/util.py
@@ -114,7 +114,7 @@ _LOGGER = logging.getLogger(__name__)
NUMBERS_ONLY_RE = re.compile(r"[^\d.]+")
VERSION_RE = re.compile(r"([0-9]+)(\.[0-9]+)?(\.[0-9]+)?")
-INVALID_END_CHARS = "-_"
+INVALID_END_CHARS = "-_ "
MAX_VERSION_PART = 2**32 - 1
@@ -182,7 +182,6 @@ HUMIDIFIER_SCHEMA = BASIC_INFO_SCHEMA.extend(
{vol.Optional(CONF_LINKED_HUMIDITY_SENSOR): cv.entity_domain(sensor.DOMAIN)}
)
-
COVER_SCHEMA = BASIC_INFO_SCHEMA.extend(
{
vol.Optional(CONF_LINKED_OBSTRUCTION_SENSOR): cv.entity_domain(
@@ -195,6 +194,14 @@ CODE_SCHEMA = BASIC_INFO_SCHEMA.extend(
{vol.Optional(ATTR_CODE, default=None): vol.Any(None, cv.string)}
)
+LOCK_SCHEMA = CODE_SCHEMA.extend(
+ {
+ vol.Optional(CONF_LINKED_DOORBELL_SENSOR): cv.entity_domain(
+ [binary_sensor.DOMAIN, EVENT_DOMAIN]
+ ),
+ }
+)
+
MEDIA_PLAYER_SCHEMA = vol.Schema(
{
vol.Required(CONF_FEATURE): vol.All(
@@ -284,7 +291,7 @@ def validate_entity_config(values: dict) -> dict[str, dict]:
if not isinstance(config, dict):
raise vol.Invalid(f"The configuration for {entity} must be a dictionary.")
- if domain in ("alarm_control_panel", "lock"):
+ if domain == "alarm_control_panel":
config = CODE_SCHEMA(config)
elif domain == media_player.const.DOMAIN:
@@ -301,6 +308,9 @@ def validate_entity_config(values: dict) -> dict[str, dict]:
elif domain == "camera":
config = CAMERA_SCHEMA(config)
+ elif domain == "lock":
+ config = LOCK_SCHEMA(config)
+
elif domain == "switch":
config = SWITCH_TYPE_SCHEMA(config)
@@ -424,20 +434,12 @@ def cleanup_name_for_homekit(name: str | None) -> str:
def temperature_to_homekit(temperature: float, unit: str) -> float:
"""Convert temperature to Celsius for HomeKit."""
- return round(
- TemperatureConverter.convert(temperature, unit, UnitOfTemperature.CELSIUS), 1
- )
+ return TemperatureConverter.convert(temperature, unit, UnitOfTemperature.CELSIUS)
def temperature_to_states(temperature: float, unit: str) -> float:
"""Convert temperature back from Celsius to Home Assistant unit."""
- return (
- round(
- TemperatureConverter.convert(temperature, UnitOfTemperature.CELSIUS, unit)
- * 2
- )
- / 2
- )
+ return TemperatureConverter.convert(temperature, UnitOfTemperature.CELSIUS, unit)
def density_to_air_quality(density: float) -> int:
diff --git a/homeassistant/components/homekit_controller/alarm_control_panel.py b/homeassistant/components/homekit_controller/alarm_control_panel.py
index 3cb80f2c817..b17f122dfa5 100644
--- a/homeassistant/components/homekit_controller/alarm_control_panel.py
+++ b/homeassistant/components/homekit_controller/alarm_control_panel.py
@@ -69,6 +69,7 @@ class HomeKitAlarmControlPanelEntity(HomeKitEntity, AlarmControlPanelEntity):
| AlarmControlPanelEntityFeature.ARM_AWAY
| AlarmControlPanelEntityFeature.ARM_NIGHT
)
+ _attr_code_arm_required = False
def get_characteristic_types(self) -> list[str]:
"""Define the homekit characteristics the entity cares about."""
diff --git a/homeassistant/components/homekit_controller/climate.py b/homeassistant/components/homekit_controller/climate.py
index 4e55c8212be..ba5237e6e2d 100644
--- a/homeassistant/components/homekit_controller/climate.py
+++ b/homeassistant/components/homekit_controller/climate.py
@@ -136,7 +136,6 @@ class HomeKitBaseClimateEntity(HomeKitEntity, ClimateEntity):
"""The base HomeKit Controller climate entity."""
_attr_temperature_unit = UnitOfTemperature.CELSIUS
- _enable_turn_on_off_backwards_compatibility = False
@callback
def _async_reconfigure(self) -> None:
diff --git a/homeassistant/components/homekit_controller/fan.py b/homeassistant/components/homekit_controller/fan.py
index 63de146a024..2ae534099ae 100644
--- a/homeassistant/components/homekit_controller/fan.py
+++ b/homeassistant/components/homekit_controller/fan.py
@@ -42,7 +42,6 @@ class BaseHomeKitFan(HomeKitEntity, FanEntity):
# This must be set in subclasses to the name of a boolean characteristic
# that controls whether the fan is on or off.
on_characteristic: str
- _enable_turn_on_off_backwards_compatibility = False
@callback
def _async_reconfigure(self) -> None:
diff --git a/homeassistant/components/homekit_controller/light.py b/homeassistant/components/homekit_controller/light.py
index 472ccfbd550..26f10768aa0 100644
--- a/homeassistant/components/homekit_controller/light.py
+++ b/homeassistant/components/homekit_controller/light.py
@@ -10,8 +10,10 @@ from propcache import cached_property
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
- ATTR_COLOR_TEMP,
+ ATTR_COLOR_TEMP_KELVIN,
ATTR_HS_COLOR,
+ DEFAULT_MAX_KELVIN,
+ DEFAULT_MIN_KELVIN,
ColorMode,
LightEntity,
)
@@ -53,11 +55,19 @@ async def async_setup_entry(
class HomeKitLight(HomeKitEntity, LightEntity):
"""Representation of a Homekit light."""
+ _attr_max_color_temp_kelvin = DEFAULT_MAX_KELVIN
+ _attr_min_color_temp_kelvin = DEFAULT_MIN_KELVIN
+
@callback
def _async_reconfigure(self) -> None:
"""Reconfigure entity."""
self._async_clear_property_cache(
- ("supported_features", "min_mireds", "max_mireds", "supported_color_modes")
+ (
+ "supported_features",
+ "min_color_temp_kelvin",
+ "max_color_temp_kelvin",
+ "supported_color_modes",
+ )
)
super()._async_reconfigure()
@@ -90,25 +100,35 @@ class HomeKitLight(HomeKitEntity, LightEntity):
)
@cached_property
- def min_mireds(self) -> int:
- """Return minimum supported color temperature."""
+ def max_color_temp_kelvin(self) -> int:
+ """Return the coldest color_temp_kelvin that this light supports."""
if not self.service.has(CharacteristicsTypes.COLOR_TEMPERATURE):
- return super().min_mireds
- min_value = self.service[CharacteristicsTypes.COLOR_TEMPERATURE].minValue
- return int(min_value) if min_value else super().min_mireds
+ return DEFAULT_MAX_KELVIN
+ min_value_mireds = self.service[CharacteristicsTypes.COLOR_TEMPERATURE].minValue
+ return (
+ color_util.color_temperature_mired_to_kelvin(min_value_mireds)
+ if min_value_mireds
+ else DEFAULT_MAX_KELVIN
+ )
@cached_property
- def max_mireds(self) -> int:
- """Return the maximum color temperature."""
+ def min_color_temp_kelvin(self) -> int:
+ """Return the warmest color_temp_kelvin that this light supports."""
if not self.service.has(CharacteristicsTypes.COLOR_TEMPERATURE):
- return super().max_mireds
- max_value = self.service[CharacteristicsTypes.COLOR_TEMPERATURE].maxValue
- return int(max_value) if max_value else super().max_mireds
+ return DEFAULT_MIN_KELVIN
+ max_value_mireds = self.service[CharacteristicsTypes.COLOR_TEMPERATURE].maxValue
+ return (
+ color_util.color_temperature_mired_to_kelvin(max_value_mireds)
+ if max_value_mireds
+ else DEFAULT_MIN_KELVIN
+ )
@property
- def color_temp(self) -> int:
- """Return the color temperature."""
- return self.service.value(CharacteristicsTypes.COLOR_TEMPERATURE)
+ def color_temp_kelvin(self) -> int:
+ """Return the color temperature value in Kelvin."""
+ return color_util.color_temperature_mired_to_kelvin(
+ self.service.value(CharacteristicsTypes.COLOR_TEMPERATURE)
+ )
@property
def color_mode(self) -> str:
@@ -153,7 +173,7 @@ class HomeKitLight(HomeKitEntity, LightEntity):
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the specified light on."""
hs_color = kwargs.get(ATTR_HS_COLOR)
- temperature = kwargs.get(ATTR_COLOR_TEMP)
+ temperature_kelvin = kwargs.get(ATTR_COLOR_TEMP_KELVIN)
brightness = kwargs.get(ATTR_BRIGHTNESS)
characteristics: dict[str, Any] = {}
@@ -167,19 +187,18 @@ class HomeKitLight(HomeKitEntity, LightEntity):
# does not support both, temperature will win. This is not
# expected to happen in the UI, but it is possible via a manual
# service call.
- if temperature is not None:
+ if temperature_kelvin is not None:
if self.service.has(CharacteristicsTypes.COLOR_TEMPERATURE):
- characteristics[CharacteristicsTypes.COLOR_TEMPERATURE] = int(
- temperature
+ characteristics[CharacteristicsTypes.COLOR_TEMPERATURE] = (
+ color_util.color_temperature_kelvin_to_mired(temperature_kelvin)
)
+
elif hs_color is None:
# Some HomeKit devices implement color temperature with HS
# since the spec "technically" does not permit the COLOR_TEMPERATURE
# characteristic and the HUE and SATURATION characteristics to be
# present at the same time.
- hue_sat = color_util.color_temperature_to_hs(
- color_util.color_temperature_mired_to_kelvin(temperature)
- )
+ hue_sat = color_util.color_temperature_to_hs(temperature_kelvin)
characteristics[CharacteristicsTypes.HUE] = hue_sat[0]
characteristics[CharacteristicsTypes.SATURATION] = hue_sat[1]
diff --git a/homeassistant/components/homekit_controller/manifest.json b/homeassistant/components/homekit_controller/manifest.json
index cddd61a12c1..b7c82b9fd51 100644
--- a/homeassistant/components/homekit_controller/manifest.json
+++ b/homeassistant/components/homekit_controller/manifest.json
@@ -14,6 +14,6 @@
"documentation": "https://www.home-assistant.io/integrations/homekit_controller",
"iot_class": "local_push",
"loggers": ["aiohomekit", "commentjson"],
- "requirements": ["aiohomekit==3.2.6"],
+ "requirements": ["aiohomekit==3.2.7"],
"zeroconf": ["_hap._tcp.local.", "_hap._udp.local."]
}
diff --git a/homeassistant/components/homematic/climate.py b/homeassistant/components/homematic/climate.py
index 2be28487cbb..6e16e16ba99 100644
--- a/homeassistant/components/homematic/climate.py
+++ b/homeassistant/components/homematic/climate.py
@@ -63,7 +63,6 @@ class HMThermostat(HMDevice, ClimateEntity):
| ClimateEntityFeature.TURN_ON
)
_attr_temperature_unit = UnitOfTemperature.CELSIUS
- _enable_turn_on_off_backwards_compatibility = False
@property
def hvac_mode(self) -> HVACMode:
diff --git a/homeassistant/components/homematic/light.py b/homeassistant/components/homematic/light.py
index b05cc6a46d6..838cdc9c3c3 100644
--- a/homeassistant/components/homematic/light.py
+++ b/homeassistant/components/homematic/light.py
@@ -6,7 +6,7 @@ from typing import Any
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
- ATTR_COLOR_TEMP,
+ ATTR_COLOR_TEMP_KELVIN,
ATTR_EFFECT,
ATTR_HS_COLOR,
ATTR_TRANSITION,
@@ -17,10 +17,14 @@ from homeassistant.components.light import (
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
+from homeassistant.util import color as color_util
from .const import ATTR_DISCOVER_DEVICES
from .entity import HMDevice
+MAX_MIREDS = 500 # 2000 K
+MIN_MIREDS = 153 # 6500 K
+
def setup_platform(
hass: HomeAssistant,
@@ -43,6 +47,9 @@ def setup_platform(
class HMLight(HMDevice, LightEntity):
"""Representation of a Homematic light."""
+ _attr_min_color_temp_kelvin = 2000 # 500 Mireds
+ _attr_max_color_temp_kelvin = 6500 # 153 Mireds
+
@property
def brightness(self):
"""Return the brightness of this light between 0..255."""
@@ -99,12 +106,14 @@ class HMLight(HMDevice, LightEntity):
return hue * 360.0, sat * 100.0
@property
- def color_temp(self):
- """Return the color temp in mireds [int]."""
+ def color_temp_kelvin(self) -> int | None:
+ """Return the color temperature value in Kelvin."""
if ColorMode.COLOR_TEMP not in self.supported_color_modes:
return None
hm_color_temp = self._hmdevice.get_color_temp(self._channel)
- return self.max_mireds - (self.max_mireds - self.min_mireds) * hm_color_temp
+ return color_util.color_temperature_mired_to_kelvin(
+ MAX_MIREDS - (MAX_MIREDS - MIN_MIREDS) * hm_color_temp
+ )
@property
def effect_list(self):
@@ -130,7 +139,7 @@ class HMLight(HMDevice, LightEntity):
self._hmdevice.set_level(percent_bright, self._channel)
elif (
ATTR_HS_COLOR not in kwargs
- and ATTR_COLOR_TEMP not in kwargs
+ and ATTR_COLOR_TEMP_KELVIN not in kwargs
and ATTR_EFFECT not in kwargs
):
self._hmdevice.on(self._channel)
@@ -141,10 +150,11 @@ class HMLight(HMDevice, LightEntity):
saturation=kwargs[ATTR_HS_COLOR][1] / 100.0,
channel=self._channel,
)
- if ATTR_COLOR_TEMP in kwargs:
- hm_temp = (self.max_mireds - kwargs[ATTR_COLOR_TEMP]) / (
- self.max_mireds - self.min_mireds
+ if ATTR_COLOR_TEMP_KELVIN in kwargs:
+ mireds = color_util.color_temperature_kelvin_to_mired(
+ kwargs[ATTR_COLOR_TEMP_KELVIN]
)
+ hm_temp = (MAX_MIREDS - mireds) / (MAX_MIREDS - MIN_MIREDS)
self._hmdevice.set_color_temp(hm_temp)
if ATTR_EFFECT in kwargs:
self._hmdevice.set_effect(kwargs[ATTR_EFFECT])
diff --git a/homeassistant/components/homematic/manifest.json b/homeassistant/components/homematic/manifest.json
index 9c67a5da0b2..749bd7b44e8 100644
--- a/homeassistant/components/homematic/manifest.json
+++ b/homeassistant/components/homematic/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/homematic",
"iot_class": "local_push",
"loggers": ["pyhomematic"],
+ "quality_scale": "legacy",
"requirements": ["pyhomematic==0.1.77"]
}
diff --git a/homeassistant/components/homematicip_cloud/climate.py b/homeassistant/components/homematicip_cloud/climate.py
index f6a69f50770..e7132fac83c 100644
--- a/homeassistant/components/homematicip_cloud/climate.py
+++ b/homeassistant/components/homematicip_cloud/climate.py
@@ -81,7 +81,6 @@ class HomematicipHeatingGroup(HomematicipGenericEntity, ClimateEntity):
ClimateEntityFeature.PRESET_MODE | ClimateEntityFeature.TARGET_TEMPERATURE
)
_attr_temperature_unit = UnitOfTemperature.CELSIUS
- _enable_turn_on_off_backwards_compatibility = False
def __init__(self, hap: HomematicipHAP, device: AsyncHeatingGroup) -> None:
"""Initialize heating group."""
diff --git a/homeassistant/components/homematicip_cloud/manifest.json b/homeassistant/components/homematicip_cloud/manifest.json
index b3e7eb9a72a..a44d0586952 100644
--- a/homeassistant/components/homematicip_cloud/manifest.json
+++ b/homeassistant/components/homematicip_cloud/manifest.json
@@ -6,6 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/homematicip_cloud",
"iot_class": "cloud_push",
"loggers": ["homematicip"],
- "quality_scale": "silver",
- "requirements": ["homematicip==1.1.2"]
+ "requirements": ["homematicip==1.1.5"]
}
diff --git a/homeassistant/components/homewizard/button.py b/homeassistant/components/homewizard/button.py
index a9cc19d72a7..7b05cb95271 100644
--- a/homeassistant/components/homewizard/button.py
+++ b/homeassistant/components/homewizard/button.py
@@ -10,6 +10,8 @@ from .coordinator import HWEnergyDeviceUpdateCoordinator
from .entity import HomeWizardEntity
from .helpers import homewizard_exception_handler
+PARALLEL_UPDATES = 1
+
async def async_setup_entry(
hass: HomeAssistant,
diff --git a/homeassistant/components/homewizard/config_flow.py b/homeassistant/components/homewizard/config_flow.py
index d52e53cf39b..a6e4356328e 100644
--- a/homeassistant/components/homewizard/config_flow.py
+++ b/homeassistant/components/homewizard/config_flow.py
@@ -6,16 +6,18 @@ from collections.abc import Mapping
import logging
from typing import Any, NamedTuple
-from homewizard_energy import HomeWizardEnergy
+from homewizard_energy import HomeWizardEnergyV1
from homewizard_energy.errors import DisabledError, RequestError, UnsupportedError
-from homewizard_energy.models import Device
-from voluptuous import Required, Schema
+from homewizard_energy.v1.models import Device
+import voluptuous as vol
from homeassistant.components import onboarding, zeroconf
+from homeassistant.components.dhcp import DhcpServiceInfo
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_IP_ADDRESS, CONF_PATH
from homeassistant.data_entry_flow import AbortFlow
from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers.selector import TextSelector
from .const import (
CONF_API_ENABLED,
@@ -68,11 +70,11 @@ class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN):
user_input = user_input or {}
return self.async_show_form(
step_id="user",
- data_schema=Schema(
+ data_schema=vol.Schema(
{
- Required(
+ vol.Required(
CONF_IP_ADDRESS, default=user_input.get(CONF_IP_ADDRESS)
- ): str,
+ ): TextSelector(),
}
),
errors=errors,
@@ -110,6 +112,32 @@ class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN):
return await self.async_step_discovery_confirm()
+ async def async_step_dhcp(
+ self, discovery_info: DhcpServiceInfo
+ ) -> ConfigFlowResult:
+ """Handle dhcp discovery to update existing entries.
+
+ This flow is triggered only by DHCP discovery of known devices.
+ """
+ try:
+ device = await self._async_try_connect(discovery_info.ip)
+ except RecoverableError as ex:
+ _LOGGER.error(ex)
+ return self.async_abort(reason="unknown")
+
+ await self.async_set_unique_id(
+ f"{device.product_type}_{discovery_info.macaddress}"
+ )
+
+ self._abort_if_unique_id_configured(
+ updates={CONF_IP_ADDRESS: discovery_info.ip}
+ )
+
+ # This situation should never happen, as Home Assistant will only
+ # send updates for existing entries. In case it does, we'll just
+ # abort the flow with an unknown error.
+ return self.async_abort(reason="unknown")
+
async def async_step_discovery_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
@@ -170,6 +198,43 @@ class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_show_form(step_id="reauth_confirm", 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] = {}
+ if user_input:
+ try:
+ device_info = await self._async_try_connect(user_input[CONF_IP_ADDRESS])
+ except RecoverableError as ex:
+ _LOGGER.error(ex)
+ errors = {"base": ex.error_code}
+ else:
+ await self.async_set_unique_id(
+ f"{device_info.product_type}_{device_info.serial}"
+ )
+ self._abort_if_unique_id_mismatch(reason="wrong_device")
+ return self.async_update_reload_and_abort(
+ 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(
+ {
+ vol.Required(
+ CONF_IP_ADDRESS,
+ default=reconfigure_entry.data.get(CONF_IP_ADDRESS),
+ ): TextSelector(),
+ }
+ ),
+ description_placeholders={
+ "title": reconfigure_entry.title,
+ },
+ errors=errors,
+ )
+
@staticmethod
async def _async_try_connect(ip_address: str) -> Device:
"""Try to connect.
@@ -177,7 +242,7 @@ class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN):
Make connection with device to test the connection
and to get info for unique_id.
"""
- energy_api = HomeWizardEnergy(ip_address)
+ energy_api = HomeWizardEnergyV1(ip_address)
try:
return await energy_api.device()
diff --git a/homeassistant/components/homewizard/const.py b/homeassistant/components/homewizard/const.py
index 8cee8350268..809ecc1416b 100644
--- a/homeassistant/components/homewizard/const.py
+++ b/homeassistant/components/homewizard/const.py
@@ -6,7 +6,7 @@ from dataclasses import dataclass
from datetime import timedelta
import logging
-from homewizard_energy.models import Data, Device, State, System
+from homewizard_energy.v1.models import Data, Device, State, System
from homeassistant.const import Platform
diff --git a/homeassistant/components/homewizard/coordinator.py b/homeassistant/components/homewizard/coordinator.py
index 61b304eb39c..8f5045d3b94 100644
--- a/homeassistant/components/homewizard/coordinator.py
+++ b/homeassistant/components/homewizard/coordinator.py
@@ -4,10 +4,10 @@ from __future__ import annotations
import logging
-from homewizard_energy import HomeWizardEnergy
-from homewizard_energy.const import SUPPORTS_IDENTIFY, SUPPORTS_STATE
+from homewizard_energy import HomeWizardEnergyV1
from homewizard_energy.errors import DisabledError, RequestError, UnsupportedError
-from homewizard_energy.models import Device
+from homewizard_energy.v1.const import SUPPORTS_IDENTIFY, SUPPORTS_STATE
+from homewizard_energy.v1.models import Device
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_IP_ADDRESS
@@ -23,7 +23,7 @@ _LOGGER = logging.getLogger(__name__)
class HWEnergyDeviceUpdateCoordinator(DataUpdateCoordinator[DeviceResponseEntry]):
"""Gather data for the energy device."""
- api: HomeWizardEnergy
+ api: HomeWizardEnergyV1
api_disabled: bool = False
_unsupported_error: bool = False
@@ -36,7 +36,7 @@ class HWEnergyDeviceUpdateCoordinator(DataUpdateCoordinator[DeviceResponseEntry]
) -> None:
"""Initialize update coordinator."""
super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=UPDATE_INTERVAL)
- self.api = HomeWizardEnergy(
+ self.api = HomeWizardEnergyV1(
self.config_entry.data[CONF_IP_ADDRESS],
clientsession=async_get_clientsession(hass),
)
@@ -66,7 +66,9 @@ class HWEnergyDeviceUpdateCoordinator(DataUpdateCoordinator[DeviceResponseEntry]
)
except RequestError as ex:
- raise UpdateFailed(ex) from ex
+ raise UpdateFailed(
+ ex, translation_domain=DOMAIN, translation_key="communication_error"
+ ) from ex
except DisabledError as ex:
if not self.api_disabled:
@@ -79,7 +81,9 @@ class HWEnergyDeviceUpdateCoordinator(DataUpdateCoordinator[DeviceResponseEntry]
self.config_entry.entry_id
)
- raise UpdateFailed(ex) from ex
+ raise UpdateFailed(
+ ex, translation_domain=DOMAIN, translation_key="api_disabled"
+ ) from ex
self.api_disabled = False
diff --git a/homeassistant/components/homewizard/manifest.json b/homeassistant/components/homewizard/manifest.json
index 65672903eb8..83937809b60 100644
--- a/homeassistant/components/homewizard/manifest.json
+++ b/homeassistant/components/homewizard/manifest.json
@@ -3,10 +3,15 @@
"name": "HomeWizard Energy",
"codeowners": ["@DCSBL"],
"config_flow": true,
+ "dhcp": [
+ {
+ "registered_devices": true
+ }
+ ],
"documentation": "https://www.home-assistant.io/integrations/homewizard",
"iot_class": "local_polling",
"loggers": ["homewizard_energy"],
"quality_scale": "platinum",
- "requirements": ["python-homewizard-energy==v6.3.0"],
+ "requirements": ["python-homewizard-energy==v7.0.1"],
"zeroconf": ["_hwenergy._tcp.local."]
}
diff --git a/homeassistant/components/homewizard/number.py b/homeassistant/components/homewizard/number.py
index 1af77859a0f..1ed4c642f6b 100644
--- a/homeassistant/components/homewizard/number.py
+++ b/homeassistant/components/homewizard/number.py
@@ -13,6 +13,8 @@ from .coordinator import HWEnergyDeviceUpdateCoordinator
from .entity import HomeWizardEntity
from .helpers import homewizard_exception_handler
+PARALLEL_UPDATES = 1
+
async def async_setup_entry(
hass: HomeAssistant,
@@ -62,4 +64,4 @@ class HWEnergyNumberEntity(HomeWizardEntity, NumberEntity):
or (brightness := self.coordinator.data.state.brightness) is None
):
return None
- return brightness_to_value((0, 100), brightness)
+ return round(brightness_to_value((0, 100), brightness))
diff --git a/homeassistant/components/homewizard/quality_scale.yaml b/homeassistant/components/homewizard/quality_scale.yaml
new file mode 100644
index 00000000000..423bc4dea49
--- /dev/null
+++ b/homeassistant/components/homewizard/quality_scale.yaml
@@ -0,0 +1,81 @@
+rules:
+ # Bronze
+ action-setup:
+ status: exempt
+ comment: |
+ The integration does not provide any 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: |
+ The integration does not provide any 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
+ action-exceptions: done
+ config-entry-unloading: done
+ docs-configuration-parameters:
+ status: exempt
+ comment: |
+ This integration does not have an options flow.
+ 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: 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 per configuration entry.
+ 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: |
+ This integration does not raise any repairable issues.
+ stale-devices:
+ status: exempt
+ comment: |
+ This integration connect to a single device per configuration entry.
+
+ # Platinum
+ async-dependency: done
+ inject-websession: done
+ strict-typing: done
diff --git a/homeassistant/components/homewizard/sensor.py b/homeassistant/components/homewizard/sensor.py
index 57071875edb..8b822bffc50 100644
--- a/homeassistant/components/homewizard/sensor.py
+++ b/homeassistant/components/homewizard/sensor.py
@@ -6,7 +6,7 @@ from collections.abc import Callable
from dataclasses import dataclass
from typing import Final
-from homewizard_energy.models import Data, ExternalDevice
+from homewizard_energy.v1.models import Data, ExternalDevice
from homeassistant.components.sensor import (
DEVICE_CLASS_UNITS,
@@ -27,6 +27,7 @@ from homeassistant.const import (
UnitOfPower,
UnitOfReactivePower,
UnitOfVolume,
+ UnitOfVolumeFlowRate,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
@@ -565,7 +566,7 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = (
HomeWizardSensorEntityDescription(
key="active_liter_lpm",
translation_key="active_liter_lpm",
- native_unit_of_measurement="l/min",
+ 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,
diff --git a/homeassistant/components/homewizard/strings.json b/homeassistant/components/homewizard/strings.json
index 751c1ec450d..4309664c4c8 100644
--- a/homeassistant/components/homewizard/strings.json
+++ b/homeassistant/components/homewizard/strings.json
@@ -6,6 +6,9 @@
"description": "Enter the IP address of your HomeWizard Energy device to integrate with Home Assistant.",
"data": {
"ip_address": "[%key:common::config_flow::data::ip%]"
+ },
+ "data_description": {
+ "ip_address": "The IP address of your HomeWizard Energy device."
}
},
"discovery_confirm": {
@@ -14,10 +17,19 @@
},
"reauth_confirm": {
"description": "The local API is disabled. Go to the HomeWizard Energy app and enable the API in the device settings."
+ },
+ "reconfigure": {
+ "description": "Update configuration for {title}.",
+ "data": {
+ "ip_address": "[%key:common::config_flow::data::ip%]"
+ },
+ "data_description": {
+ "ip_address": "[%key:component::homewizard::config::step::user::data_description::ip_address%]"
+ }
}
},
"error": {
- "api_not_enabled": "The API is not enabled. Enable API in the HomeWizard Energy App under settings",
+ "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"
},
"abort": {
@@ -26,7 +38,9 @@
"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_successful": "Enabling API was successful",
+ "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
+ "wrong_device": "The configured device is not the same found on this IP address."
}
},
"entity": {
@@ -120,7 +134,7 @@
},
"exceptions": {
"api_disabled": {
- "message": "The local API of the HomeWizard device is disabled"
+ "message": "The local API is disabled."
},
"communication_error": {
"message": "An error occurred while communicating with HomeWizard device"
diff --git a/homeassistant/components/homewizard/switch.py b/homeassistant/components/homewizard/switch.py
index 14c6e0778f1..aa0af17f578 100644
--- a/homeassistant/components/homewizard/switch.py
+++ b/homeassistant/components/homewizard/switch.py
@@ -6,7 +6,7 @@ from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from typing import Any
-from homewizard_energy import HomeWizardEnergy
+from homewizard_energy import HomeWizardEnergyV1
from homeassistant.components.switch import (
SwitchDeviceClass,
@@ -23,6 +23,8 @@ from .coordinator import HWEnergyDeviceUpdateCoordinator
from .entity import HomeWizardEntity
from .helpers import homewizard_exception_handler
+PARALLEL_UPDATES = 1
+
@dataclass(frozen=True, kw_only=True)
class HomeWizardSwitchEntityDescription(SwitchEntityDescription):
@@ -31,7 +33,7 @@ class HomeWizardSwitchEntityDescription(SwitchEntityDescription):
available_fn: Callable[[DeviceResponseEntry], bool]
create_fn: Callable[[HWEnergyDeviceUpdateCoordinator], bool]
is_on_fn: Callable[[DeviceResponseEntry], bool | None]
- set_fn: Callable[[HomeWizardEnergy, bool], Awaitable[Any]]
+ set_fn: Callable[[HomeWizardEnergyV1, bool], Awaitable[Any]]
SWITCHES = [
diff --git a/homeassistant/components/honeywell/__init__.py b/homeassistant/components/honeywell/__init__.py
index 5a4d6374304..eb89ba2a681 100644
--- a/homeassistant/components/honeywell/__init__.py
+++ b/homeassistant/components/honeywell/__init__.py
@@ -22,14 +22,16 @@ from .const import (
)
UPDATE_LOOP_SLEEP_TIME = 5
-PLATFORMS = [Platform.CLIMATE, Platform.SENSOR, Platform.SWITCH]
+PLATFORMS = [Platform.CLIMATE, Platform.HUMIDIFIER, Platform.SENSOR, Platform.SWITCH]
MIGRATE_OPTIONS_KEYS = {CONF_COOL_AWAY_TEMPERATURE, CONF_HEAT_AWAY_TEMPERATURE}
+type HoneywellConfigEntry = ConfigEntry[HoneywellData]
+
@callback
def _async_migrate_data_to_options(
- hass: HomeAssistant, config_entry: ConfigEntry
+ hass: HomeAssistant, config_entry: HoneywellConfigEntry
) -> None:
if not MIGRATE_OPTIONS_KEYS.intersection(config_entry.data):
return
@@ -45,7 +47,9 @@ def _async_migrate_data_to_options(
)
-async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
+async def async_setup_entry(
+ hass: HomeAssistant, config_entry: HoneywellConfigEntry
+) -> bool:
"""Set up the Honeywell thermostat."""
_async_migrate_data_to_options(hass, config_entry)
@@ -84,8 +88,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
if len(devices) == 0:
_LOGGER.debug("No devices found")
return False
- data = HoneywellData(config_entry.entry_id, client, devices)
- hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = data
+ config_entry.runtime_data = HoneywellData(config_entry.entry_id, client, devices)
await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
config_entry.async_on_unload(config_entry.add_update_listener(update_listener))
@@ -93,19 +96,18 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
return True
-async def update_listener(hass: HomeAssistant, config_entry: ConfigEntry) -> None:
+async def update_listener(
+ hass: HomeAssistant, config_entry: HoneywellConfigEntry
+) -> None:
"""Update listener."""
await hass.config_entries.async_reload(config_entry.entry_id)
-async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
+async def async_unload_entry(
+ hass: HomeAssistant, config_entry: HoneywellConfigEntry
+) -> bool:
"""Unload the config and platforms."""
- unload_ok = await hass.config_entries.async_unload_platforms(
- config_entry, PLATFORMS
- )
- if unload_ok:
- hass.data[DOMAIN].pop(config_entry.entry_id)
- return unload_ok
+ return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS)
@dataclass
diff --git a/homeassistant/components/honeywell/climate.py b/homeassistant/components/honeywell/climate.py
index 98cbae4eb7e..7398ada23be 100644
--- a/homeassistant/components/honeywell/climate.py
+++ b/homeassistant/components/honeywell/climate.py
@@ -31,7 +31,6 @@ 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.exceptions import HomeAssistantError, ServiceValidationError
@@ -40,7 +39,7 @@ from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util.unit_conversion import TemperatureConverter
-from . import HoneywellData
+from . import HoneywellConfigEntry, HoneywellData
from .const import (
_LOGGER,
CONF_COOL_AWAY_TEMPERATURE,
@@ -97,13 +96,15 @@ SCAN_INTERVAL = datetime.timedelta(seconds=30)
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: HoneywellConfigEntry,
+ async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Honeywell thermostat."""
cool_away_temp = entry.options.get(CONF_COOL_AWAY_TEMPERATURE)
heat_away_temp = entry.options.get(CONF_HEAT_AWAY_TEMPERATURE)
- data: HoneywellData = hass.data[DOMAIN][entry.entry_id]
+ data = entry.runtime_data
_async_migrate_unique_id(hass, data.devices)
async_add_entities(
[
@@ -131,7 +132,7 @@ def _async_migrate_unique_id(
def remove_stale_devices(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ config_entry: HoneywellConfigEntry,
devices: dict[str, SomeComfortDevice],
) -> None:
"""Remove stale devices from device registry."""
@@ -164,7 +165,6 @@ class HoneywellUSThermostat(ClimateEntity):
_attr_has_entity_name = True
_attr_name = None
_attr_translation_key = "honeywell"
- _enable_turn_on_off_backwards_compatibility = False
def __init__(
self,
@@ -398,7 +398,7 @@ class HoneywellUSThermostat(ClimateEntity):
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="temp_failed_value",
- translation_placeholders={"temp": temperature},
+ translation_placeholders={"temperature": temperature},
) from err
async def async_set_temperature(self, **kwargs: Any) -> None:
@@ -422,7 +422,7 @@ class HoneywellUSThermostat(ClimateEntity):
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="temp_failed_value",
- translation_placeholders={"temp": str(temperature)},
+ translation_placeholders={"temperature": str(temperature)},
) from err
async def async_set_fan_mode(self, fan_mode: str) -> None:
diff --git a/homeassistant/components/honeywell/diagnostics.py b/homeassistant/components/honeywell/diagnostics.py
index 35624c8fc39..b266e06d110 100644
--- a/homeassistant/components/honeywell/diagnostics.py
+++ b/homeassistant/components/honeywell/diagnostics.py
@@ -4,19 +4,17 @@ from __future__ import annotations
from typing import Any
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from . import HoneywellData
-from .const import DOMAIN
+from . import HoneywellConfigEntry
async def async_get_config_entry_diagnostics(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ config_entry: HoneywellConfigEntry,
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
- honeywell: HoneywellData = hass.data[DOMAIN][config_entry.entry_id]
+ honeywell = config_entry.runtime_data
return {
f"Device {device}": {
diff --git a/homeassistant/components/honeywell/humidifier.py b/homeassistant/components/honeywell/humidifier.py
new file mode 100644
index 00000000000..e94ba465c30
--- /dev/null
+++ b/homeassistant/components/honeywell/humidifier.py
@@ -0,0 +1,136 @@
+"""Support for Honeywell (de)humidifiers."""
+
+from __future__ import annotations
+
+from collections.abc import Callable
+from dataclasses import dataclass
+from typing import Any
+
+from aiosomecomfort.device import Device
+
+from homeassistant.components.humidifier import (
+ HumidifierDeviceClass,
+ HumidifierEntity,
+ HumidifierEntityDescription,
+)
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.device_registry import DeviceInfo
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+
+from . import HoneywellConfigEntry
+from .const import DOMAIN
+
+HUMIDIFIER_KEY = "humidifier"
+DEHUMIDIFIER_KEY = "dehumidifier"
+
+
+@dataclass(frozen=True, kw_only=True)
+class HoneywellHumidifierEntityDescription(HumidifierEntityDescription):
+ """Describes a Honeywell humidifier entity."""
+
+ current_humidity: Callable[[Device], Any]
+ current_set_humidity: Callable[[Device], Any]
+ max_humidity: Callable[[Device], Any]
+ min_humidity: Callable[[Device], Any]
+ set_humidity: Callable[[Device, Any], Any]
+ mode: Callable[[Device], Any]
+ off: Callable[[Device], Any]
+ on: Callable[[Device], Any]
+
+
+HUMIDIFIERS: dict[str, HoneywellHumidifierEntityDescription] = {
+ "Humidifier": HoneywellHumidifierEntityDescription(
+ key=HUMIDIFIER_KEY,
+ translation_key=HUMIDIFIER_KEY,
+ current_humidity=lambda device: device.current_humidity,
+ set_humidity=lambda device, humidity: device.set_humidifier_setpoint(humidity),
+ min_humidity=lambda device: device.humidifier_lower_limit,
+ max_humidity=lambda device: device.humidifier_upper_limit,
+ current_set_humidity=lambda device: device.humidifier_setpoint,
+ mode=lambda device: device.humidifier_mode,
+ off=lambda device: device.set_humidifier_off(),
+ on=lambda device: device.set_humidifier_auto(),
+ device_class=HumidifierDeviceClass.HUMIDIFIER,
+ ),
+ "Dehumidifier": HoneywellHumidifierEntityDescription(
+ key=DEHUMIDIFIER_KEY,
+ translation_key=DEHUMIDIFIER_KEY,
+ current_humidity=lambda device: device.current_humidity,
+ set_humidity=lambda device, humidity: device.set_dehumidifier_setpoint(
+ humidity
+ ),
+ min_humidity=lambda device: device.dehumidifier_lower_limit,
+ max_humidity=lambda device: device.dehumidifier_upper_limit,
+ current_set_humidity=lambda device: device.dehumidifier_setpoint,
+ mode=lambda device: device.dehumidifier_mode,
+ off=lambda device: device.set_dehumidifier_off(),
+ on=lambda device: device.set_dehumidifier_auto(),
+ device_class=HumidifierDeviceClass.DEHUMIDIFIER,
+ ),
+}
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ config_entry: HoneywellConfigEntry,
+ async_add_entities: AddEntitiesCallback,
+) -> None:
+ """Set up Honeywell (de)humidifier dynamically."""
+ data = config_entry.runtime_data
+ entities: list = []
+ for device in data.devices.values():
+ if device.has_humidifier:
+ entities.append(HoneywellHumidifier(device, HUMIDIFIERS["Humidifier"]))
+ if device.has_dehumidifier:
+ entities.append(HoneywellHumidifier(device, HUMIDIFIERS["Dehumidifier"]))
+
+ async_add_entities(entities)
+
+
+class HoneywellHumidifier(HumidifierEntity):
+ """Representation of a Honeywell US (De)Humidifier."""
+
+ entity_description: HoneywellHumidifierEntityDescription
+ _attr_has_entity_name = True
+
+ def __init__(
+ self, device: Device, description: HoneywellHumidifierEntityDescription
+ ) -> None:
+ """Initialize the (De)Humidifier."""
+ self._device = device
+ self.entity_description = description
+ self._attr_unique_id = f"{device.deviceid}_{description.key}"
+ self._attr_min_humidity = description.min_humidity(device)
+ self._attr_max_humidity = description.max_humidity(device)
+ self._attr_device_info = DeviceInfo(
+ identifiers={(DOMAIN, device.deviceid)},
+ name=device.name,
+ manufacturer="Honeywell",
+ )
+
+ @property
+ def is_on(self) -> bool:
+ """Return the device is on or off."""
+ return self.entity_description.mode(self._device) != 0
+
+ @property
+ def target_humidity(self) -> int | None:
+ """Return the humidity we try to reach."""
+ return self.entity_description.current_set_humidity(self._device)
+
+ @property
+ def current_humidity(self) -> int | None:
+ """Return the current humidity."""
+ return self.entity_description.current_humidity(self._device)
+
+ async def async_turn_on(self, **kwargs: Any) -> None:
+ """Turn the device on."""
+ await self.entity_description.on(self._device)
+
+ async def async_turn_off(self, **kwargs: Any) -> None:
+ """Turn the device off."""
+ await self.entity_description.off(self._device)
+
+ async def async_set_humidity(self, humidity: int) -> None:
+ """Set new target humidity."""
+ await self.entity_description.set_humidity(self._device, humidity)
diff --git a/homeassistant/components/honeywell/manifest.json b/homeassistant/components/honeywell/manifest.json
index d0f0c8281f7..4a50e326965 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.25"]
+ "requirements": ["AIOSomecomfort==0.0.28"]
}
diff --git a/homeassistant/components/honeywell/sensor.py b/homeassistant/components/honeywell/sensor.py
index 31ed8d646c5..a9109d5d557 100644
--- a/homeassistant/components/honeywell/sensor.py
+++ b/homeassistant/components/honeywell/sensor.py
@@ -14,14 +14,13 @@ from homeassistant.components.sensor import (
SensorEntityDescription,
SensorStateClass,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import PERCENTAGE, UnitOfTemperature
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 . import HoneywellData
+from . import HoneywellConfigEntry
from .const import DOMAIN
OUTDOOR_TEMPERATURE_STATUS_KEY = "outdoor_temperature"
@@ -81,11 +80,11 @@ SENSOR_TYPES: tuple[HoneywellSensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ config_entry: HoneywellConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Honeywell thermostat."""
- data: HoneywellData = hass.data[DOMAIN][config_entry.entry_id]
+ data = config_entry.runtime_data
async_add_entities(
HoneywellSensor(device, description)
diff --git a/homeassistant/components/honeywell/strings.json b/homeassistant/components/honeywell/strings.json
index a64f1a6fce0..2538e7101a1 100644
--- a/homeassistant/components/honeywell/strings.json
+++ b/homeassistant/components/honeywell/strings.json
@@ -61,6 +61,14 @@
}
}
}
+ },
+ "humidifier": {
+ "humidifier": {
+ "name": "[%key:component::humidifier::title%]"
+ },
+ "dehumidifier": {
+ "name": "[%key:component::humidifier::entity_component::dehumidifier::name%]"
+ }
}
},
"exceptions": {
diff --git a/homeassistant/components/honeywell/switch.py b/homeassistant/components/honeywell/switch.py
index b90dd339593..3602dd1ba10 100644
--- a/homeassistant/components/honeywell/switch.py
+++ b/homeassistant/components/honeywell/switch.py
@@ -12,13 +12,12 @@ from homeassistant.components.switch import (
SwitchEntity,
SwitchEntityDescription,
)
-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 HoneywellData
+from . import HoneywellConfigEntry, HoneywellData
from .const import DOMAIN
EMERGENCY_HEAT_KEY = "emergency_heat"
@@ -34,11 +33,11 @@ SWITCH_TYPES: tuple[SwitchEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ config_entry: HoneywellConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Honeywell switches."""
- data: HoneywellData = hass.data[DOMAIN][config_entry.entry_id]
+ data = config_entry.runtime_data
async_add_entities(
HoneywellSwitch(data, device, description)
for device in data.devices.values()
diff --git a/homeassistant/components/horizon/manifest.json b/homeassistant/components/horizon/manifest.json
index d1280a6fe65..d30e2f39e34 100644
--- a/homeassistant/components/horizon/manifest.json
+++ b/homeassistant/components/horizon/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/horizon",
"iot_class": "local_polling",
"loggers": ["horimote"],
+ "quality_scale": "legacy",
"requirements": ["horimote==0.4.1"]
}
diff --git a/homeassistant/components/hp_ilo/manifest.json b/homeassistant/components/hp_ilo/manifest.json
index 378a9ac1865..9f2dfb21783 100644
--- a/homeassistant/components/hp_ilo/manifest.json
+++ b/homeassistant/components/hp_ilo/manifest.json
@@ -4,5 +4,6 @@
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/hp_ilo",
"iot_class": "local_polling",
+ "quality_scale": "legacy",
"requirements": ["python-hpilo==4.4.3"]
}
diff --git a/homeassistant/components/html5/strings.json b/homeassistant/components/html5/strings.json
index 40bdbb36261..2c68223581a 100644
--- a/homeassistant/components/html5/strings.json
+++ b/homeassistant/components/html5/strings.json
@@ -7,7 +7,7 @@
"vapid_prv_key": "VAPID private key"
},
"data_description": {
- "vapid_email": "Email to use for html5 push notifications.",
+ "vapid_email": "This contact address will be included in the metadata of each notification.",
"vapid_prv_key": "If not specified, one will be automatically generated."
}
}
diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py
index a8721720dfb..95cdee9ab9e 100644
--- a/homeassistant/components/http/__init__.py
+++ b/homeassistant/components/http/__init__.py
@@ -326,7 +326,8 @@ class HomeAssistantApplication(web.Application):
protocol,
writer,
task,
- loop=self._loop,
+ # loop will never be None when called from aiohttp
+ loop=self._loop, # type: ignore[arg-type]
client_max_size=self._client_max_size,
)
@@ -505,15 +506,14 @@ class HomeAssistantHTTP:
self, url_path: str, path: str, cache_headers: bool = True
) -> None:
"""Register a folder or file to serve as a static path."""
- frame.report(
+ frame.report_usage(
"calls hass.http.register_static_path which is deprecated because "
"it does blocking I/O in the event loop, instead "
"call `await hass.http.async_register_static_paths("
- f'[StaticPathConfig("{url_path}", "{path}", {cache_headers})])`; '
- "This function will be removed in 2025.7",
+ f'[StaticPathConfig("{url_path}", "{path}", {cache_headers})])`',
exclude_integrations={"http"},
- error_if_core=False,
- error_if_integration=False,
+ core_behavior=frame.ReportBehavior.LOG,
+ breaks_in_ha_version="2025.7",
)
configs = [StaticPathConfig(url_path, path, cache_headers)]
resources = self._make_static_resources(configs)
diff --git a/homeassistant/components/http/static.py b/homeassistant/components/http/static.py
index 29c5840a4bf..9ca34af3741 100644
--- a/homeassistant/components/http/static.py
+++ b/homeassistant/components/http/static.py
@@ -4,6 +4,7 @@ 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
@@ -17,6 +18,15 @@ 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
+
class CachingStaticResource(StaticResource):
"""Static Resource handler that will add cache headers."""
@@ -37,9 +47,7 @@ class CachingStaticResource(StaticResource):
# Must be directory index; ignore caching
return response
file_path = response._path # noqa: SLF001
- response.content_type = (
- CONTENT_TYPES.guess_type(file_path)[0] or FALLBACK_CONTENT_TYPE
- )
+ response.content_type = _GUESSER(file_path)[0] or FALLBACK_CONTENT_TYPE
# Cache actual header after setter construction.
content_type = response.headers[CONTENT_TYPE]
RESPONSE_CACHE[key] = (file_path, content_type)
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/manifest.json b/homeassistant/components/hue/manifest.json
index dbd9b511977..22f1d3991e7 100644
--- a/homeassistant/components/hue/manifest.json
+++ b/homeassistant/components/hue/manifest.json
@@ -10,7 +10,6 @@
"integration_type": "hub",
"iot_class": "local_push",
"loggers": ["aiohue"],
- "quality_scale": "platinum",
"requirements": ["aiohue==4.7.3"],
"zeroconf": ["_hue._tcp.local."]
}
diff --git a/homeassistant/components/hue/scene.py b/homeassistant/components/hue/scene.py
index 6808ddb5353..1d83804820d 100644
--- a/homeassistant/components/hue/scene.py
+++ b/homeassistant/components/hue/scene.py
@@ -130,10 +130,15 @@ class HueSceneEntity(HueSceneEntityBase):
@property
def is_dynamic(self) -> bool:
"""Return if this scene has a dynamic color palette."""
- if self.resource.palette.color and len(self.resource.palette.color) > 1:
+ if (
+ self.resource.palette
+ and self.resource.palette.color
+ and len(self.resource.palette.color) > 1
+ ):
return True
if (
- self.resource.palette.color_temperature
+ self.resource.palette
+ and self.resource.palette.color_temperature
and len(self.resource.palette.color_temperature) > 1
):
return True
diff --git a/homeassistant/components/hue/v1/light.py b/homeassistant/components/hue/v1/light.py
index 76dd0fce12b..e9669d226f0 100644
--- a/homeassistant/components/hue/v1/light.py
+++ b/homeassistant/components/hue/v1/light.py
@@ -12,11 +12,13 @@ import aiohue
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
- ATTR_COLOR_TEMP,
+ ATTR_COLOR_TEMP_KELVIN,
ATTR_EFFECT,
ATTR_FLASH,
ATTR_HS_COLOR,
ATTR_TRANSITION,
+ DEFAULT_MAX_KELVIN,
+ DEFAULT_MIN_KELVIN,
EFFECT_COLORLOOP,
EFFECT_RANDOM,
FLASH_LONG,
@@ -35,7 +37,7 @@ from homeassistant.helpers.update_coordinator import (
DataUpdateCoordinator,
UpdateFailed,
)
-from homeassistant.util import color
+from homeassistant.util import color as color_util
from ..bridge import HueBridge
from ..const import (
@@ -362,7 +364,7 @@ class HueLight(CoordinatorEntity, LightEntity):
"bulb in the Philips Hue App."
)
LOGGER.warning(err, self.name)
- if self.gamut and not color.check_valid_gamut(self.gamut):
+ if self.gamut and not color_util.check_valid_gamut(self.gamut):
err = "Color gamut of %s: %s, not valid, setting gamut to None."
LOGGER.debug(err, self.name, str(self.gamut))
self.gamut_typ = GAMUT_TYPE_UNAVAILABLE
@@ -427,49 +429,50 @@ class HueLight(CoordinatorEntity, LightEntity):
source = self.light.action if self.is_group else self.light.state
if mode in ("xy", "hs") and "xy" in source:
- return color.color_xy_to_hs(*source["xy"], self.gamut)
+ return color_util.color_xy_to_hs(*source["xy"], self.gamut)
return None
@property
- def color_temp(self):
- """Return the CT color value."""
+ def color_temp_kelvin(self) -> int | None:
+ """Return the color temperature value in Kelvin."""
# Don't return color temperature unless in color temperature mode
if self._color_mode != "ct":
return None
- if self.is_group:
- return self.light.action.get("ct")
- return self.light.state.get("ct")
+ ct = (
+ self.light.action.get("ct") if self.is_group else self.light.state.get("ct")
+ )
+ return color_util.color_temperature_mired_to_kelvin(ct) if ct else None
@property
- def min_mireds(self):
- """Return the coldest color_temp that this light supports."""
+ def max_color_temp_kelvin(self) -> int:
+ """Return the coldest color_temp_kelvin that this light supports."""
if self.is_group:
- return super().min_mireds
+ return DEFAULT_MAX_KELVIN
min_mireds = self.light.controlcapabilities.get("ct", {}).get("min")
# We filter out '0' too, which can be incorrectly reported by 3rd party buls
if not min_mireds:
- return super().min_mireds
+ return DEFAULT_MAX_KELVIN
- return min_mireds
+ return color_util.color_temperature_mired_to_kelvin(min_mireds)
@property
- def max_mireds(self):
- """Return the warmest color_temp that this light supports."""
+ def min_color_temp_kelvin(self) -> int:
+ """Return the warmest color_temp_kelvin that this light supports."""
if self.is_group:
- return super().max_mireds
+ return DEFAULT_MIN_KELVIN
if self.is_livarno:
- return 500
+ return 2000 # 500 mireds
max_mireds = self.light.controlcapabilities.get("ct", {}).get("max")
if not max_mireds:
- return super().max_mireds
+ return DEFAULT_MIN_KELVIN
- return max_mireds
+ return color_util.color_temperature_mired_to_kelvin(max_mireds)
@property
def is_on(self):
@@ -541,11 +544,14 @@ class HueLight(CoordinatorEntity, LightEntity):
# Philips hue bulb models respond differently to hue/sat
# requests, so we convert to XY first to ensure a consistent
# color.
- xy_color = color.color_hs_to_xy(*kwargs[ATTR_HS_COLOR], self.gamut)
+ xy_color = color_util.color_hs_to_xy(*kwargs[ATTR_HS_COLOR], self.gamut)
command["xy"] = xy_color
- elif ATTR_COLOR_TEMP in kwargs:
- temp = kwargs[ATTR_COLOR_TEMP]
- command["ct"] = max(self.min_mireds, min(temp, self.max_mireds))
+ elif ATTR_COLOR_TEMP_KELVIN in kwargs:
+ temp_k = max(
+ self.min_color_temp_kelvin,
+ min(self.max_color_temp_kelvin, kwargs[ATTR_COLOR_TEMP_KELVIN]),
+ )
+ command["ct"] = color_util.color_temperature_kelvin_to_mired(temp_k)
if ATTR_BRIGHTNESS in kwargs:
command["bri"] = hass_to_hue_brightness(kwargs[ATTR_BRIGHTNESS])
diff --git a/homeassistant/components/hue/v2/group.py b/homeassistant/components/hue/v2/group.py
index 97ff6feffa5..c7f966ce9f2 100644
--- a/homeassistant/components/hue/v2/group.py
+++ b/homeassistant/components/hue/v2/group.py
@@ -12,7 +12,7 @@ from aiohue.v2.models.feature import DynamicStatus
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
- ATTR_COLOR_TEMP,
+ ATTR_COLOR_TEMP_KELVIN,
ATTR_FLASH,
ATTR_TRANSITION,
ATTR_XY_COLOR,
@@ -27,6 +27,7 @@ from homeassistant.core import HomeAssistant, callback
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
from ..const import DOMAIN
@@ -157,7 +158,7 @@ class GroupedHueLight(HueBaseEntity, LightEntity):
"""Turn the grouped_light on."""
transition = normalize_hue_transition(kwargs.get(ATTR_TRANSITION))
xy_color = kwargs.get(ATTR_XY_COLOR)
- color_temp = normalize_hue_colortemp(kwargs.get(ATTR_COLOR_TEMP))
+ color_temp = normalize_hue_colortemp(kwargs.get(ATTR_COLOR_TEMP_KELVIN))
brightness = normalize_hue_brightness(kwargs.get(ATTR_BRIGHTNESS))
flash = kwargs.get(ATTR_FLASH)
@@ -235,9 +236,21 @@ class GroupedHueLight(HueBaseEntity, LightEntity):
if color_temp := light.color_temperature:
lights_with_color_temp_support += 1
# we assume mired values from the first capable light
- self._attr_color_temp = color_temp.mirek
- self._attr_max_mireds = color_temp.mirek_schema.mirek_maximum
- self._attr_min_mireds = color_temp.mirek_schema.mirek_minimum
+ self._attr_color_temp_kelvin = (
+ color_util.color_temperature_mired_to_kelvin(color_temp.mirek)
+ if color_temp.mirek
+ else None
+ )
+ self._attr_min_color_temp_kelvin = (
+ color_util.color_temperature_mired_to_kelvin(
+ color_temp.mirek_schema.mirek_maximum
+ )
+ )
+ self._attr_max_color_temp_kelvin = (
+ color_util.color_temperature_mired_to_kelvin(
+ color_temp.mirek_schema.mirek_minimum
+ )
+ )
if color_temp.mirek is not None and color_temp.mirek_valid:
lights_in_colortemp_mode += 1
if color := light.color:
diff --git a/homeassistant/components/hue/v2/helpers.py b/homeassistant/components/hue/v2/helpers.py
index 480296760e7..384d2a30596 100644
--- a/homeassistant/components/hue/v2/helpers.py
+++ b/homeassistant/components/hue/v2/helpers.py
@@ -2,6 +2,8 @@
from __future__ import annotations
+from homeassistant.util import color as color_util
+
def normalize_hue_brightness(brightness: float | None) -> float | None:
"""Return calculated brightness values."""
@@ -21,10 +23,11 @@ def normalize_hue_transition(transition: float | None) -> float | None:
return transition
-def normalize_hue_colortemp(colortemp: int | None) -> int | None:
+def normalize_hue_colortemp(colortemp_k: int | None) -> int | None:
"""Return color temperature within Hue's ranges."""
- if colortemp is not None:
- # Hue only accepts a range between 153..500
- colortemp = min(colortemp, 500)
- colortemp = max(colortemp, 153)
- return colortemp
+ if colortemp_k is None:
+ return None
+ colortemp = color_util.color_temperature_kelvin_to_mired(colortemp_k)
+ # Hue only accepts a range between 153..500
+ colortemp = min(colortemp, 500)
+ return max(colortemp, 153)
diff --git a/homeassistant/components/hue/v2/light.py b/homeassistant/components/hue/v2/light.py
index 053b3c19c2d..86d8cc93e54 100644
--- a/homeassistant/components/hue/v2/light.py
+++ b/homeassistant/components/hue/v2/light.py
@@ -13,7 +13,7 @@ from aiohue.v2.models.light import Light
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
- ATTR_COLOR_TEMP,
+ ATTR_COLOR_TEMP_KELVIN,
ATTR_EFFECT,
ATTR_FLASH,
ATTR_TRANSITION,
@@ -28,6 +28,7 @@ from homeassistant.components.light import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.util import color as color_util
from ..bridge import HueBridge
from ..const import DOMAIN
@@ -39,9 +40,9 @@ from .helpers import (
)
EFFECT_NONE = "None"
-FALLBACK_MIN_MIREDS = 153 # 6500 K
-FALLBACK_MAX_MIREDS = 500 # 2000 K
-FALLBACK_MIREDS = 173 # halfway
+FALLBACK_MIN_KELVIN = 6500
+FALLBACK_MAX_KELVIN = 2000
+FALLBACK_KELVIN = 5800 # halfway
async def async_setup_entry(
@@ -164,28 +165,32 @@ class HueLight(HueBaseEntity, LightEntity):
return None
@property
- def color_temp(self) -> int:
- """Return the color temperature."""
+ def color_temp_kelvin(self) -> int | None:
+ """Return the color temperature value in Kelvin."""
if color_temp := self.resource.color_temperature:
- return color_temp.mirek
+ return color_util.color_temperature_mired_to_kelvin(color_temp.mirek)
# return a fallback value to prevent issues with mired->kelvin conversions
- return FALLBACK_MIREDS
+ return FALLBACK_KELVIN
@property
- def min_mireds(self) -> int:
- """Return the coldest color_temp that this light supports."""
+ def max_color_temp_kelvin(self) -> int:
+ """Return the coldest color_temp_kelvin that this light supports."""
if color_temp := self.resource.color_temperature:
- return color_temp.mirek_schema.mirek_minimum
+ return color_util.color_temperature_mired_to_kelvin(
+ color_temp.mirek_schema.mirek_minimum
+ )
# return a fallback value to prevent issues with mired->kelvin conversions
- return FALLBACK_MIN_MIREDS
+ return FALLBACK_MAX_KELVIN
@property
- def max_mireds(self) -> int:
- """Return the warmest color_temp that this light supports."""
+ def min_color_temp_kelvin(self) -> int:
+ """Return the warmest color_temp_kelvin that this light supports."""
if color_temp := self.resource.color_temperature:
- return color_temp.mirek_schema.mirek_maximum
+ return color_util.color_temperature_mired_to_kelvin(
+ color_temp.mirek_schema.mirek_maximum
+ )
# return a fallback value to prevent issues with mired->kelvin conversions
- return FALLBACK_MAX_MIREDS
+ return FALLBACK_MIN_KELVIN
@property
def extra_state_attributes(self) -> dict[str, str] | None:
@@ -210,7 +215,7 @@ class HueLight(HueBaseEntity, LightEntity):
"""Turn the device on."""
transition = normalize_hue_transition(kwargs.get(ATTR_TRANSITION))
xy_color = kwargs.get(ATTR_XY_COLOR)
- color_temp = normalize_hue_colortemp(kwargs.get(ATTR_COLOR_TEMP))
+ color_temp = normalize_hue_colortemp(kwargs.get(ATTR_COLOR_TEMP_KELVIN))
brightness = normalize_hue_brightness(kwargs.get(ATTR_BRIGHTNESS))
if self._last_brightness and brightness is None:
# The Hue bridge sets the brightness to 1% when turning on a bulb
diff --git a/homeassistant/components/humidifier/__init__.py b/homeassistant/components/humidifier/__init__.py
index b556a6961bb..8c892dca327 100644
--- a/homeassistant/components/humidifier/__init__.py
+++ b/homeassistant/components/humidifier/__init__.py
@@ -4,7 +4,6 @@ from __future__ import annotations
from datetime import timedelta
from enum import StrEnum
-from functools import partial
import logging
from typing import Any, final
@@ -22,11 +21,6 @@ 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 (
- all_with_deprecated_constants,
- check_if_deprecated_constant,
- dir_with_deprecated_constants,
-)
from homeassistant.helpers.entity import ToggleEntity, ToggleEntityDescription
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.typing import ConfigType
@@ -34,9 +28,6 @@ from homeassistant.loader import bind_hass
from homeassistant.util.hass_dict import HassKey
from .const import ( # noqa: F401
- _DEPRECATED_DEVICE_CLASS_DEHUMIDIFIER,
- _DEPRECATED_DEVICE_CLASS_HUMIDIFIER,
- _DEPRECATED_SUPPORT_MODES,
ATTR_ACTION,
ATTR_AVAILABLE_MODES,
ATTR_CURRENT_HUMIDITY,
@@ -179,7 +170,7 @@ class HumidifierEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_AT
ATTR_MAX_HUMIDITY: self.max_humidity,
}
- if HumidifierEntityFeature.MODES in self.supported_features_compat:
+ if HumidifierEntityFeature.MODES in self.supported_features:
data[ATTR_AVAILABLE_MODES] = self.available_modes
return data
@@ -208,7 +199,7 @@ class HumidifierEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_AT
if self.target_humidity is not None:
data[ATTR_HUMIDITY] = self.target_humidity
- if HumidifierEntityFeature.MODES in self.supported_features_compat:
+ if HumidifierEntityFeature.MODES in self.supported_features:
data[ATTR_MODE] = self.mode
return data
@@ -275,19 +266,6 @@ class HumidifierEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_AT
"""Return the list of supported features."""
return self._attr_supported_features
- @property
- def supported_features_compat(self) -> HumidifierEntityFeature:
- """Return the supported features as HumidifierEntityFeature.
-
- Remove this compatibility shim in 2025.1 or later.
- """
- features = self.supported_features
- if type(features) is int: # noqa: E721
- new_features = HumidifierEntityFeature(features)
- self._report_deprecated_supported_features_values(new_features)
- return new_features
- return features
-
async def async_service_humidity_set(
entity: HumidifierEntity, service_call: ServiceCall
@@ -314,13 +292,3 @@ async def async_service_humidity_set(
)
await entity.async_set_humidity(humidity)
-
-
-# As we import deprecated constants from the const module, we need to add these two functions
-# otherwise this module will be logged for using deprecated constants and not the custom component
-# 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/humidifier/const.py b/homeassistant/components/humidifier/const.py
index fc6b0fc14d4..ceef0c5a890 100644
--- a/homeassistant/components/humidifier/const.py
+++ b/homeassistant/components/humidifier/const.py
@@ -1,15 +1,6 @@
"""Provides the constants needed for component."""
from enum import IntFlag, StrEnum
-from functools import partial
-
-from homeassistant.helpers.deprecation import (
- DeprecatedConstant,
- DeprecatedConstantEnum,
- all_with_deprecated_constants,
- check_if_deprecated_constant,
- dir_with_deprecated_constants,
-)
MODE_NORMAL = "normal"
MODE_ECO = "eco"
@@ -43,34 +34,11 @@ DEFAULT_MAX_HUMIDITY = 100
DOMAIN = "humidifier"
-# DEVICE_CLASS_* below are deprecated as of 2021.12
-# use the HumidifierDeviceClass enum instead.
-_DEPRECATED_DEVICE_CLASS_HUMIDIFIER = DeprecatedConstant(
- "humidifier", "HumidifierDeviceClass.HUMIDIFIER", "2025.1"
-)
-_DEPRECATED_DEVICE_CLASS_DEHUMIDIFIER = DeprecatedConstant(
- "dehumidifier", "HumidifierDeviceClass.DEHUMIDIFIER", "2025.1"
-)
-
SERVICE_SET_MODE = "set_mode"
SERVICE_SET_HUMIDITY = "set_humidity"
class HumidifierEntityFeature(IntFlag):
- """Supported features of the alarm control panel entity."""
+ """Supported features of the humidifier entity."""
MODES = 1
-
-
-# The SUPPORT_MODES constant is deprecated as of Home Assistant 2022.5.
-# Please use the HumidifierEntityFeature enum instead.
-_DEPRECATED_SUPPORT_MODES = DeprecatedConstantEnum(
- HumidifierEntityFeature.MODES, "2025.1"
-)
-
-# 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/hunterdouglas_powerview/number.py b/homeassistant/components/hunterdouglas_powerview/number.py
index f893b04b2d1..fb8c9f76d79 100644
--- a/homeassistant/components/hunterdouglas_powerview/number.py
+++ b/homeassistant/components/hunterdouglas_powerview/number.py
@@ -95,7 +95,7 @@ class PowerViewNumber(ShadeEntity, RestoreNumber):
self.entity_description = description
self._attr_unique_id = f"{self._attr_unique_id}_{description.key}"
- def set_native_value(self, value: float) -> None:
+ async def async_set_native_value(self, value: float) -> None:
"""Update the current value."""
self._attr_native_value = value
self.entity_description.store_value_fn(self.coordinator, self._shade.id, value)
diff --git a/homeassistant/components/husqvarna_automower/__init__.py b/homeassistant/components/husqvarna_automower/__init__.py
index 822f81f5f75..da7965250cd 100644
--- a/homeassistant/components/husqvarna_automower/__init__.py
+++ b/homeassistant/components/husqvarna_automower/__init__.py
@@ -62,7 +62,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: AutomowerConfigEntry) ->
raise ConfigEntryAuthFailed from err
raise ConfigEntryNotReady from err
- coordinator = AutomowerDataUpdateCoordinator(hass, automower_api, entry)
+ if "amc:api" not in entry.data["token"]["scope"]:
+ # We raise ConfigEntryAuthFailed here because the websocket can't be used
+ # without the scope. So only polling would be possible.
+ raise ConfigEntryAuthFailed
+
+ 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)
@@ -74,11 +79,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: AutomowerConfigEntry) ->
"websocket_task",
)
- if "amc:api" not in entry.data["token"]["scope"]:
- # We raise ConfigEntryAuthFailed here because the websocket can't be used
- # without the scope. So only polling would be possible.
- raise ConfigEntryAuthFailed
-
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
@@ -89,7 +89,9 @@ async def async_unload_entry(hass: HomeAssistant, entry: AutomowerConfigEntry) -
def cleanup_removed_devices(
- hass: HomeAssistant, config_entry: ConfigEntry, available_devices: list[str]
+ hass: HomeAssistant,
+ config_entry: AutomowerConfigEntry,
+ available_devices: list[str],
) -> None:
"""Cleanup entity and device registry from removed devices."""
device_reg = dr.async_get(hass)
@@ -104,7 +106,7 @@ def cleanup_removed_devices(
def remove_work_area_entities(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ config_entry: AutomowerConfigEntry,
removed_work_areas: set[int],
mower_id: str,
) -> None:
diff --git a/homeassistant/components/husqvarna_automower/api.py b/homeassistant/components/husqvarna_automower/api.py
index f1d3e1ef4fa..8a9a31b926a 100644
--- a/homeassistant/components/husqvarna_automower/api.py
+++ b/homeassistant/components/husqvarna_automower/api.py
@@ -7,6 +7,7 @@ from aioautomower.auth import AbstractAuth
from aioautomower.const import API_BASE_URL
from aiohttp import ClientSession
+from homeassistant.const import CONF_ACCESS_TOKEN
from homeassistant.helpers import config_entry_oauth2_flow
_LOGGER = logging.getLogger(__name__)
@@ -28,3 +29,16 @@ class AsyncConfigEntryAuth(AbstractAuth):
"""Return a valid access token."""
await self._oauth_session.async_ensure_token_valid()
return cast(str, self._oauth_session.token["access_token"])
+
+
+class AsyncConfigFlowAuth(AbstractAuth):
+ """Provide Automower AbstractAuth for the config flow."""
+
+ def __init__(self, websession: ClientSession, token: dict) -> None:
+ """Initialize Husqvarna Automower auth."""
+ super().__init__(websession, API_BASE_URL)
+ self.token: dict = token
+
+ async def async_get_access_token(self) -> str:
+ """Return a valid access token."""
+ return cast(str, self.token[CONF_ACCESS_TOKEN])
diff --git a/homeassistant/components/husqvarna_automower/binary_sensor.py b/homeassistant/components/husqvarna_automower/binary_sensor.py
index 5d1ccb6a074..3c23da76797 100644
--- a/homeassistant/components/husqvarna_automower/binary_sensor.py
+++ b/homeassistant/components/husqvarna_automower/binary_sensor.py
@@ -3,22 +3,42 @@
from collections.abc import Callable
from dataclasses import dataclass
import logging
+from typing import TYPE_CHECKING
from aioautomower.model import MowerActivities, MowerAttributes
+from homeassistant.components.automation import automations_with_entity
from homeassistant.components.binary_sensor import (
+ DOMAIN as BINARY_SENSOR_DOMAIN,
BinarySensorDeviceClass,
BinarySensorEntity,
BinarySensorEntityDescription,
)
+from homeassistant.components.script import scripts_with_entity
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 AutomowerConfigEntry
+from .const import DOMAIN
from .coordinator import AutomowerDataUpdateCoordinator
from .entity import AutomowerBaseEntity
_LOGGER = logging.getLogger(__name__)
+# Coordinator is used to centralize the data updates
+PARALLEL_UPDATES = 0
+
+
+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
@dataclass(frozen=True, kw_only=True)
@@ -43,6 +63,7 @@ MOWER_BINARY_SENSOR_TYPES: tuple[AutomowerBinarySensorEntityDescription, ...] =
key="returning_to_dock",
translation_key="returning_to_dock",
value_fn=lambda data: data.mower.activity == MowerActivities.GOING_HOME,
+ entity_registry_enabled_default=False,
),
)
@@ -81,3 +102,39 @@ class AutomowerBinarySensorEntity(AutomowerBaseEntity, BinarySensorEntity):
def is_on(self) -> bool:
"""Return the state of the binary sensor."""
return self.entity_description.value_fn(self.mower_attributes)
+
+ 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 not (
+ entity_id := er.async_get(self.hass).async_get_entity_id(
+ BINARY_SENSOR_DOMAIN, DOMAIN, self.unique_id
+ )
+ ):
+ return
+ if (
+ self.enabled
+ and self.entity_description.key == "returning_to_dock"
+ and entity_used_in(self.hass, entity_id)
+ ):
+ async_create_issue(
+ self.hass,
+ DOMAIN,
+ f"deprecated_entity_{self.entity_description.key}",
+ breaks_in_ha_version="2025.6.0",
+ is_fixable=False,
+ severity=IssueSeverity.WARNING,
+ translation_key="deprecated_entity",
+ translation_placeholders={
+ "entity_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/husqvarna_automower/button.py b/homeassistant/components/husqvarna_automower/button.py
index 22a732ec54c..ce303325496 100644
--- a/homeassistant/components/husqvarna_automower/button.py
+++ b/homeassistant/components/husqvarna_automower/button.py
@@ -22,6 +22,8 @@ from .entity import (
_LOGGER = logging.getLogger(__name__)
+PARALLEL_UPDATES = 1
+
@dataclass(frozen=True, kw_only=True)
class AutomowerButtonEntityDescription(ButtonEntityDescription):
diff --git a/homeassistant/components/husqvarna_automower/calendar.py b/homeassistant/components/husqvarna_automower/calendar.py
index d4162af0c5c..f3e82fde5d4 100644
--- a/homeassistant/components/husqvarna_automower/calendar.py
+++ b/homeassistant/components/husqvarna_automower/calendar.py
@@ -15,6 +15,8 @@ from .coordinator import AutomowerDataUpdateCoordinator
from .entity import AutomowerBaseEntity
_LOGGER = logging.getLogger(__name__)
+# Coordinator is used to centralize the data updates
+PARALLEL_UPDATES = 0
async def async_setup_entry(
diff --git a/homeassistant/components/husqvarna_automower/config_flow.py b/homeassistant/components/husqvarna_automower/config_flow.py
index 3e76b9ac812..7efed529453 100644
--- a/homeassistant/components/husqvarna_automower/config_flow.py
+++ b/homeassistant/components/husqvarna_automower/config_flow.py
@@ -4,12 +4,15 @@ from collections.abc import Mapping
import logging
from typing import Any
+from aioautomower.session import AutomowerSession
from aioautomower.utils import structure_token
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_NAME, CONF_TOKEN
-from homeassistant.helpers import config_entry_oauth2_flow
+from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow
+from homeassistant.util import dt as dt_util
+from .api import AsyncConfigFlowAuth
from .const import DOMAIN, NAME
_LOGGER = logging.getLogger(__name__)
@@ -46,9 +49,20 @@ class HusqvarnaConfigFlowHandler(
self._abort_if_unique_id_configured()
+ websession = aiohttp_client.async_get_clientsession(self.hass)
+ tz = await dt_util.async_get_time_zone(str(dt_util.DEFAULT_TIME_ZONE))
+ automower_api = AutomowerSession(AsyncConfigFlowAuth(websession, token), tz)
+ try:
+ status_data = await automower_api.get_status()
+ except Exception: # noqa: BLE001
+ return self.async_abort(reason="unknown")
+ if status_data == {}:
+ return self.async_abort(reason="no_mower_connected")
+
structured_token = structure_token(token[CONF_ACCESS_TOKEN])
first_name = structured_token.user.first_name
last_name = structured_token.user.last_name
+
return self.async_create_entry(
title=f"{NAME} of {first_name} {last_name}",
data=data,
diff --git a/homeassistant/components/husqvarna_automower/coordinator.py b/homeassistant/components/husqvarna_automower/coordinator.py
index 458ff50dac9..57be02e7066 100644
--- a/homeassistant/components/husqvarna_automower/coordinator.py
+++ b/homeassistant/components/husqvarna_automower/coordinator.py
@@ -1,37 +1,42 @@
"""Data UpdateCoordinator for the Husqvarna Automower integration."""
+from __future__ import annotations
+
import asyncio
from datetime import timedelta
import logging
+from typing import TYPE_CHECKING
from aioautomower.exceptions import (
ApiException,
AuthException,
HusqvarnaWSServerHandshakeError,
+ TimeoutException,
)
from aioautomower.model import MowerAttributes
from aioautomower.session import AutomowerSession
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN
+if TYPE_CHECKING:
+ from . import AutomowerConfigEntry
+
_LOGGER = logging.getLogger(__name__)
MAX_WS_RECONNECT_TIME = 600
SCAN_INTERVAL = timedelta(minutes=8)
+DEFAULT_RECONNECT_TIME = 2 # Define a default reconnect time
class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[dict[str, MowerAttributes]]):
"""Class to manage fetching Husqvarna data."""
- config_entry: ConfigEntry
+ config_entry: AutomowerConfigEntry
- def __init__(
- self, hass: HomeAssistant, api: AutomowerSession, entry: ConfigEntry
- ) -> None:
+ def __init__(self, hass: HomeAssistant, api: AutomowerSession) -> None:
"""Initialize data updater."""
super().__init__(
hass,
@@ -40,8 +45,8 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[dict[str, MowerAttrib
update_interval=SCAN_INTERVAL,
)
self.api = api
-
self.ws_connected: bool = False
+ self.reconnect_time = DEFAULT_RECONNECT_TIME
async def _async_update_data(self) -> dict[str, MowerAttributes]:
"""Subscribe for websocket and poll data from the API."""
@@ -64,26 +69,30 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[dict[str, MowerAttrib
async def client_listen(
self,
hass: HomeAssistant,
- entry: ConfigEntry,
+ entry: AutomowerConfigEntry,
automower_client: AutomowerSession,
- reconnect_time: int = 2,
) -> None:
"""Listen with the client."""
try:
await automower_client.auth.websocket_connect()
- reconnect_time = 2
+ # Reset reconnect time after successful connection
+ self.reconnect_time = DEFAULT_RECONNECT_TIME
await automower_client.start_listening()
except HusqvarnaWSServerHandshakeError as err:
_LOGGER.debug(
- "Failed to connect to websocket. Trying to reconnect: %s", err
+ "Failed to connect to websocket. Trying to reconnect: %s",
+ err,
+ )
+ except TimeoutException as err:
+ _LOGGER.debug(
+ "Failed to listen to websocket. Trying to reconnect: %s",
+ err,
)
-
if not hass.is_stopping:
- await asyncio.sleep(reconnect_time)
- reconnect_time = min(reconnect_time * 2, MAX_WS_RECONNECT_TIME)
- await self.client_listen(
- hass=hass,
- entry=entry,
- automower_client=automower_client,
- reconnect_time=reconnect_time,
+ await asyncio.sleep(self.reconnect_time)
+ self.reconnect_time = min(self.reconnect_time * 2, MAX_WS_RECONNECT_TIME)
+ entry.async_create_background_task(
+ hass,
+ self.client_listen(hass, entry, automower_client),
+ "reconnect_task",
)
diff --git a/homeassistant/components/husqvarna_automower/device_tracker.py b/homeassistant/components/husqvarna_automower/device_tracker.py
index 5e84b7cc67d..520eaceb1d0 100644
--- a/homeassistant/components/husqvarna_automower/device_tracker.py
+++ b/homeassistant/components/husqvarna_automower/device_tracker.py
@@ -8,6 +8,9 @@ from . import AutomowerConfigEntry
from .coordinator import AutomowerDataUpdateCoordinator
from .entity import AutomowerBaseEntity
+# Coordinator is used to centralize the data updates
+PARALLEL_UPDATES = 0
+
async def async_setup_entry(
hass: HomeAssistant,
diff --git a/homeassistant/components/husqvarna_automower/diagnostics.py b/homeassistant/components/husqvarna_automower/diagnostics.py
index 658f6f94445..ceeec0f3e0d 100644
--- a/homeassistant/components/husqvarna_automower/diagnostics.py
+++ b/homeassistant/components/husqvarna_automower/diagnostics.py
@@ -6,7 +6,6 @@ import logging
from typing import Any
from homeassistant.components.diagnostics import async_redact_data
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ACCESS_TOKEN
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntry
@@ -26,7 +25,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_get_config_entry_diagnostics(
- hass: HomeAssistant, entry: ConfigEntry
+ hass: HomeAssistant, entry: AutomowerConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
return async_redact_data(entry.as_dict(), TO_REDACT)
diff --git a/homeassistant/components/husqvarna_automower/entity.py b/homeassistant/components/husqvarna_automower/entity.py
index da6c0ae59ce..5b5156e5f1d 100644
--- a/homeassistant/components/husqvarna_automower/entity.py
+++ b/homeassistant/components/husqvarna_automower/entity.py
@@ -1,10 +1,12 @@
"""Platform for Husqvarna Automower base entity."""
+from __future__ import annotations
+
import asyncio
-from collections.abc import Awaitable, Callable, Coroutine
+from collections.abc import Callable, Coroutine
import functools
import logging
-from typing import TYPE_CHECKING, Any
+from typing import TYPE_CHECKING, Any, Concatenate
from aioautomower.exceptions import ApiException
from aioautomower.model import MowerActivities, MowerAttributes, MowerStates, WorkArea
@@ -52,18 +54,17 @@ def _work_area_translation_key(work_area_id: int, key: str) -> str:
return f"work_area_{key}"
-def handle_sending_exception(
+type _FuncType[_T, **_P, _R] = Callable[Concatenate[_T, _P], Coroutine[Any, Any, _R]]
+
+
+def handle_sending_exception[_Entity: AutomowerBaseEntity, **_P](
poll_after_sending: bool = False,
-) -> Callable[
- [Callable[..., Awaitable[Any]]], Callable[..., Coroutine[Any, Any, None]]
-]:
+) -> Callable[[_FuncType[_Entity, _P, Any]], _FuncType[_Entity, _P, None]]:
"""Handle exceptions while sending a command and optionally refresh coordinator."""
- def decorator(
- func: Callable[..., Awaitable[Any]],
- ) -> Callable[..., Coroutine[Any, Any, None]]:
+ def decorator(func: _FuncType[_Entity, _P, Any]) -> _FuncType[_Entity, _P, None]:
@functools.wraps(func)
- async def wrapper(self: Any, *args: Any, **kwargs: Any) -> Any:
+ async def wrapper(self: _Entity, *args: _P.args, **kwargs: _P.kwargs) -> None:
try:
await func(self, *args, **kwargs)
except ApiException as exception:
@@ -133,7 +134,7 @@ class AutomowerControlEntity(AutomowerAvailableEntity):
class WorkAreaAvailableEntity(AutomowerAvailableEntity):
- """Base entity for work work areas."""
+ """Base entity for work areas."""
def __init__(
self,
@@ -164,4 +165,4 @@ class WorkAreaAvailableEntity(AutomowerAvailableEntity):
class WorkAreaControlEntity(WorkAreaAvailableEntity, AutomowerControlEntity):
- """Base entity work work areas with control function."""
+ """Base entity for work areas with control function."""
diff --git a/homeassistant/components/husqvarna_automower/lawn_mower.py b/homeassistant/components/husqvarna_automower/lawn_mower.py
index eeabaa09f79..9b3ce7dab1a 100644
--- a/homeassistant/components/husqvarna_automower/lawn_mower.py
+++ b/homeassistant/components/husqvarna_automower/lawn_mower.py
@@ -22,6 +22,10 @@ from .const import DOMAIN
from .coordinator import AutomowerDataUpdateCoordinator
from .entity import AutomowerAvailableEntity, handle_sending_exception
+_LOGGER = logging.getLogger(__name__)
+
+PARALLEL_UPDATES = 1
+
DOCKED_ACTIVITIES = (MowerActivities.PARKED_IN_CS, MowerActivities.CHARGING)
MOWING_ACTIVITIES = (
MowerActivities.MOWING,
@@ -42,9 +46,6 @@ PARK = "park"
OVERRIDE_MODES = [MOW, PARK]
-_LOGGER = logging.getLogger(__name__)
-
-
async def async_setup_entry(
hass: HomeAssistant,
entry: AutomowerConfigEntry,
diff --git a/homeassistant/components/husqvarna_automower/manifest.json b/homeassistant/components/husqvarna_automower/manifest.json
index d22d23583ba..1eed2be4575 100644
--- a/homeassistant/components/husqvarna_automower/manifest.json
+++ b/homeassistant/components/husqvarna_automower/manifest.json
@@ -7,5 +7,6 @@
"documentation": "https://www.home-assistant.io/integrations/husqvarna_automower",
"iot_class": "cloud_push",
"loggers": ["aioautomower"],
- "requirements": ["aioautomower==2024.10.3"]
+ "quality_scale": "silver",
+ "requirements": ["aioautomower==2025.1.0"]
}
diff --git a/homeassistant/components/husqvarna_automower/number.py b/homeassistant/components/husqvarna_automower/number.py
index d6d794f2d83..e69b52fab93 100644
--- a/homeassistant/components/husqvarna_automower/number.py
+++ b/homeassistant/components/husqvarna_automower/number.py
@@ -24,6 +24,8 @@ from .entity import (
_LOGGER = logging.getLogger(__name__)
+PARALLEL_UPDATES = 1
+
@callback
def _async_get_cutting_height(data: MowerAttributes) -> int:
diff --git a/homeassistant/components/husqvarna_automower/quality_scale.yaml b/homeassistant/components/husqvarna_automower/quality_scale.yaml
new file mode 100644
index 00000000000..2287ccb4d4f
--- /dev/null
+++ b/homeassistant/components/husqvarna_automower/quality_scale.yaml
@@ -0,0 +1,80 @@
+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 of this integration does not explicitly subscribe to events.
+ dependency-transparency: done
+ action-setup:
+ status: done
+ comment: |
+ The integration only has an entity service, registered in the platform.
+ 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: no configuration options
+
+ # Gold
+ devices: done
+ diagnostics: done
+ discovery-update-info:
+ status: todo
+ comment: Discovery not implemented, yet.
+ discovery:
+ status: todo
+ comment: |
+ Most of the mowers are connected with a SIM card, some of the also have a
+ Wifi connection. Check, if discovery with Wifi is possible
+ docs-data-update: todo
+ 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: Add devices dynamically
+ entity-category: done
+ entity-device-class: done
+ entity-disabled-by-default: done
+ entity-translations: done
+ exception-translations: done
+ icon-translations: done
+ reconfiguration-flow:
+ status: exempt
+ comment: no configuration possible
+ repair-issues: done
+ stale-devices:
+ status: todo
+ comment: We only remove devices on reload
+
+ # Platinum
+ async-dependency: done
+ inject-websession: done
+ strict-typing: done
diff --git a/homeassistant/components/husqvarna_automower/select.py b/homeassistant/components/husqvarna_automower/select.py
index a9431acaae3..65960e897e4 100644
--- a/homeassistant/components/husqvarna_automower/select.py
+++ b/homeassistant/components/husqvarna_automower/select.py
@@ -16,6 +16,7 @@ from .entity import AutomowerControlEntity, handle_sending_exception
_LOGGER = logging.getLogger(__name__)
+PARALLEL_UPDATES = 1
HEADLIGHT_MODES: list = [
HeadlightModes.ALWAYS_OFF.lower(),
diff --git a/homeassistant/components/husqvarna_automower/sensor.py b/homeassistant/components/husqvarna_automower/sensor.py
index ebb68033918..fb8603623e4 100644
--- a/homeassistant/components/husqvarna_automower/sensor.py
+++ b/homeassistant/components/husqvarna_automower/sensor.py
@@ -35,6 +35,8 @@ from .entity import (
)
_LOGGER = logging.getLogger(__name__)
+# Coordinator is used to centralize the data updates
+PARALLEL_UPDATES = 0
ATTR_WORK_AREA_ID_ASSIGNMENT = "work_area_id_assignment"
@@ -349,6 +351,7 @@ MOWER_SENSOR_TYPES: tuple[AutomowerSensorEntityDescription, ...] = (
key="number_of_collisions",
translation_key="number_of_collisions",
entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
state_class=SensorStateClass.TOTAL,
exists_fn=lambda data: data.statistics.number_of_collisions is not None,
value_fn=attrgetter("statistics.number_of_collisions"),
diff --git a/homeassistant/components/husqvarna_automower/strings.json b/homeassistant/components/husqvarna_automower/strings.json
index 05a18bcb19f..d4c91e29f7d 100644
--- a/homeassistant/components/husqvarna_automower/strings.json
+++ b/homeassistant/components/husqvarna_automower/strings.json
@@ -27,7 +27,9 @@
"oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"wrong_account": "You can only reauthenticate this entry with the same Husqvarna account.",
- "missing_amc_scope": "The `Authentication API` and the `Automower Connect API` are not connected to your application in the Husqvarna Developer Portal."
+ "no_mower_connected": "No mowers connected to this account.",
+ "missing_amc_scope": "The `Authentication API` and the `Automower Connect API` are not connected to your application in the Husqvarna Developer Portal.",
+ "unknown": "[%key:common::config_flow::error::unknown%]"
},
"create_entry": {
"default": "[%key:common::config_flow::create_entry::authenticated%]"
@@ -311,6 +313,12 @@
}
}
},
+ "issues": {
+ "deprecated_entity": {
+ "title": "The Husqvarna Automower {entity_name} sensor is deprecated",
+ "description": "The Husqvarna Automower entity `{entity}` is deprecated and will be removed in a future release.\nYou can use the new returning state of the lawn mower entity instead.\nPlease update your automations and scripts to replace the sensor entity with the newly added lawn mower entity.\nWhen you are done migrating you can disable `{entity}`."
+ }
+ },
"services": {
"override_schedule": {
"name": "Override schedule",
diff --git a/homeassistant/components/husqvarna_automower/switch.py b/homeassistant/components/husqvarna_automower/switch.py
index 2bbe5c87624..352b4c59ba1 100644
--- a/homeassistant/components/husqvarna_automower/switch.py
+++ b/homeassistant/components/husqvarna_automower/switch.py
@@ -19,6 +19,8 @@ from .entity import (
handle_sending_exception,
)
+PARALLEL_UPDATES = 1
+
_LOGGER = logging.getLogger(__name__)
diff --git a/homeassistant/components/husqvarna_automower_ble/manifest.json b/homeassistant/components/husqvarna_automower_ble/manifest.json
index 3e72d9707c7..7566b5c9d32 100644
--- a/homeassistant/components/husqvarna_automower_ble/manifest.json
+++ b/homeassistant/components/husqvarna_automower_ble/manifest.json
@@ -10,7 +10,7 @@
"codeowners": ["@alistair23"],
"config_flow": true,
"dependencies": ["bluetooth_adapters"],
- "documentation": "https://www.home-assistant.io/integrations/???",
+ "documentation": "https://www.home-assistant.io/integrations/husqvarna_automower_ble",
"iot_class": "local_polling",
"requirements": ["automower-ble==0.2.0"]
}
diff --git a/homeassistant/components/huum/climate.py b/homeassistant/components/huum/climate.py
index df740aea3d1..7e0e4ce5ef1 100644
--- a/homeassistant/components/huum/climate.py
+++ b/homeassistant/components/huum/climate.py
@@ -56,7 +56,6 @@ class HuumDevice(ClimateEntity):
_target_temperature: int | None = None
_status: HuumStatusResponse | None = None
- _enable_turn_on_off_backwards_compatibility = False
def __init__(self, huum_handler: Huum, unique_id: str) -> None:
"""Initialize the heater."""
diff --git a/homeassistant/components/huum/manifest.json b/homeassistant/components/huum/manifest.json
index cc393f3785f..38562e1a072 100644
--- a/homeassistant/components/huum/manifest.json
+++ b/homeassistant/components/huum/manifest.json
@@ -5,5 +5,5 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/huum",
"iot_class": "cloud_polling",
- "requirements": ["huum==0.7.11"]
+ "requirements": ["huum==0.7.12"]
}
diff --git a/homeassistant/components/hvv_departures/strings.json b/homeassistant/components/hvv_departures/strings.json
index a9ec58f12ad..f69dcd22047 100644
--- a/homeassistant/components/hvv_departures/strings.json
+++ b/homeassistant/components/hvv_departures/strings.json
@@ -32,6 +32,10 @@
}
},
"options": {
+ "error": {
+ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
+ "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]"
+ },
"step": {
"init": {
"title": "Options",
diff --git a/homeassistant/components/hydrawise/__init__.py b/homeassistant/components/hydrawise/__init__.py
index d2af8f37e36..ea5a5801e69 100644
--- a/homeassistant/components/hydrawise/__init__.py
+++ b/homeassistant/components/hydrawise/__init__.py
@@ -7,8 +7,12 @@ from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
-from .const import DOMAIN, SCAN_INTERVAL
-from .coordinator import HydrawiseDataUpdateCoordinator
+from .const import APP_ID, DOMAIN
+from .coordinator import (
+ HydrawiseMainDataUpdateCoordinator,
+ HydrawiseUpdateCoordinators,
+ HydrawiseWaterUseDataUpdateCoordinator,
+)
PLATFORMS: list[Platform] = [
Platform.BINARY_SENSOR,
@@ -26,12 +30,22 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
raise ConfigEntryAuthFailed
hydrawise = client.Hydrawise(
- auth.Auth(config_entry.data[CONF_USERNAME], config_entry.data[CONF_PASSWORD])
+ auth.Auth(config_entry.data[CONF_USERNAME], config_entry.data[CONF_PASSWORD]),
+ app_id=APP_ID,
)
- coordinator = HydrawiseDataUpdateCoordinator(hass, hydrawise, SCAN_INTERVAL)
- await coordinator.async_config_entry_first_refresh()
- hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = coordinator
+ main_coordinator = HydrawiseMainDataUpdateCoordinator(hass, hydrawise)
+ await main_coordinator.async_config_entry_first_refresh()
+ water_use_coordinator = HydrawiseWaterUseDataUpdateCoordinator(
+ hass, hydrawise, main_coordinator
+ )
+ await water_use_coordinator.async_config_entry_first_refresh()
+ hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = (
+ HydrawiseUpdateCoordinators(
+ main=main_coordinator,
+ water_use=water_use_coordinator,
+ )
+ )
await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
return True
diff --git a/homeassistant/components/hydrawise/binary_sensor.py b/homeassistant/components/hydrawise/binary_sensor.py
index 9b6dcadf95f..34c31d3ad16 100644
--- a/homeassistant/components/hydrawise/binary_sensor.py
+++ b/homeassistant/components/hydrawise/binary_sensor.py
@@ -21,7 +21,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import VolDictType
from .const import DOMAIN, SERVICE_RESUME, SERVICE_START_WATERING, SERVICE_SUSPEND
-from .coordinator import HydrawiseDataUpdateCoordinator
+from .coordinator import HydrawiseUpdateCoordinators
from .entity import HydrawiseEntity
@@ -81,18 +81,16 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Hydrawise binary_sensor platform."""
- coordinator: HydrawiseDataUpdateCoordinator = hass.data[DOMAIN][
- config_entry.entry_id
- ]
+ coordinators: HydrawiseUpdateCoordinators = hass.data[DOMAIN][config_entry.entry_id]
entities: list[HydrawiseBinarySensor] = []
- for controller in coordinator.data.controllers.values():
+ for controller in coordinators.main.data.controllers.values():
entities.extend(
- HydrawiseBinarySensor(coordinator, description, controller)
+ HydrawiseBinarySensor(coordinators.main, description, controller)
for description in CONTROLLER_BINARY_SENSORS
)
entities.extend(
HydrawiseBinarySensor(
- coordinator,
+ coordinators.main,
description,
controller,
sensor_id=sensor.id,
@@ -103,7 +101,7 @@ async def async_setup_entry(
)
entities.extend(
HydrawiseZoneBinarySensor(
- coordinator, description, controller, zone_id=zone.id
+ coordinators.main, description, controller, zone_id=zone.id
)
for zone in controller.zones
for description in ZONE_BINARY_SENSORS
diff --git a/homeassistant/components/hydrawise/config_flow.py b/homeassistant/components/hydrawise/config_flow.py
index 242763e81e3..5af32af3951 100644
--- a/homeassistant/components/hydrawise/config_flow.py
+++ b/homeassistant/components/hydrawise/config_flow.py
@@ -6,14 +6,14 @@ from collections.abc import Callable, Mapping
from typing import Any
from aiohttp import ClientError
-from pydrawise import auth, client
+from pydrawise import auth as pydrawise_auth, client
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 .const import DOMAIN, LOGGER
+from .const import APP_ID, DOMAIN, LOGGER
class HydrawiseConfigFlow(ConfigFlow, domain=DOMAIN):
@@ -29,16 +29,21 @@ class HydrawiseConfigFlow(ConfigFlow, domain=DOMAIN):
on_failure: Callable[[str], ConfigFlowResult],
) -> ConfigFlowResult:
"""Create the config entry."""
-
# Verify that the provided credentials work."""
- api = client.Hydrawise(auth.Auth(username, password))
+ auth = pydrawise_auth.Auth(username, password)
try:
- # Don't fetch zones because we don't need them yet.
- user = await api.get_user(fetch_zones=False)
+ 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")
diff --git a/homeassistant/components/hydrawise/const.py b/homeassistant/components/hydrawise/const.py
index 47b9bef845e..beaf450a586 100644
--- a/homeassistant/components/hydrawise/const.py
+++ b/homeassistant/components/hydrawise/const.py
@@ -3,14 +3,19 @@
from datetime import timedelta
import logging
+from homeassistant.const import __version__ as HA_VERSION
+
LOGGER = logging.getLogger(__package__)
+APP_ID = f"homeassistant-{HA_VERSION}"
+
DOMAIN = "hydrawise"
DEFAULT_WATERING_TIME = timedelta(minutes=15)
MANUFACTURER = "Hydrawise"
-SCAN_INTERVAL = timedelta(seconds=60)
+MAIN_SCAN_INTERVAL = timedelta(minutes=5)
+WATER_USE_SCAN_INTERVAL = timedelta(minutes=60)
SIGNAL_UPDATE_HYDRAWISE = "hydrawise_update"
diff --git a/homeassistant/components/hydrawise/coordinator.py b/homeassistant/components/hydrawise/coordinator.py
index 6cd233eb1df..e82a4ec1588 100644
--- a/homeassistant/components/hydrawise/coordinator.py
+++ b/homeassistant/components/hydrawise/coordinator.py
@@ -2,8 +2,7 @@
from __future__ import annotations
-from dataclasses import dataclass
-from datetime import timedelta
+from dataclasses import dataclass, field
from pydrawise import Hydrawise
from pydrawise.schema import Controller, ControllerWaterUseSummary, Sensor, User, Zone
@@ -12,7 +11,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from homeassistant.util.dt import now
-from .const import DOMAIN, LOGGER
+from .const import DOMAIN, LOGGER, MAIN_SCAN_INTERVAL, WATER_USE_SCAN_INTERVAL
@dataclass
@@ -20,22 +19,39 @@ class HydrawiseData:
"""Container for data fetched from the Hydrawise API."""
user: User
- controllers: dict[int, Controller]
- zones: dict[int, Zone]
- sensors: dict[int, Sensor]
- daily_water_summary: dict[int, ControllerWaterUseSummary]
+ controllers: dict[int, Controller] = field(default_factory=dict)
+ zones: dict[int, Zone] = field(default_factory=dict)
+ sensors: dict[int, Sensor] = field(default_factory=dict)
+ daily_water_summary: dict[int, ControllerWaterUseSummary] = field(
+ default_factory=dict
+ )
+
+
+@dataclass
+class HydrawiseUpdateCoordinators:
+ """Container for all Hydrawise DataUpdateCoordinator instances."""
+
+ main: HydrawiseMainDataUpdateCoordinator
+ water_use: HydrawiseWaterUseDataUpdateCoordinator
class HydrawiseDataUpdateCoordinator(DataUpdateCoordinator[HydrawiseData]):
- """The Hydrawise Data Update Coordinator."""
+ """Base class for Hydrawise Data Update Coordinators."""
api: Hydrawise
- def __init__(
- self, hass: HomeAssistant, api: Hydrawise, scan_interval: timedelta
- ) -> None:
+
+class HydrawiseMainDataUpdateCoordinator(HydrawiseDataUpdateCoordinator):
+ """The main Hydrawise Data Update Coordinator.
+
+ This fetches the primary state data for Hydrawise controllers and zones
+ at a relatively frequent interval so that the primary functions of the
+ integration are updated in a timely manner.
+ """
+
+ def __init__(self, hass: HomeAssistant, api: Hydrawise) -> None:
"""Initialize HydrawiseDataUpdateCoordinator."""
- super().__init__(hass, LOGGER, name=DOMAIN, update_interval=scan_interval)
+ super().__init__(hass, LOGGER, name=DOMAIN, update_interval=MAIN_SCAN_INTERVAL)
self.api = api
async def _async_update_data(self) -> HydrawiseData:
@@ -43,28 +59,56 @@ class HydrawiseDataUpdateCoordinator(DataUpdateCoordinator[HydrawiseData]):
# Don't fetch zones. We'll fetch them for each controller later.
# This is to prevent 502 errors in some cases.
# See: https://github.com/home-assistant/core/issues/120128
- user = await self.api.get_user(fetch_zones=False)
- controllers = {}
- zones = {}
- sensors = {}
- daily_water_summary: dict[int, ControllerWaterUseSummary] = {}
- for controller in user.controllers:
- controllers[controller.id] = controller
+ data = HydrawiseData(user=await self.api.get_user(fetch_zones=False))
+ for controller in data.user.controllers:
+ data.controllers[controller.id] = controller
controller.zones = await self.api.get_zones(controller)
for zone in controller.zones:
- zones[zone.id] = zone
+ data.zones[zone.id] = zone
for sensor in controller.sensors:
- sensors[sensor.id] = sensor
+ data.sensors[sensor.id] = sensor
+ return data
+
+
+class HydrawiseWaterUseDataUpdateCoordinator(HydrawiseDataUpdateCoordinator):
+ """Data Update Coordinator for Hydrawise Water Use.
+
+ This fetches data that is more expensive for the Hydrawise API to compute
+ at a less frequent interval as to not overload the Hydrawise servers.
+ """
+
+ _main_coordinator: HydrawiseMainDataUpdateCoordinator
+
+ def __init__(
+ self,
+ hass: HomeAssistant,
+ api: Hydrawise,
+ main_coordinator: HydrawiseMainDataUpdateCoordinator,
+ ) -> None:
+ """Initialize HydrawiseWaterUseDataUpdateCoordinator."""
+ super().__init__(
+ hass,
+ LOGGER,
+ name=f"{DOMAIN} water use",
+ update_interval=WATER_USE_SCAN_INTERVAL,
+ )
+ self.api = api
+ self._main_coordinator = main_coordinator
+
+ async def _async_update_data(self) -> HydrawiseData:
+ """Fetch the latest data from Hydrawise."""
+ daily_water_summary: dict[int, ControllerWaterUseSummary] = {}
+ for controller in self._main_coordinator.data.controllers.values():
daily_water_summary[controller.id] = await self.api.get_water_use_summary(
controller,
now().replace(hour=0, minute=0, second=0, microsecond=0),
now(),
)
-
+ main_data = self._main_coordinator.data
return HydrawiseData(
- user=user,
- controllers=controllers,
- zones=zones,
- sensors=sensors,
+ user=main_data.user,
+ controllers=main_data.controllers,
+ zones=main_data.zones,
+ sensors=main_data.sensors,
daily_water_summary=daily_water_summary,
)
diff --git a/homeassistant/components/hydrawise/manifest.json b/homeassistant/components/hydrawise/manifest.json
index 9678dc83e5f..50f803c07dc 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.9.0"]
+ "requirements": ["pydrawise==2024.12.0"]
}
diff --git a/homeassistant/components/hydrawise/sensor.py b/homeassistant/components/hydrawise/sensor.py
index 563af893700..96cc16832da 100644
--- a/homeassistant/components/hydrawise/sensor.py
+++ b/homeassistant/components/hydrawise/sensor.py
@@ -4,9 +4,11 @@ from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
-from datetime import datetime, timedelta
+from datetime import timedelta
from typing import Any
+from pydrawise.schema import ControllerWaterUseSummary
+
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
@@ -19,7 +21,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util import dt as dt_util
from .const import DOMAIN
-from .coordinator import HydrawiseDataUpdateCoordinator
+from .coordinator import HydrawiseUpdateCoordinators
from .entity import HydrawiseEntity
@@ -30,100 +32,58 @@ class HydrawiseSensorEntityDescription(SensorEntityDescription):
value_fn: Callable[[HydrawiseSensor], Any]
-def _get_zone_watering_time(sensor: HydrawiseSensor) -> int:
- if (current_run := sensor.zone.scheduled_runs.current_run) is not None:
- return int(current_run.remaining_time.total_seconds() / 60)
- return 0
+def _get_water_use(sensor: HydrawiseSensor) -> ControllerWaterUseSummary:
+ return sensor.coordinator.data.daily_water_summary[sensor.controller.id]
-def _get_zone_next_cycle(sensor: HydrawiseSensor) -> datetime | None:
- if (next_run := sensor.zone.scheduled_runs.next_run) is not None:
- return dt_util.as_utc(next_run.start_time)
- return None
-
-
-def _get_zone_daily_active_water_use(sensor: HydrawiseSensor) -> float:
- """Get active water use for the zone."""
- daily_water_summary = sensor.coordinator.data.daily_water_summary[
- sensor.controller.id
- ]
- return float(daily_water_summary.active_use_by_zone_id.get(sensor.zone.id, 0.0))
-
-
-def _get_zone_daily_active_water_time(sensor: HydrawiseSensor) -> float | None:
- """Get active water time for the zone."""
- daily_water_summary = sensor.coordinator.data.daily_water_summary[
- sensor.controller.id
- ]
- return daily_water_summary.active_time_by_zone_id.get(
- sensor.zone.id, timedelta()
- ).total_seconds()
-
-
-def _get_controller_daily_active_water_use(sensor: HydrawiseSensor) -> float | None:
- """Get active water use for the controller."""
- daily_water_summary = sensor.coordinator.data.daily_water_summary[
- sensor.controller.id
- ]
- return daily_water_summary.total_active_use
-
-
-def _get_controller_daily_inactive_water_use(sensor: HydrawiseSensor) -> float | None:
- """Get inactive water use for the controller."""
- daily_water_summary = sensor.coordinator.data.daily_water_summary[
- sensor.controller.id
- ]
- return daily_water_summary.total_inactive_use
-
-
-def _get_controller_daily_active_water_time(sensor: HydrawiseSensor) -> float:
- """Get active water time for the controller."""
- daily_water_summary = sensor.coordinator.data.daily_water_summary[
- sensor.controller.id
- ]
- return daily_water_summary.total_active_time.total_seconds()
-
-
-def _get_controller_daily_total_water_use(sensor: HydrawiseSensor) -> float | None:
- """Get inactive water use for the controller."""
- daily_water_summary = sensor.coordinator.data.daily_water_summary[
- sensor.controller.id
- ]
- return daily_water_summary.total_use
-
-
-CONTROLLER_SENSORS: tuple[HydrawiseSensorEntityDescription, ...] = (
+WATER_USE_CONTROLLER_SENSORS: tuple[HydrawiseSensorEntityDescription, ...] = (
HydrawiseSensorEntityDescription(
key="daily_active_water_time",
translation_key="daily_active_water_time",
device_class=SensorDeviceClass.DURATION,
native_unit_of_measurement=UnitOfTime.SECONDS,
- value_fn=_get_controller_daily_active_water_time,
+ value_fn=lambda sensor: _get_water_use(
+ sensor
+ ).total_active_time.total_seconds(),
),
)
+WATER_USE_ZONE_SENSORS: tuple[HydrawiseSensorEntityDescription, ...] = (
+ HydrawiseSensorEntityDescription(
+ key="daily_active_water_time",
+ translation_key="daily_active_water_time",
+ device_class=SensorDeviceClass.DURATION,
+ native_unit_of_measurement=UnitOfTime.SECONDS,
+ value_fn=lambda sensor: (
+ _get_water_use(sensor)
+ .active_time_by_zone_id.get(sensor.zone.id, timedelta())
+ .total_seconds()
+ ),
+ ),
+)
+
FLOW_CONTROLLER_SENSORS: tuple[HydrawiseSensorEntityDescription, ...] = (
HydrawiseSensorEntityDescription(
key="daily_total_water_use",
translation_key="daily_total_water_use",
device_class=SensorDeviceClass.VOLUME,
suggested_display_precision=1,
- value_fn=_get_controller_daily_total_water_use,
+ value_fn=lambda sensor: _get_water_use(sensor).total_use,
),
HydrawiseSensorEntityDescription(
key="daily_active_water_use",
translation_key="daily_active_water_use",
device_class=SensorDeviceClass.VOLUME,
suggested_display_precision=1,
- value_fn=_get_controller_daily_active_water_use,
+ value_fn=lambda sensor: _get_water_use(sensor).total_active_use,
),
HydrawiseSensorEntityDescription(
key="daily_inactive_water_use",
translation_key="daily_inactive_water_use",
device_class=SensorDeviceClass.VOLUME,
suggested_display_precision=1,
- value_fn=_get_controller_daily_inactive_water_use,
+ value_fn=lambda sensor: _get_water_use(sensor).total_inactive_use,
),
)
@@ -133,7 +93,9 @@ FLOW_ZONE_SENSORS: tuple[SensorEntityDescription, ...] = (
translation_key="daily_active_water_use",
device_class=SensorDeviceClass.VOLUME,
suggested_display_precision=1,
- value_fn=_get_zone_daily_active_water_use,
+ value_fn=lambda sensor: float(
+ _get_water_use(sensor).active_use_by_zone_id.get(sensor.zone.id, 0.0)
+ ),
),
)
@@ -142,20 +104,24 @@ ZONE_SENSORS: tuple[HydrawiseSensorEntityDescription, ...] = (
key="next_cycle",
translation_key="next_cycle",
device_class=SensorDeviceClass.TIMESTAMP,
- value_fn=_get_zone_next_cycle,
+ value_fn=lambda sensor: (
+ dt_util.as_utc(sensor.zone.scheduled_runs.next_run.start_time)
+ if sensor.zone.scheduled_runs.next_run is not None
+ else None
+ ),
),
HydrawiseSensorEntityDescription(
key="watering_time",
translation_key="watering_time",
native_unit_of_measurement=UnitOfTime.MINUTES,
- value_fn=_get_zone_watering_time,
- ),
- HydrawiseSensorEntityDescription(
- key="daily_active_water_time",
- translation_key="daily_active_water_time",
- device_class=SensorDeviceClass.DURATION,
- native_unit_of_measurement=UnitOfTime.SECONDS,
- value_fn=_get_zone_daily_active_water_time,
+ value_fn=lambda sensor: (
+ int(
+ sensor.zone.scheduled_runs.current_run.remaining_time.total_seconds()
+ / 60
+ )
+ if sensor.zone.scheduled_runs.current_run is not None
+ else 0
+ ),
),
)
@@ -168,29 +134,37 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Hydrawise sensor platform."""
- coordinator: HydrawiseDataUpdateCoordinator = hass.data[DOMAIN][
- config_entry.entry_id
- ]
+ coordinators: HydrawiseUpdateCoordinators = hass.data[DOMAIN][config_entry.entry_id]
entities: list[HydrawiseSensor] = []
- for controller in coordinator.data.controllers.values():
+ for controller in coordinators.main.data.controllers.values():
entities.extend(
- HydrawiseSensor(coordinator, description, controller)
- for description in CONTROLLER_SENSORS
+ HydrawiseSensor(coordinators.water_use, description, controller)
+ for description in WATER_USE_CONTROLLER_SENSORS
)
entities.extend(
- HydrawiseSensor(coordinator, description, controller, zone_id=zone.id)
+ HydrawiseSensor(
+ coordinators.water_use, description, controller, zone_id=zone.id
+ )
+ for zone in controller.zones
+ for description in WATER_USE_ZONE_SENSORS
+ )
+ entities.extend(
+ HydrawiseSensor(coordinators.main, description, controller, zone_id=zone.id)
for zone in controller.zones
for description in ZONE_SENSORS
)
- if coordinator.data.daily_water_summary[controller.id].total_use is not None:
+ if (
+ coordinators.water_use.data.daily_water_summary[controller.id].total_use
+ is not None
+ ):
# we have a flow sensor for this controller
entities.extend(
- HydrawiseSensor(coordinator, description, controller)
+ HydrawiseSensor(coordinators.water_use, description, controller)
for description in FLOW_CONTROLLER_SENSORS
)
entities.extend(
HydrawiseSensor(
- coordinator,
+ coordinators.water_use,
description,
controller,
zone_id=zone.id,
diff --git a/homeassistant/components/hydrawise/switch.py b/homeassistant/components/hydrawise/switch.py
index 001a8e399ee..1addaf1ec92 100644
--- a/homeassistant/components/hydrawise/switch.py
+++ b/homeassistant/components/hydrawise/switch.py
@@ -20,7 +20,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util import dt as dt_util
from .const import DEFAULT_WATERING_TIME, DOMAIN
-from .coordinator import HydrawiseDataUpdateCoordinator
+from .coordinator import HydrawiseUpdateCoordinators
from .entity import HydrawiseEntity
@@ -66,12 +66,10 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Hydrawise switch platform."""
- coordinator: HydrawiseDataUpdateCoordinator = hass.data[DOMAIN][
- config_entry.entry_id
- ]
+ coordinators: HydrawiseUpdateCoordinators = hass.data[DOMAIN][config_entry.entry_id]
async_add_entities(
- HydrawiseSwitch(coordinator, description, controller, zone_id=zone.id)
- for controller in coordinator.data.controllers.values()
+ HydrawiseSwitch(coordinators.main, description, controller, zone_id=zone.id)
+ for controller in coordinators.main.data.controllers.values()
for zone in controller.zones
for description in SWITCH_TYPES
)
diff --git a/homeassistant/components/hydrawise/valve.py b/homeassistant/components/hydrawise/valve.py
index 6ceb3673c71..37f196bc054 100644
--- a/homeassistant/components/hydrawise/valve.py
+++ b/homeassistant/components/hydrawise/valve.py
@@ -17,7 +17,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN
-from .coordinator import HydrawiseDataUpdateCoordinator
+from .coordinator import HydrawiseUpdateCoordinators
from .entity import HydrawiseEntity
VALVE_TYPES: tuple[ValveEntityDescription, ...] = (
@@ -34,12 +34,10 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Hydrawise valve platform."""
- coordinator: HydrawiseDataUpdateCoordinator = hass.data[DOMAIN][
- config_entry.entry_id
- ]
+ coordinators: HydrawiseUpdateCoordinators = hass.data[DOMAIN][config_entry.entry_id]
async_add_entities(
- HydrawiseValve(coordinator, description, controller, zone_id=zone.id)
- for controller in coordinator.data.controllers.values()
+ HydrawiseValve(coordinators.main, description, controller, zone_id=zone.id)
+ for controller in coordinators.main.data.controllers.values()
for zone in controller.zones
for description in VALVE_TYPES
)
diff --git a/homeassistant/components/hyperion/manifest.json b/homeassistant/components/hyperion/manifest.json
index f18491044fa..684fb276f53 100644
--- a/homeassistant/components/hyperion/manifest.json
+++ b/homeassistant/components/hyperion/manifest.json
@@ -6,7 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/hyperion",
"iot_class": "local_push",
"loggers": ["hyperion"],
- "quality_scale": "platinum",
"requirements": ["hyperion-py==0.7.5"],
"ssdp": [
{
diff --git a/homeassistant/components/iammeter/manifest.json b/homeassistant/components/iammeter/manifest.json
index f1ebecab00d..22831767e62 100644
--- a/homeassistant/components/iammeter/manifest.json
+++ b/homeassistant/components/iammeter/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/iammeter",
"iot_class": "local_polling",
"loggers": ["iammeter"],
+ "quality_scale": "legacy",
"requirements": ["iammeter==0.2.1"]
}
diff --git a/homeassistant/components/iaqualink/climate.py b/homeassistant/components/iaqualink/climate.py
index 78da1eff071..53d1bce80de 100644
--- a/homeassistant/components/iaqualink/climate.py
+++ b/homeassistant/components/iaqualink/climate.py
@@ -54,7 +54,6 @@ class HassAqualinkThermostat(AqualinkEntity, ClimateEntity):
| ClimateEntityFeature.TURN_OFF
| ClimateEntityFeature.TURN_ON
)
- _enable_turn_on_off_backwards_compatibility = False
def __init__(self, dev: AqualinkThermostat) -> None:
"""Initialize AquaLink thermostat."""
diff --git a/homeassistant/components/iaqualink/config_flow.py b/homeassistant/components/iaqualink/config_flow.py
index 2cb1ba4b5d7..a307c1af98d 100644
--- a/homeassistant/components/iaqualink/config_flow.py
+++ b/homeassistant/components/iaqualink/config_flow.py
@@ -14,6 +14,7 @@ import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
+from homeassistant.helpers.httpx_client import get_async_client
from .const import DOMAIN
@@ -34,7 +35,9 @@ class AqualinkFlowHandler(ConfigFlow, domain=DOMAIN):
password = user_input[CONF_PASSWORD]
try:
- async with AqualinkClient(username, password):
+ async with AqualinkClient(
+ username, password, httpx_client=get_async_client(self.hass)
+ ):
pass
except AqualinkServiceUnauthorizedException:
errors["base"] = "invalid_auth"
diff --git a/homeassistant/components/idasen_desk/__init__.py b/homeassistant/components/idasen_desk/__init__.py
index 56a377ac2df..671319e46eb 100644
--- a/homeassistant/components/idasen_desk/__init__.py
+++ b/homeassistant/components/idasen_desk/__init__.py
@@ -4,53 +4,31 @@ from __future__ import annotations
import logging
-from attr import dataclass
from bleak.exc import BleakError
from idasen_ha.errors import AuthFailedError
from homeassistant.components import bluetooth
from homeassistant.components.bluetooth.match import ADDRESS, BluetoothCallbackMatcher
from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import (
- ATTR_NAME,
- CONF_ADDRESS,
- EVENT_HOMEASSISTANT_STOP,
- Platform,
-)
+from homeassistant.const import CONF_ADDRESS, EVENT_HOMEASSISTANT_STOP, Platform
from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady
-from homeassistant.helpers import device_registry as dr
-from homeassistant.helpers.device_registry import DeviceInfo
-from .const import DOMAIN
from .coordinator import IdasenDeskCoordinator
PLATFORMS: list[Platform] = [Platform.BUTTON, Platform.COVER, Platform.SENSOR]
_LOGGER = logging.getLogger(__name__)
-
-@dataclass
-class DeskData:
- """Data for the Idasen Desk integration."""
-
- address: str
- device_info: DeviceInfo
- coordinator: IdasenDeskCoordinator
+type IdasenDeskConfigEntry = ConfigEntry[IdasenDeskCoordinator]
-async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_setup_entry(hass: HomeAssistant, entry: IdasenDeskConfigEntry) -> bool:
"""Set up IKEA Idasen from a config entry."""
address: str = entry.data[CONF_ADDRESS].upper()
- coordinator = IdasenDeskCoordinator(hass, _LOGGER, entry.title, address)
- device_info = DeviceInfo(
- name=entry.title,
- connections={(dr.CONNECTION_BLUETOOTH, address)},
- )
- hass.data.setdefault(DOMAIN, {})[entry.entry_id] = DeskData(
- address, device_info, coordinator
- )
+ coordinator = IdasenDeskCoordinator(hass, entry.title, address)
+ entry.runtime_data = coordinator
try:
if not await coordinator.async_connect():
@@ -89,18 +67,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
return True
-async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
+async def _async_update_listener(
+ hass: HomeAssistant, entry: IdasenDeskConfigEntry
+) -> None:
"""Handle options update."""
- data: DeskData = hass.data[DOMAIN][entry.entry_id]
- if entry.title != data.device_info[ATTR_NAME]:
- await hass.config_entries.async_reload(entry.entry_id)
+ 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: IdasenDeskConfigEntry) -> bool:
"""Unload a config entry."""
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
- data: DeskData = hass.data[DOMAIN].pop(entry.entry_id)
- await data.coordinator.async_disconnect()
- bluetooth.async_rediscover_address(hass, data.address)
+ coordinator = entry.runtime_data
+ await coordinator.async_disconnect()
+ bluetooth.async_rediscover_address(hass, coordinator.address)
return unload_ok
diff --git a/homeassistant/components/idasen_desk/button.py b/homeassistant/components/idasen_desk/button.py
index 0de3125576d..cd7553da1ac 100644
--- a/homeassistant/components/idasen_desk/button.py
+++ b/homeassistant/components/idasen_desk/button.py
@@ -6,14 +6,12 @@ import logging
from typing import Any, Final
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.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from . import DeskData, IdasenDeskCoordinator
-from .const import DOMAIN
+from . import IdasenDeskConfigEntry, IdasenDeskCoordinator
+from .entity import IdasenDeskEntity
_LOGGER = logging.getLogger(__name__)
@@ -45,43 +43,38 @@ BUTTONS: Final = [
async def async_setup_entry(
hass: HomeAssistant,
- entry: ConfigEntry,
+ entry: IdasenDeskConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set buttons for device."""
- data: DeskData = hass.data[DOMAIN][entry.entry_id]
- async_add_entities(
- IdasenDeskButton(data.address, data.device_info, data.coordinator, button)
- for button in BUTTONS
- )
+ coordinator = entry.runtime_data
+ async_add_entities(IdasenDeskButton(coordinator, button) for button in BUTTONS)
-class IdasenDeskButton(ButtonEntity):
+class IdasenDeskButton(IdasenDeskEntity, ButtonEntity):
"""Defines a IdasenDesk button."""
entity_description: IdasenDeskButtonDescription
- _attr_has_entity_name = True
def __init__(
self,
- address: str,
- device_info: DeviceInfo,
coordinator: IdasenDeskCoordinator,
description: IdasenDeskButtonDescription,
) -> None:
"""Initialize the IdasenDesk button entity."""
+ super().__init__(f"{description.key}-{coordinator.address}", coordinator)
self.entity_description = description
- self._attr_unique_id = f"{description.key}-{address}"
- self._attr_device_info = device_info
- self._address = address
- self._coordinator = coordinator
-
async def async_press(self) -> None:
"""Triggers the IdasenDesk button press service."""
_LOGGER.debug(
"Trigger %s for %s",
self.entity_description.key,
- self._address,
+ self.coordinator.address,
)
- await self.entity_description.press_action(self._coordinator)()
+ await self.entity_description.press_action(self.coordinator)()
+
+ @property
+ def available(self) -> bool:
+ """Connect/disconnect buttons should always be available."""
+ return True
diff --git a/homeassistant/components/idasen_desk/coordinator.py b/homeassistant/components/idasen_desk/coordinator.py
index 0661f2dede1..d9e90cfe5ea 100644
--- a/homeassistant/components/idasen_desk/coordinator.py
+++ b/homeassistant/components/idasen_desk/coordinator.py
@@ -19,27 +19,26 @@ class IdasenDeskCoordinator(DataUpdateCoordinator[int | None]):
def __init__(
self,
hass: HomeAssistant,
- logger: logging.Logger,
name: str,
address: str,
) -> None:
"""Init IdasenDeskCoordinator."""
- super().__init__(hass, logger, name=name)
- self._address = address
+ super().__init__(hass, _LOGGER, name=name)
+ self.address = address
self._expected_connected = False
self.desk = Desk(self.async_set_updated_data)
async def async_connect(self) -> bool:
"""Connect to desk."""
- _LOGGER.debug("Trying to connect %s", self._address)
+ _LOGGER.debug("Trying to connect %s", self.address)
self._expected_connected = True
ble_device = bluetooth.async_ble_device_from_address(
- self.hass, self._address, connectable=True
+ self.hass, self.address, connectable=True
)
if ble_device is None:
- _LOGGER.debug("No BLEDevice for %s", self._address)
+ _LOGGER.debug("No BLEDevice for %s", self.address)
return False
await self.desk.connect(ble_device)
return True
@@ -47,7 +46,7 @@ class IdasenDeskCoordinator(DataUpdateCoordinator[int | None]):
async def async_disconnect(self) -> None:
"""Disconnect from desk."""
self._expected_connected = False
- _LOGGER.debug("Disconnecting from %s", self._address)
+ _LOGGER.debug("Disconnecting from %s", self.address)
await self.desk.disconnect()
async def async_connect_if_expected(self) -> None:
diff --git a/homeassistant/components/idasen_desk/cover.py b/homeassistant/components/idasen_desk/cover.py
index eb6bf5523de..a8ba0983e99 100644
--- a/homeassistant/components/idasen_desk/cover.py
+++ b/homeassistant/components/idasen_desk/cover.py
@@ -12,30 +12,25 @@ from homeassistant.components.cover import (
CoverEntity,
CoverEntityFeature,
)
-from homeassistant.config_entries import ConfigEntry
-from homeassistant.core import HomeAssistant, callback
+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 homeassistant.helpers.update_coordinator import CoordinatorEntity
-from . import DeskData, IdasenDeskCoordinator
-from .const import DOMAIN
+from . import IdasenDeskConfigEntry, IdasenDeskCoordinator
+from .entity import IdasenDeskEntity
async def async_setup_entry(
hass: HomeAssistant,
- entry: ConfigEntry,
+ entry: IdasenDeskConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the cover platform for Idasen Desk."""
- data: DeskData = hass.data[DOMAIN][entry.entry_id]
- async_add_entities(
- [IdasenDeskCover(data.address, data.device_info, data.coordinator)]
- )
+ coordinator = entry.runtime_data
+ async_add_entities([IdasenDeskCover(coordinator)])
-class IdasenDeskCover(CoordinatorEntity[IdasenDeskCoordinator], CoverEntity):
+class IdasenDeskCover(IdasenDeskEntity, CoverEntity):
"""Representation of Idasen Desk device."""
_attr_device_class = CoverDeviceClass.DAMPER
@@ -45,28 +40,12 @@ class IdasenDeskCover(CoordinatorEntity[IdasenDeskCoordinator], CoverEntity):
| CoverEntityFeature.STOP
| CoverEntityFeature.SET_POSITION
)
- _attr_has_entity_name = True
_attr_name = None
_attr_translation_key = "desk"
- def __init__(
- self,
- address: str,
- device_info: DeviceInfo,
- coordinator: IdasenDeskCoordinator,
- ) -> None:
+ def __init__(self, coordinator: IdasenDeskCoordinator) -> None:
"""Initialize an Idasen Desk cover."""
- super().__init__(coordinator)
- self._desk = coordinator.desk
- self._attr_unique_id = address
- self._attr_device_info = device_info
-
- self._attr_current_cover_position = self._desk.height_percent
-
- @property
- def available(self) -> bool:
- """Return True if entity is available."""
- return super().available and self._desk.is_connected is True
+ super().__init__(coordinator.address, coordinator)
@property
def is_closed(self) -> bool:
@@ -103,8 +82,7 @@ class IdasenDeskCover(CoordinatorEntity[IdasenDeskCoordinator], CoverEntity):
"Failed to move to specified position: Bluetooth error"
) from err
- @callback
- def _handle_coordinator_update(self, *args: Any) -> None:
- """Handle data update."""
- self._attr_current_cover_position = self._desk.height_percent
- self.async_write_ha_state()
+ @property
+ def current_cover_position(self) -> int | None:
+ """Return the current cover position."""
+ return self._desk.height_percent
diff --git a/homeassistant/components/idasen_desk/entity.py b/homeassistant/components/idasen_desk/entity.py
new file mode 100644
index 00000000000..bda7afd528c
--- /dev/null
+++ b/homeassistant/components/idasen_desk/entity.py
@@ -0,0 +1,34 @@
+"""Base entity for Idasen Desk."""
+
+from __future__ import annotations
+
+from homeassistant.helpers import device_registry as dr
+from homeassistant.helpers.update_coordinator import CoordinatorEntity
+
+from . import IdasenDeskCoordinator
+
+
+class IdasenDeskEntity(CoordinatorEntity[IdasenDeskCoordinator]):
+ """IdasenDesk sensor."""
+
+ _attr_has_entity_name = True
+
+ def __init__(
+ self,
+ unique_id: str,
+ coordinator: IdasenDeskCoordinator,
+ ) -> None:
+ """Initialize the IdasenDesk sensor entity."""
+ super().__init__(coordinator)
+
+ self._attr_unique_id = unique_id
+ self._attr_device_info = dr.DeviceInfo(
+ manufacturer="LINAK",
+ connections={(dr.CONNECTION_BLUETOOTH, coordinator.address)},
+ )
+ self._desk = coordinator.desk
+
+ @property
+ def available(self) -> bool:
+ """Return True if entity is available."""
+ return super().available and self._desk.is_connected is True
diff --git a/homeassistant/components/idasen_desk/manifest.json b/homeassistant/components/idasen_desk/manifest.json
index 17a5f519274..9e83347f098 100644
--- a/homeassistant/components/idasen_desk/manifest.json
+++ b/homeassistant/components/idasen_desk/manifest.json
@@ -10,7 +10,8 @@
"config_flow": true,
"dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/idasen_desk",
+ "integration_type": "device",
"iot_class": "local_push",
- "quality_scale": "silver",
- "requirements": ["idasen-ha==2.6.2"]
+ "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
new file mode 100644
index 00000000000..34bf97d9496
--- /dev/null
+++ b/homeassistant/components/idasen_desk/quality_scale.yaml
@@ -0,0 +1,101 @@
+rules:
+ # Bronze
+ action-setup:
+ status: exempt
+ comment: |
+ This integration does not provide additional actions.
+ appropriate-polling:
+ status: exempt
+ comment: |
+ This integration does not use polling.
+ 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: done
+ docs-configuration-parameters:
+ status: exempt
+ comment: |
+ This integration does not provide configuration parameters.
+ docs-installation-parameters:
+ status: exempt
+ comment: |
+ This integration does not provide installation parameters.
+ entity-unavailable: done
+ integration-owner: done
+ log-when-unavailable: todo
+ parallel-updates: todo
+ reauthentication-flow:
+ status: exempt
+ comment: |
+ This integration does not require authentication.
+ test-coverage:
+ status: todo
+ comment: |
+ - remove the await hass.async_block_till_done() after service calls with blocking=True
+ - use constants (like SERVICE_PRESS and ATTR_ENTITY_ID) in the tests calling services
+ - rename test_buttons.py -> test_button.py
+ - rename test_sensors.py -> test_sensor.py
+ # Gold
+ devices: done
+ diagnostics: todo
+ discovery-update-info:
+ status: exempt
+ comment: |
+ This integration uses Bluetooth and addresses don't change.
+ discovery: done
+ 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 has one device per config entry.
+ entity-category: done
+ entity-device-class: done
+ entity-disabled-by-default: done
+ entity-translations: done
+ exception-translations: todo
+ icon-translations: done
+ reconfiguration-flow:
+ status: exempt
+ comment: |
+ This integration doesn't have any cases where a reconfiguration is needed.
+ 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:
+ status: exempt
+ comment: |
+ This integration doesn't use websession.
+ strict-typing: todo
diff --git a/homeassistant/components/idasen_desk/sensor.py b/homeassistant/components/idasen_desk/sensor.py
index 8ed85d21a34..4613d316a52 100644
--- a/homeassistant/components/idasen_desk/sensor.py
+++ b/homeassistant/components/idasen_desk/sensor.py
@@ -4,9 +4,7 @@ from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
-from typing import Any
-from homeassistant import config_entries
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
@@ -14,13 +12,11 @@ from homeassistant.components.sensor import (
SensorStateClass,
)
from homeassistant.const import UnitOfLength
-from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.device_registry import DeviceInfo
+from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from homeassistant.helpers.update_coordinator import CoordinatorEntity
-from . import DeskData, IdasenDeskCoordinator
-from .const import DOMAIN
+from . import IdasenDeskConfigEntry, IdasenDeskCoordinator
+from .entity import IdasenDeskEntity
@dataclass(frozen=True, kw_only=True)
@@ -46,57 +42,32 @@ SENSORS = (
async def async_setup_entry(
hass: HomeAssistant,
- entry: config_entries.ConfigEntry,
+ entry: IdasenDeskConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Idasen Desk sensors."""
- data: DeskData = hass.data[DOMAIN][entry.entry_id]
+ coordinator = entry.runtime_data
async_add_entities(
- IdasenDeskSensor(
- data.address, data.device_info, data.coordinator, sensor_description
- )
+ IdasenDeskSensor(coordinator, sensor_description)
for sensor_description in SENSORS
)
-class IdasenDeskSensor(CoordinatorEntity[IdasenDeskCoordinator], SensorEntity):
+class IdasenDeskSensor(IdasenDeskEntity, SensorEntity):
"""IdasenDesk sensor."""
entity_description: IdasenDeskSensorDescription
- _attr_has_entity_name = True
def __init__(
self,
- address: str,
- device_info: DeviceInfo,
coordinator: IdasenDeskCoordinator,
description: IdasenDeskSensorDescription,
) -> None:
"""Initialize the IdasenDesk sensor entity."""
- super().__init__(coordinator)
+ super().__init__(f"{description.key}-{coordinator.address}", coordinator)
self.entity_description = description
- self._attr_unique_id = f"{description.key}-{address}"
- self._attr_device_info = device_info
- self._address = address
- self._desk = coordinator.desk
-
- async def async_added_to_hass(self) -> None:
- """When entity is added to hass."""
- await super().async_added_to_hass()
- self._update_native_value()
-
@property
- def available(self) -> bool:
- """Return True if entity is available."""
- return super().available and self._desk.is_connected is True
-
- @callback
- def _handle_coordinator_update(self, *args: Any) -> None:
- """Handle data update."""
- self._update_native_value()
- super()._handle_coordinator_update()
-
- def _update_native_value(self) -> None:
- """Update the native value attribute."""
- self._attr_native_value = self.entity_description.value_fn(self.coordinator)
+ def native_value(self) -> float | None:
+ """Return the value reported by the sensor."""
+ return self.entity_description.value_fn(self.coordinator)
diff --git a/homeassistant/components/idasen_desk/strings.json b/homeassistant/components/idasen_desk/strings.json
index 70e08976925..7486973638b 100644
--- a/homeassistant/components/idasen_desk/strings.json
+++ b/homeassistant/components/idasen_desk/strings.json
@@ -4,7 +4,10 @@
"step": {
"user": {
"data": {
- "address": "Bluetooth address"
+ "address": "Device"
+ },
+ "data_description": {
+ "address": "The bluetooth device for the desk."
}
}
},
diff --git a/homeassistant/components/idteck_prox/manifest.json b/homeassistant/components/idteck_prox/manifest.json
index e1d9b8a7ba8..92055908591 100644
--- a/homeassistant/components/idteck_prox/manifest.json
+++ b/homeassistant/components/idteck_prox/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/idteck_prox",
"iot_class": "local_push",
"loggers": ["rfk101py"],
+ "quality_scale": "legacy",
"requirements": ["rfk101py==0.0.1"]
}
diff --git a/homeassistant/components/iglo/light.py b/homeassistant/components/iglo/light.py
index a31183f4489..0d20761c6e5 100644
--- a/homeassistant/components/iglo/light.py
+++ b/homeassistant/components/iglo/light.py
@@ -2,7 +2,6 @@
from __future__ import annotations
-import math
from typing import Any
from iglo import Lamp
@@ -11,7 +10,7 @@ import voluptuous as vol
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
- ATTR_COLOR_TEMP,
+ ATTR_COLOR_TEMP_KELVIN,
ATTR_EFFECT,
ATTR_HS_COLOR,
PLATFORM_SCHEMA as LIGHT_PLATFORM_SCHEMA,
@@ -83,23 +82,19 @@ class IGloLamp(LightEntity):
return ColorMode.HS
@property
- def color_temp(self):
- """Return the color temperature."""
- return color_util.color_temperature_kelvin_to_mired(self._lamp.state()["white"])
+ def color_temp_kelvin(self) -> int | None:
+ """Return the color temperature value in Kelvin."""
+ return self._lamp.state()["white"]
@property
- def min_mireds(self) -> int:
- """Return the coldest color_temp that this light supports."""
- return math.ceil(
- color_util.color_temperature_kelvin_to_mired(self._lamp.max_kelvin)
- )
+ def max_color_temp_kelvin(self) -> int:
+ """Return the coldest color_temp_kelvin that this light supports."""
+ return self._lamp.max_kelvin
@property
- def max_mireds(self) -> int:
- """Return the warmest color_temp that this light supports."""
- return math.ceil(
- color_util.color_temperature_kelvin_to_mired(self._lamp.min_kelvin)
- )
+ def min_color_temp_kelvin(self) -> int:
+ """Return the warmest color_temp_kelvin that this light supports."""
+ return self._lamp.min_kelvin
@property
def hs_color(self):
@@ -135,11 +130,8 @@ class IGloLamp(LightEntity):
self._lamp.rgb(*rgb)
return
- if ATTR_COLOR_TEMP in kwargs:
- kelvin = int(
- color_util.color_temperature_mired_to_kelvin(kwargs[ATTR_COLOR_TEMP])
- )
- self._lamp.white(kelvin)
+ if ATTR_COLOR_TEMP_KELVIN in kwargs:
+ self._lamp.white(kwargs[ATTR_COLOR_TEMP_KELVIN])
return
if ATTR_EFFECT in kwargs:
diff --git a/homeassistant/components/iglo/manifest.json b/homeassistant/components/iglo/manifest.json
index f270d06bcae..7ce4804a516 100644
--- a/homeassistant/components/iglo/manifest.json
+++ b/homeassistant/components/iglo/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/iglo",
"iot_class": "local_polling",
"loggers": ["iglo"],
+ "quality_scale": "legacy",
"requirements": ["iglo==1.2.7"]
}
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..28e287db2ab
--- /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.0.6"]
+}
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/manifest.json b/homeassistant/components/ign_sismologia/manifest.json
index c76013f6821..d371f0d3614 100644
--- a/homeassistant/components/ign_sismologia/manifest.json
+++ b/homeassistant/components/ign_sismologia/manifest.json
@@ -6,5 +6,6 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["georss_ign_sismologia_client"],
+ "quality_scale": "legacy",
"requirements": ["georss-ign-sismologia-client==0.8"]
}
diff --git a/homeassistant/components/ihc/manifest.json b/homeassistant/components/ihc/manifest.json
index 2400206c3a0..68cc1b2c754 100644
--- a/homeassistant/components/ihc/manifest.json
+++ b/homeassistant/components/ihc/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/ihc",
"iot_class": "local_push",
"loggers": ["ihcsdk"],
+ "quality_scale": "legacy",
"requirements": ["defusedxml==0.7.1", "ihcsdk==2.8.5"]
}
diff --git a/homeassistant/components/image_upload/manifest.json b/homeassistant/components/image_upload/manifest.json
index 963721a0476..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==10.4.0"]
+ "requirements": ["Pillow==11.1.0"]
}
diff --git a/homeassistant/components/image_upload/media_source.py b/homeassistant/components/image_upload/media_source.py
new file mode 100644
index 00000000000..ee9511e2c36
--- /dev/null
+++ b/homeassistant/components/image_upload/media_source.py
@@ -0,0 +1,76 @@
+"""Expose image_upload as media sources."""
+
+from __future__ import annotations
+
+from homeassistant.components.media_player import BrowseError, MediaClass
+from homeassistant.components.media_source import (
+ BrowseMediaSource,
+ MediaSource,
+ MediaSourceItem,
+ PlayMedia,
+ Unresolvable,
+)
+from homeassistant.core import HomeAssistant
+
+from .const import DOMAIN
+
+
+async def async_get_media_source(hass: HomeAssistant) -> ImageUploadMediaSource:
+ """Set up image media source."""
+ return ImageUploadMediaSource(hass)
+
+
+class ImageUploadMediaSource(MediaSource):
+ """Provide images as media sources."""
+
+ name: str = "Image Upload"
+
+ def __init__(self, hass: HomeAssistant) -> None:
+ """Initialize ImageMediaSource."""
+ super().__init__(DOMAIN)
+ self.hass = hass
+
+ async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia:
+ """Resolve media to a url."""
+ image = self.hass.data[DOMAIN].data.get(item.identifier)
+
+ if not image:
+ raise Unresolvable(f"Could not resolve media item: {item.identifier}")
+
+ return PlayMedia(
+ f"/api/image/serve/{image['id']}/original", image["content_type"]
+ )
+
+ async def async_browse_media(
+ self,
+ item: MediaSourceItem,
+ ) -> BrowseMediaSource:
+ """Return media."""
+ if item.identifier:
+ raise BrowseError("Unknown item")
+
+ children = [
+ BrowseMediaSource(
+ domain=DOMAIN,
+ identifier=image["id"],
+ media_class=MediaClass.IMAGE,
+ media_content_type=image["content_type"],
+ title=image["name"],
+ thumbnail=f"/api/image/serve/{image['id']}/256x256",
+ can_play=True,
+ can_expand=False,
+ )
+ for image in self.hass.data[DOMAIN].data.values()
+ ]
+
+ return BrowseMediaSource(
+ domain=DOMAIN,
+ identifier=None,
+ media_class=MediaClass.APP,
+ media_content_type="",
+ title="Image Upload",
+ can_play=False,
+ can_expand=True,
+ children_media_class=MediaClass.IMAGE,
+ children=children,
+ )
diff --git a/homeassistant/components/imap/config_flow.py b/homeassistant/components/imap/config_flow.py
index 994c53b5b3e..df0e63e200a 100644
--- a/homeassistant/components/imap/config_flow.py
+++ b/homeassistant/components/imap/config_flow.py
@@ -9,12 +9,7 @@ from typing import Any
from aioimaplib import AioImapException
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_NAME,
CONF_PASSWORD,
@@ -35,6 +30,7 @@ from homeassistant.helpers.selector import (
)
from homeassistant.util.ssl import SSLCipherList
+from . import ImapConfigEntry
from .const import (
CONF_CHARSET,
CONF_CUSTOM_EVENT_DATA_TEMPLATE,
@@ -212,7 +208,7 @@ class IMAPConfigFlow(ConfigFlow, domain=DOMAIN):
@staticmethod
@callback
def async_get_options_flow(
- config_entry: ConfigEntry,
+ config_entry: ImapConfigEntry,
) -> ImapOptionsFlow:
"""Get the options flow for this handler."""
return ImapOptionsFlow()
diff --git a/homeassistant/components/imap/coordinator.py b/homeassistant/components/imap/coordinator.py
index a9d0fdfbd48..1df107196ff 100644
--- a/homeassistant/components/imap/coordinator.py
+++ b/homeassistant/components/imap/coordinator.py
@@ -14,7 +14,6 @@ from typing import TYPE_CHECKING, Any
from aioimaplib import AUTH, IMAP4_SSL, NONAUTH, SELECTED, AioImapException
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONF_PASSWORD,
CONF_PORT,
@@ -53,6 +52,9 @@ from .const import (
)
from .errors import InvalidAuth, InvalidFolder
+if TYPE_CHECKING:
+ from . import ImapConfigEntry
+
_LOGGER = logging.getLogger(__name__)
BACKOFF_TIME = 10
@@ -210,14 +212,14 @@ class ImapMessage:
class ImapDataUpdateCoordinator(DataUpdateCoordinator[int | None]):
"""Base class for imap client."""
- config_entry: ConfigEntry
+ config_entry: ImapConfigEntry
custom_event_template: Template | None
def __init__(
self,
hass: HomeAssistant,
imap_client: IMAP4_SSL,
- entry: ConfigEntry,
+ entry: ImapConfigEntry,
update_interval: timedelta | None,
) -> None:
"""Initiate imap client."""
@@ -332,7 +334,17 @@ class ImapDataUpdateCoordinator(DataUpdateCoordinator[int | None]):
raise UpdateFailed(
f"Invalid response for search '{self.config_entry.data[CONF_SEARCH]}': {result} / {lines[0]}"
)
- if not (count := len(message_ids := lines[0].split())):
+ # Check we do have returned items.
+ #
+ # In rare cases, when no UID's are returned,
+ # only the status line is returned, and not an empty line.
+ # See: https://github.com/home-assistant/core/issues/132042
+ #
+ # Strictly the RfC notes that 0 or more numbers should be returned
+ # delimited by a space.
+ #
+ # See: https://datatracker.ietf.org/doc/html/rfc3501#section-7.2.5
+ if len(lines) == 1 or not (count := len(message_ids := lines[0].split())):
self._last_message_uid = None
return 0
last_message_uid = (
@@ -391,7 +403,7 @@ class ImapPollingDataUpdateCoordinator(ImapDataUpdateCoordinator):
"""Class for imap client."""
def __init__(
- self, hass: HomeAssistant, imap_client: IMAP4_SSL, entry: ConfigEntry
+ self, hass: HomeAssistant, imap_client: IMAP4_SSL, entry: ImapConfigEntry
) -> None:
"""Initiate imap client."""
_LOGGER.debug(
@@ -437,7 +449,7 @@ class ImapPushDataUpdateCoordinator(ImapDataUpdateCoordinator):
"""Class for imap client."""
def __init__(
- self, hass: HomeAssistant, imap_client: IMAP4_SSL, entry: ConfigEntry
+ self, hass: HomeAssistant, imap_client: IMAP4_SSL, entry: ImapConfigEntry
) -> None:
"""Initiate imap client."""
_LOGGER.debug("Connected to server %s using IMAP push", entry.data[CONF_SERVER])
diff --git a/homeassistant/components/imap/quality_scale.yaml b/homeassistant/components/imap/quality_scale.yaml
new file mode 100644
index 00000000000..1c75b527882
--- /dev/null
+++ b/homeassistant/components/imap/quality_scale.yaml
@@ -0,0 +1,100 @@
+rules:
+ # Bronze
+ action-setup: done
+ appropriate-polling: done
+ brands: done
+ common-modules: done
+ config-flow-test-coverage: done
+ config-flow: done
+ dependency-transparency:
+ status: todo
+ comment: |
+ The package is only tested, but not built and published inside a CI pipeline yet.
+ docs-actions: done
+ docs-high-level-description: done
+ docs-installation-instructions: done
+ docs-removal-instructions: done
+ entity-event-setup:
+ status: exempt
+ comment: >
+ Per IMAP service instance there is one numeric sensor entity to reflect
+ the actual number of emails for a service. There is no event registration.
+ 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
+ log-when-unavailable:
+ status: done
+ comment: |
+ Logs for unavailability are on debug level to avoid flooding the logs.
+ entity-unavailable:
+ status: done
+ comment: >
+ An entity is available as long as the service is loaded.
+ An `unknown` value is set if the mail service is temporary unavailable.
+ action-exceptions: done
+ reauthentication-flow: done
+ parallel-updates: done
+ test-coverage: done
+ integration-owner: done
+ docs-installation-parameters: done
+ docs-configuration-parameters: done
+
+ # Gold
+ entity-translations: done
+ entity-device-class: done
+ devices: done
+ entity-category: done
+ entity-disabled-by-default:
+ status: done
+ comment: The only entity supplied returns the primary value for the service.
+ discovery:
+ status: exempt
+ comment: |
+ Discovery for IMAP services is not desirerable.
+ stale-devices:
+ status: exempt
+ comment: >
+ The device class is a service. When removed, entities are removed as well.
+ diagnostics: done
+ exception-translations: done
+ icon-translations: done
+ reconfiguration-flow:
+ status: todo
+ comment: |
+ Options can be set through the option flow, reconfiguration is not supported yet.
+ dynamic-devices:
+ status: exempt
+ comment: |
+ The device class is a service.
+ discovery-update-info:
+ status: exempt
+ comment: Discovery is not desirable for this integration.
+ repair-issues:
+ status: exempt
+ comment: There are no repairs currently.
+ docs-use-cases: done
+ docs-supported-devices:
+ status: exempt
+ comment: The device class is a service.
+ docs-supported-functions: done
+ docs-data-update: done
+ docs-known-limitations: done
+ docs-troubleshooting: done
+ docs-examples: done
+
+ # Platinum
+ async-dependency: done
+ inject-websession:
+ status: exempt
+ comment: |
+ This integration does not use web sessions.
+ strict-typing:
+ status: todo
+ comment: |
+ Requirement 'aioimaplib==1.1.0' appears untyped
diff --git a/homeassistant/components/imap/sensor.py b/homeassistant/components/imap/sensor.py
index 625af9ce6a1..60892388252 100644
--- a/homeassistant/components/imap/sensor.py
+++ b/homeassistant/components/imap/sensor.py
@@ -7,7 +7,7 @@ from homeassistant.components.sensor import (
SensorEntityDescription,
SensorStateClass,
)
-from homeassistant.const import CONF_USERNAME
+from homeassistant.const import CONF_USERNAME, EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
@@ -17,12 +17,15 @@ from . import ImapConfigEntry
from .const import DOMAIN
from .coordinator import ImapDataUpdateCoordinator
+# Coordinator is used to centralize the data updates
+PARALLEL_UPDATES = 0
+
IMAP_MAIL_COUNT_DESCRIPTION = SensorEntityDescription(
key="imap_mail_count",
+ entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=0,
translation_key="imap_mail_count",
- name=None,
)
diff --git a/homeassistant/components/imap/strings.json b/homeassistant/components/imap/strings.json
index 7c4a0d9a973..8ff5d838199 100644
--- a/homeassistant/components/imap/strings.json
+++ b/homeassistant/components/imap/strings.json
@@ -10,8 +10,21 @@
"charset": "Character set",
"folder": "Folder",
"search": "IMAP search",
+ "event_message_data": "Message data to be included in the `imap_content` event data:",
"ssl_cipher_list": "SSL cipher list (Advanced)",
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
+ },
+ "data_description": {
+ "username": "The IMAP username.",
+ "password": "The IMAP password",
+ "server": "The IMAP server.",
+ "port": "The IMAP port supporting SSL, usually this is 993.",
+ "charset": "The character set used. Common values are `utf-8` or `US-ASCII`.",
+ "folder": "In generally the folder is set to `INBOX`, but e.g. in case of a sub folder, named `Test`, this should be `INBOX.Test`.",
+ "search": "The IMAP search command which is `UnSeen UnDeleted` by default.",
+ "event_message_data": "Note that the event size is limited, and not all message text might be sent with the event if the message is too large.",
+ "ssl_cipher_list": "If the IMAP service only supports legacy encryption, try to change this.",
+ "verify_ssl": "Recommended, to ensure the server certificate is valid. Turn off, if the server certificate is not trusted (e.g. self signed)."
}
},
"reauth_confirm": {
@@ -19,6 +32,9 @@
"title": "[%key:common::config_flow::title::reauth%]",
"data": {
"password": "[%key:common::config_flow::data::password%]"
+ },
+ "data_description": {
+ "password": "Correct the IMAP password."
}
}
},
@@ -35,6 +51,14 @@
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
}
},
+ "entity": {
+ "sensor": {
+ "imap_mail_count": {
+ "name": "Messages",
+ "unit_of_measurement": "messages"
+ }
+ }
+ },
"exceptions": {
"copy_failed": {
"message": "Copying the message failed with \"{error}\"."
@@ -73,7 +97,15 @@
"custom_event_data_template": "Template to create custom event data",
"max_message_size": "Max message size (2048 < size < 30000)",
"enable_push": "Enable Push-IMAP if the server supports it. Turn off if Push-IMAP updates are unreliable.",
- "event_message_data": "Message data to be included in the `imap_content` event data:"
+ "event_message_data": "Message data to be included in the `imap_content` event data."
+ },
+ "data_description": {
+ "folder": "[%key:component::imap::config::step::user::data_description::folder%]",
+ "search": "[%key:component::imap::config::step::user::data_description::search%]",
+ "event_message_data": "[%key:component::imap::config::step::user::data_description::event_message_data%]",
+ "custom_event_data_template": "This template is evaluated when a new message was received, and the result is added to the `custom` attribute of the event data.",
+ "max_message_size": "Limit the maximum size of the event. Instead of passing the (whole) text message, using a template is a better option.",
+ "enable_push": "Using Push-IMAP is recommended. Polling will increase the time to respond."
}
}
},
diff --git a/homeassistant/components/imgw_pib/manifest.json b/homeassistant/components/imgw_pib/manifest.json
index c01be10fc68..ce3bc14d37b 100644
--- a/homeassistant/components/imgw_pib/manifest.json
+++ b/homeassistant/components/imgw_pib/manifest.json
@@ -5,6 +5,5 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/imgw_pib",
"iot_class": "cloud_polling",
- "quality_scale": "platinum",
- "requirements": ["imgw_pib==1.0.6"]
+ "requirements": ["imgw_pib==1.0.7"]
}
diff --git a/homeassistant/components/incomfort/__init__.py b/homeassistant/components/incomfort/__init__.py
index 39e471b7614..4b6a6a5fcc3 100644
--- a/homeassistant/components/incomfort/__init__.py
+++ b/homeassistant/components/incomfort/__init__.py
@@ -4,33 +4,15 @@ from __future__ import annotations
from aiohttp import ClientResponseError
from incomfortclient import IncomfortError, InvalidHeaterList
-import voluptuous as vol
-from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
-from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform
-from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant
-from homeassistant.data_entry_flow import FlowResultType
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import Platform
+from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
-from homeassistant.helpers import config_validation as cv, issue_registry as ir
-from homeassistant.helpers.typing import ConfigType
-from .const import DOMAIN
from .coordinator import InComfortDataCoordinator, async_connect_gateway
from .errors import InConfortTimeout, InConfortUnknownError, NoHeaters, NotFound
-CONFIG_SCHEMA = vol.Schema(
- {
- DOMAIN: vol.Schema(
- {
- vol.Required(CONF_HOST): cv.string,
- vol.Inclusive(CONF_USERNAME, "credentials"): cv.string,
- vol.Inclusive(CONF_PASSWORD, "credentials"): cv.string,
- }
- )
- },
- extra=vol.ALLOW_EXTRA,
-)
-
PLATFORMS = (
Platform.WATER_HEATER,
Platform.BINARY_SENSOR,
@@ -43,53 +25,6 @@ INTEGRATION_TITLE = "Intergas InComfort/Intouch Lan2RF gateway"
type InComfortConfigEntry = ConfigEntry[InComfortDataCoordinator]
-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:
- ir.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=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.1.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(hass: HomeAssistant, hass_config: ConfigType) -> bool:
- """Create an Intergas InComfort/Intouch system."""
- if config := hass_config.get(DOMAIN):
- hass.async_create_task(_async_import(hass, config))
- return True
-
-
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up a config entry."""
try:
diff --git a/homeassistant/components/incomfort/binary_sensor.py b/homeassistant/components/incomfort/binary_sensor.py
index a94e1fac504..45da990d44f 100644
--- a/homeassistant/components/incomfort/binary_sensor.py
+++ b/homeassistant/components/incomfort/binary_sensor.py
@@ -20,6 +20,8 @@ from . import InComfortConfigEntry
from .coordinator import InComfortDataCoordinator
from .entity import IncomfortBoilerEntity
+PARALLEL_UPDATES = 0
+
@dataclass(frozen=True, kw_only=True)
class IncomfortBinarySensorEntityDescription(BinarySensorEntityDescription):
diff --git a/homeassistant/components/incomfort/climate.py b/homeassistant/components/incomfort/climate.py
index eccf03588dc..caebcfdb23b 100644
--- a/homeassistant/components/incomfort/climate.py
+++ b/homeassistant/components/incomfort/climate.py
@@ -22,6 +22,8 @@ from .const import DOMAIN
from .coordinator import InComfortDataCoordinator
from .entity import IncomfortEntity
+PARALLEL_UPDATES = 1
+
async def async_setup_entry(
hass: HomeAssistant,
@@ -46,7 +48,6 @@ class InComfortClimate(IncomfortEntity, ClimateEntity):
_attr_hvac_modes = [HVACMode.HEAT]
_attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE
_attr_temperature_unit = UnitOfTemperature.CELSIUS
- _enable_turn_on_off_backwards_compatibility = False
def __init__(
self,
diff --git a/homeassistant/components/incomfort/config_flow.py b/homeassistant/components/incomfort/config_flow.py
index e905f0d743d..f4838a9771d 100644
--- a/homeassistant/components/incomfort/config_flow.py
+++ b/homeassistant/components/incomfort/config_flow.py
@@ -81,11 +81,3 @@ class InComfortConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_show_form(
step_id="user", data_schema=CONFIG_SCHEMA, errors=errors
)
-
- async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult:
- """Import `incomfort` config entry from configuration.yaml."""
- errors: dict[str, str] | None = None
- if (errors := await async_try_connect_gateway(self.hass, import_data)) is None:
- return self.async_create_entry(title=TITLE, data=import_data)
- reason = next(iter(errors.items()))[1]
- return self.async_abort(reason=reason)
diff --git a/homeassistant/components/incomfort/manifest.json b/homeassistant/components/incomfort/manifest.json
index 40c93012eef..f404f33b970 100644
--- a/homeassistant/components/incomfort/manifest.json
+++ b/homeassistant/components/incomfort/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/incomfort",
"iot_class": "local_polling",
"loggers": ["incomfortclient"],
- "requirements": ["incomfort-client==0.6.3-1"]
+ "requirements": ["incomfort-client==0.6.4"]
}
diff --git a/homeassistant/components/incomfort/sensor.py b/homeassistant/components/incomfort/sensor.py
index e0d6740f1d4..e3cc52fb3a7 100644
--- a/homeassistant/components/incomfort/sensor.py
+++ b/homeassistant/components/incomfort/sensor.py
@@ -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):
diff --git a/homeassistant/components/incomfort/water_heater.py b/homeassistant/components/incomfort/water_heater.py
index e7620ac2a1a..5b676b3b7ff 100644
--- a/homeassistant/components/incomfort/water_heater.py
+++ b/homeassistant/components/incomfort/water_heater.py
@@ -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,
diff --git a/homeassistant/components/influxdb/manifest.json b/homeassistant/components/influxdb/manifest.json
index ad3f282eff7..55af2b37fb7 100644
--- a/homeassistant/components/influxdb/manifest.json
+++ b/homeassistant/components/influxdb/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/influxdb",
"iot_class": "local_push",
"loggers": ["influxdb", "influxdb_client"],
+ "quality_scale": "legacy",
"requirements": ["influxdb==5.3.1", "influxdb-client==1.24.0"]
}
diff --git a/homeassistant/components/input_datetime/__init__.py b/homeassistant/components/input_datetime/__init__.py
index dcc2865acad..428ffccb7c1 100644
--- a/homeassistant/components/input_datetime/__init__.py
+++ b/homeassistant/components/input_datetime/__init__.py
@@ -385,7 +385,7 @@ class InputDatetime(collection.CollectionEntity, RestoreEntity):
@callback
def async_set_datetime(self, date=None, time=None, datetime=None, timestamp=None):
"""Set a new date / time."""
- if timestamp:
+ if timestamp is not None:
datetime = dt_util.as_local(dt_util.utc_from_timestamp(timestamp))
if datetime:
diff --git a/homeassistant/components/input_number/strings.json b/homeassistant/components/input_number/strings.json
index 8a2351ebad4..ed6b6fad208 100644
--- a/homeassistant/components/input_number/strings.json
+++ b/homeassistant/components/input_number/strings.json
@@ -41,7 +41,7 @@
},
"increment": {
"name": "Increment",
- "description": "Increments the value by 1 step."
+ "description": "Increments the current value by 1 step."
},
"set_value": {
"name": "Set",
diff --git a/homeassistant/components/insteon/climate.py b/homeassistant/components/insteon/climate.py
index 3db8edbf1c9..506841e7efb 100644
--- a/homeassistant/components/insteon/climate.py
+++ b/homeassistant/components/insteon/climate.py
@@ -94,7 +94,6 @@ class InsteonClimateEntity(InsteonEntity, ClimateEntity):
_attr_hvac_modes = list(HVAC_MODES.values())
_attr_fan_modes = list(FAN_MODES.values())
_attr_min_humidity = 1
- _enable_turn_on_off_backwards_compatibility = False
@property
def temperature_unit(self) -> str:
diff --git a/homeassistant/components/insteon/fan.py b/homeassistant/components/insteon/fan.py
index c13e22bf8c5..0f1c70b9ea8 100644
--- a/homeassistant/components/insteon/fan.py
+++ b/homeassistant/components/insteon/fan.py
@@ -56,7 +56,6 @@ class InsteonFanEntity(InsteonEntity, FanEntity):
| FanEntityFeature.TURN_ON
)
_attr_speed_count = 3
- _enable_turn_on_off_backwards_compatibility = False
@property
def percentage(self) -> int | None:
diff --git a/homeassistant/components/integration/sensor.py b/homeassistant/components/integration/sensor.py
index a053e5cea5c..27aa74d0785 100644
--- a/homeassistant/components/integration/sensor.py
+++ b/homeassistant/components/integration/sensor.py
@@ -576,7 +576,7 @@ class IntegrationSensor(RestoreSensor):
if (
self._max_sub_interval is not None
and source_state is not None
- and (source_state_dec := _decimal_state(source_state.state))
+ and (source_state_dec := _decimal_state(source_state.state)) is not None
):
@callback
diff --git a/homeassistant/components/integration/strings.json b/homeassistant/components/integration/strings.json
index 6186521aa1b..ed4f5de3ea7 100644
--- a/homeassistant/components/integration/strings.json
+++ b/homeassistant/components/integration/strings.json
@@ -3,7 +3,7 @@
"config": {
"step": {
"user": {
- "title": "Add Riemann sum integral sensor",
+ "title": "Create Riemann sum integral sensor",
"description": "Create a sensor that calculates a Riemann sum to estimate the integral of a sensor.",
"data": {
"method": "Integration method",
diff --git a/homeassistant/components/intellifire/climate.py b/homeassistant/components/intellifire/climate.py
index 4eddde5ff10..f72df254424 100644
--- a/homeassistant/components/intellifire/climate.py
+++ b/homeassistant/components/intellifire/climate.py
@@ -58,7 +58,6 @@ class IntellifireClimate(IntellifireEntity, ClimateEntity):
_attr_target_temperature_step = 1.0
_attr_temperature_unit = UnitOfTemperature.CELSIUS
last_temp = DEFAULT_THERMOSTAT_TEMP
- _enable_turn_on_off_backwards_compatibility = False
def __init__(
self,
diff --git a/homeassistant/components/intellifire/fan.py b/homeassistant/components/intellifire/fan.py
index dc2fc279a5d..c5bec07faaa 100644
--- a/homeassistant/components/intellifire/fan.py
+++ b/homeassistant/components/intellifire/fan.py
@@ -81,7 +81,6 @@ class IntellifireFan(IntellifireEntity, FanEntity):
| FanEntityFeature.TURN_OFF
| FanEntityFeature.TURN_ON
)
- _enable_turn_on_off_backwards_compatibility = False
@property
def is_on(self) -> bool:
diff --git a/homeassistant/components/intent/__init__.py b/homeassistant/components/intent/__init__.py
index 1322576f115..71ef40ad369 100644
--- a/homeassistant/components/intent/__init__.py
+++ b/homeassistant/components/intent/__init__.py
@@ -2,7 +2,6 @@
from __future__ import annotations
-from datetime import datetime
import logging
from typing import Any, Protocol
@@ -42,9 +41,11 @@ from homeassistant.const import (
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, State
from homeassistant.helpers import config_validation as cv, integration_platform, intent
from homeassistant.helpers.typing import ConfigType
+from homeassistant.util import dt as dt_util
from .const import DOMAIN, TIMER_DATA
from .timers import (
+ CancelAllTimersIntentHandler,
CancelTimerIntentHandler,
DecreaseTimerIntentHandler,
IncreaseTimerIntentHandler,
@@ -130,6 +131,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
intent.async_register(hass, SetPositionIntentHandler())
intent.async_register(hass, StartTimerIntentHandler())
intent.async_register(hass, CancelTimerIntentHandler())
+ intent.async_register(hass, CancelAllTimersIntentHandler())
intent.async_register(hass, IncreaseTimerIntentHandler())
intent.async_register(hass, DecreaseTimerIntentHandler())
intent.async_register(hass, PauseTimerIntentHandler())
@@ -137,7 +139,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
intent.async_register(hass, TimerStatusIntentHandler())
intent.async_register(hass, GetCurrentDateIntentHandler())
intent.async_register(hass, GetCurrentTimeIntentHandler())
- intent.async_register(hass, HelloIntentHandler())
+ intent.async_register(hass, RespondIntentHandler())
return True
@@ -405,7 +407,7 @@ class GetCurrentDateIntentHandler(intent.IntentHandler):
async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse:
response = intent_obj.create_response()
- response.async_set_speech_slots({"date": datetime.now().date()})
+ response.async_set_speech_slots({"date": dt_util.now().date()})
return response
@@ -417,19 +419,29 @@ class GetCurrentTimeIntentHandler(intent.IntentHandler):
async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse:
response = intent_obj.create_response()
- response.async_set_speech_slots({"time": datetime.now().time()})
+ response.async_set_speech_slots({"time": dt_util.now().time()})
return response
-class HelloIntentHandler(intent.IntentHandler):
+class RespondIntentHandler(intent.IntentHandler):
"""Responds with no action."""
intent_type = intent.INTENT_RESPOND
description = "Returns the provided response with no action."
+ slot_schema = {
+ vol.Optional("response"): cv.string,
+ }
+
async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse:
"""Return the provided response, but take no action."""
- return intent_obj.create_response()
+ slots = self.async_validate_slots(intent_obj.slots)
+ response = intent_obj.create_response()
+
+ if "response" in slots:
+ response.async_set_speech(slots["response"]["value"])
+
+ return response
async def _async_process_intent(
diff --git a/homeassistant/components/intent/timers.py b/homeassistant/components/intent/timers.py
index 639744abc66..84b96492241 100644
--- a/homeassistant/components/intent/timers.py
+++ b/homeassistant/components/intent/timers.py
@@ -887,6 +887,36 @@ class CancelTimerIntentHandler(intent.IntentHandler):
return intent_obj.create_response()
+class CancelAllTimersIntentHandler(intent.IntentHandler):
+ """Intent handler for cancelling all timers."""
+
+ intent_type = intent.INTENT_CANCEL_ALL_TIMERS
+ description = "Cancels all timers"
+ slot_schema = {
+ vol.Optional("area"): cv.string,
+ }
+
+ async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse:
+ """Handle the intent."""
+ hass = intent_obj.hass
+ timer_manager: TimerManager = hass.data[TIMER_DATA]
+ slots = self.async_validate_slots(intent_obj.slots)
+ canceled = 0
+
+ for timer in _find_timers(hass, intent_obj.device_id, slots):
+ timer_manager.cancel_timer(timer.id)
+ canceled += 1
+
+ response = intent_obj.create_response()
+ speech_slots = {"canceled": canceled}
+ if "area" in slots:
+ speech_slots["area"] = slots["area"]["value"]
+
+ response.async_set_speech_slots(speech_slots)
+
+ return response
+
+
class IncreaseTimerIntentHandler(intent.IntentHandler):
"""Intent handler for increasing the time of a timer."""
diff --git a/homeassistant/components/intent_script/__init__.py b/homeassistant/components/intent_script/__init__.py
index 6f47cadb04f..a4f84f6ff9e 100644
--- a/homeassistant/components/intent_script/__init__.py
+++ b/homeassistant/components/intent_script/__init__.py
@@ -148,6 +148,8 @@ class ScriptIntentHandler(intent.IntentHandler):
vol.Any("name", "area", "floor"): cv.string,
vol.Optional("domain"): vol.All(cv.ensure_list, [cv.string]),
vol.Optional("device_class"): vol.All(cv.ensure_list, [cv.string]),
+ vol.Optional("preferred_area_id"): cv.string,
+ vol.Optional("preferred_floor_id"): cv.string,
}
def __init__(self, intent_type: str, config: ConfigType) -> None:
@@ -205,7 +207,14 @@ class ScriptIntentHandler(intent.IntentHandler):
)
if match_constraints.has_constraints:
- match_result = intent.async_match_targets(hass, match_constraints)
+ match_preferences = intent.MatchTargetsPreferences(
+ area_id=slots.get("preferred_area_id"),
+ floor_id=slots.get("preferred_floor_id"),
+ )
+
+ match_result = intent.async_match_targets(
+ hass, match_constraints, match_preferences
+ )
if match_result.is_match:
targets = {}
diff --git a/homeassistant/components/intesishome/climate.py b/homeassistant/components/intesishome/climate.py
index 82b653a34c7..1a1f58a6b80 100644
--- a/homeassistant/components/intesishome/climate.py
+++ b/homeassistant/components/intesishome/climate.py
@@ -147,7 +147,6 @@ class IntesisAC(ClimateEntity):
_attr_should_poll = False
_attr_temperature_unit = UnitOfTemperature.CELSIUS
- _enable_turn_on_off_backwards_compatibility = False
def __init__(self, ih_device_id, ih_device, controller):
"""Initialize the thermostat."""
diff --git a/homeassistant/components/intesishome/manifest.json b/homeassistant/components/intesishome/manifest.json
index 6b7a579d99f..ab306fb4773 100644
--- a/homeassistant/components/intesishome/manifest.json
+++ b/homeassistant/components/intesishome/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/intesishome",
"iot_class": "cloud_push",
"loggers": ["pyintesishome"],
+ "quality_scale": "legacy",
"requirements": ["pyintesishome==1.8.0"]
}
diff --git a/homeassistant/components/iotty/manifest.json b/homeassistant/components/iotty/manifest.json
index 1c0d5cc3df2..5425ce3b480 100644
--- a/homeassistant/components/iotty/manifest.json
+++ b/homeassistant/components/iotty/manifest.json
@@ -1,11 +1,11 @@
{
"domain": "iotty",
"name": "iotty",
- "codeowners": ["@pburgio", "@shapournemati-iotty"],
+ "codeowners": ["@shapournemati-iotty"],
"config_flow": true,
"dependencies": ["application_credentials"],
"documentation": "https://www.home-assistant.io/integrations/iotty",
"integration_type": "device",
"iot_class": "cloud_polling",
- "requirements": ["iottycloud==0.2.1"]
+ "requirements": ["iottycloud==0.3.0"]
}
diff --git a/homeassistant/components/iotty/switch.py b/homeassistant/components/iotty/switch.py
index 1e2bdffcf79..b06e3ea308d 100644
--- a/homeassistant/components/iotty/switch.py
+++ b/homeassistant/components/iotty/switch.py
@@ -3,13 +3,22 @@
from __future__ import annotations
import logging
-from typing import Any
+from typing import TYPE_CHECKING, Any
-from iottycloud.device import Device
from iottycloud.lightswitch import LightSwitch
-from iottycloud.verbs import LS_DEVICE_TYPE_UID
+from iottycloud.outlet import Outlet
+from iottycloud.verbs import (
+ COMMAND_TURNOFF,
+ COMMAND_TURNON,
+ LS_DEVICE_TYPE_UID,
+ OU_DEVICE_TYPE_UID,
+)
-from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity
+from homeassistant.components.switch import (
+ SwitchDeviceClass,
+ SwitchEntity,
+ SwitchEntityDescription,
+)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
@@ -20,31 +29,62 @@ from .entity import IottyEntity
_LOGGER = logging.getLogger(__name__)
+ENTITIES: dict[str, SwitchEntityDescription] = {
+ LS_DEVICE_TYPE_UID: SwitchEntityDescription(
+ key="light",
+ name=None,
+ device_class=SwitchDeviceClass.SWITCH,
+ ),
+ OU_DEVICE_TYPE_UID: SwitchEntityDescription(
+ key="outlet",
+ name=None,
+ device_class=SwitchDeviceClass.OUTLET,
+ ),
+}
+
async def async_setup_entry(
hass: HomeAssistant,
config_entry: IottyConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
- """Activate the iotty LightSwitch component."""
+ """Activate the iotty Switch component."""
_LOGGER.debug("Setup SWITCH entry id is %s", config_entry.entry_id)
coordinator = config_entry.runtime_data.coordinator
- entities = [
- IottyLightSwitch(
- coordinator=coordinator, iotty_cloud=coordinator.iotty, iotty_device=d
+ lightswitch_entities = [
+ IottySwitch(
+ coordinator=coordinator,
+ iotty_cloud=coordinator.iotty,
+ iotty_device=d,
+ entity_description=ENTITIES[LS_DEVICE_TYPE_UID],
)
for d in coordinator.data.devices
if d.device_type == LS_DEVICE_TYPE_UID
if (isinstance(d, LightSwitch))
]
- _LOGGER.debug("Found %d LightSwitches", len(entities))
+ _LOGGER.debug("Found %d LightSwitches", len(lightswitch_entities))
+
+ outlet_entities = [
+ IottySwitch(
+ coordinator=coordinator,
+ iotty_cloud=coordinator.iotty,
+ iotty_device=d,
+ entity_description=ENTITIES[OU_DEVICE_TYPE_UID],
+ )
+ for d in coordinator.data.devices
+ if d.device_type == OU_DEVICE_TYPE_UID
+ if (isinstance(d, Outlet))
+ ]
+ _LOGGER.debug("Found %d Outlets", len(outlet_entities))
+
+ entities = lightswitch_entities + outlet_entities
async_add_entities(entities)
known_devices: set = config_entry.runtime_data.known_devices
for known_device in coordinator.data.devices:
- if known_device.device_type == LS_DEVICE_TYPE_UID:
+ if known_device.device_type in {LS_DEVICE_TYPE_UID, OU_DEVICE_TYPE_UID}:
known_devices.add(known_device)
@callback
@@ -59,21 +99,37 @@ async def async_setup_entry(
# Add entities for devices which we've not yet seen
for device in devices:
- if (
- any(d.device_id == device.device_id for d in known_devices)
- or device.device_type != LS_DEVICE_TYPE_UID
+ if any(d.device_id == device.device_id for d in known_devices) or (
+ device.device_type not in {LS_DEVICE_TYPE_UID, OU_DEVICE_TYPE_UID}
):
continue
- iotty_entity = IottyLightSwitch(
- coordinator=coordinator,
- iotty_cloud=coordinator.iotty,
- iotty_device=LightSwitch(
+ iotty_entity: SwitchEntity
+ iotty_device: LightSwitch | Outlet
+ if device.device_type == LS_DEVICE_TYPE_UID:
+ if TYPE_CHECKING:
+ assert isinstance(device, LightSwitch)
+ iotty_device = LightSwitch(
device.device_id,
device.serial_number,
device.device_type,
device.device_name,
- ),
+ )
+ else:
+ if TYPE_CHECKING:
+ assert isinstance(device, Outlet)
+ iotty_device = Outlet(
+ device.device_id,
+ device.serial_number,
+ device.device_type,
+ device.device_name,
+ )
+
+ iotty_entity = IottySwitch(
+ coordinator=coordinator,
+ iotty_cloud=coordinator.iotty,
+ iotty_device=iotty_device,
+ entity_description=ENTITIES[device.device_type],
)
entities.extend([iotty_entity])
@@ -85,24 +141,27 @@ async def async_setup_entry(
coordinator.async_add_listener(async_update_data)
-class IottyLightSwitch(IottyEntity, SwitchEntity):
- """Haas entity class for iotty LightSwitch."""
+class IottySwitch(IottyEntity, SwitchEntity):
+ """Haas entity class for iotty switch."""
- _attr_device_class = SwitchDeviceClass.SWITCH
- _iotty_device: LightSwitch
+ _attr_device_class: SwitchDeviceClass | None
+ _iotty_device: LightSwitch | Outlet
def __init__(
self,
coordinator: IottyDataUpdateCoordinator,
iotty_cloud: IottyProxy,
- iotty_device: LightSwitch,
+ iotty_device: LightSwitch | Outlet,
+ entity_description: SwitchEntityDescription,
) -> None:
- """Initialize the LightSwitch device."""
+ """Initialize the Switch device."""
super().__init__(coordinator, iotty_cloud, iotty_device)
+ self.entity_description = entity_description
+ self._attr_device_class = entity_description.device_class
@property
def is_on(self) -> bool:
- """Return true if the LightSwitch is on."""
+ """Return true if the Switch is on."""
_LOGGER.debug(
"Retrieve device status for %s ? %s",
self._iotty_device.device_id,
@@ -111,30 +170,25 @@ class IottyLightSwitch(IottyEntity, SwitchEntity):
return self._iotty_device.is_on
async def async_turn_on(self, **kwargs: Any) -> None:
- """Turn the LightSwitch on."""
+ """Turn the Switch on."""
_LOGGER.debug("[%s] Turning on", self._iotty_device.device_id)
- await self._iotty_cloud.command(
- self._iotty_device.device_id, self._iotty_device.cmd_turn_on()
- )
+ await self._iotty_cloud.command(self._iotty_device.device_id, COMMAND_TURNON)
await self.coordinator.async_request_refresh()
async def async_turn_off(self, **kwargs: Any) -> None:
- """Turn the LightSwitch off."""
+ """Turn the Switch off."""
_LOGGER.debug("[%s] Turning off", self._iotty_device.device_id)
- await self._iotty_cloud.command(
- self._iotty_device.device_id, self._iotty_device.cmd_turn_off()
- )
+ await self._iotty_cloud.command(self._iotty_device.device_id, COMMAND_TURNOFF)
await self.coordinator.async_request_refresh()
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
- device: Device = next(
+ device: LightSwitch | Outlet = next( # type: ignore[assignment]
device
for device in self.coordinator.data.devices
if device.device_id == self._iotty_device.device_id
)
- if isinstance(device, LightSwitch):
- self._iotty_device.is_on = device.is_on
+ self._iotty_device.is_on = device.is_on
self.async_write_ha_state()
diff --git a/homeassistant/components/iperf3/manifest.json b/homeassistant/components/iperf3/manifest.json
index a1bb26ddc1a..16e33e47331 100644
--- a/homeassistant/components/iperf3/manifest.json
+++ b/homeassistant/components/iperf3/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/iperf3",
"iot_class": "local_polling",
"loggers": ["iperf3"],
+ "quality_scale": "legacy",
"requirements": ["iperf3==0.1.11"]
}
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/manifest.json b/homeassistant/components/ipp/manifest.json
index baa41cf00bd..54c26b63585 100644
--- a/homeassistant/components/ipp/manifest.json
+++ b/homeassistant/components/ipp/manifest.json
@@ -7,7 +7,6 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["deepmerge", "pyipp"],
- "quality_scale": "platinum",
"requirements": ["pyipp==0.17.0"],
"zeroconf": ["_ipps._tcp.local.", "_ipp._tcp.local."]
}
diff --git a/homeassistant/components/iqvia/manifest.json b/homeassistant/components/iqvia/manifest.json
index d589c117edd..4a308d02b3d 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.1.2", "pyiqvia==2022.04.0"]
+ "requirements": ["numpy==2.2.1", "pyiqvia==2022.04.0"]
}
diff --git a/homeassistant/components/irish_rail_transport/manifest.json b/homeassistant/components/irish_rail_transport/manifest.json
index bb9b0d59ef0..2a118f17e2a 100644
--- a/homeassistant/components/irish_rail_transport/manifest.json
+++ b/homeassistant/components/irish_rail_transport/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/irish_rail_transport",
"iot_class": "cloud_polling",
"loggers": ["pyirishrail"],
+ "quality_scale": "legacy",
"requirements": ["pyirishrail==0.0.2"]
}
diff --git a/homeassistant/components/irish_rail_transport/sensor.py b/homeassistant/components/irish_rail_transport/sensor.py
index 39bf39bcbe0..2765a14b7a3 100644
--- a/homeassistant/components/irish_rail_transport/sensor.py
+++ b/homeassistant/components/irish_rail_transport/sensor.py
@@ -194,9 +194,9 @@ class IrishRailTransportData:
ATTR_STATION: self.station,
ATTR_ORIGIN: "",
ATTR_DESTINATION: dest,
- ATTR_DUE_IN: "n/a",
- ATTR_DUE_AT: "n/a",
- ATTR_EXPECT_AT: "n/a",
+ ATTR_DUE_IN: None,
+ ATTR_DUE_AT: None,
+ ATTR_EXPECT_AT: None,
ATTR_DIRECTION: direction,
ATTR_STOPS_AT: stops_at,
ATTR_TRAIN_TYPE: "",
diff --git a/homeassistant/components/iron_os/__init__.py b/homeassistant/components/iron_os/__init__.py
index 56a83117e68..6af6abb1436 100644
--- a/homeassistant/components/iron_os/__init__.py
+++ b/homeassistant/components/iron_os/__init__.py
@@ -5,8 +5,7 @@ from __future__ import annotations
import logging
from typing import TYPE_CHECKING
-from aiogithubapi import GitHubAPI
-from pynecil import Pynecil
+from pynecil import IronOSUpdate, Pynecil
from homeassistant.components import bluetooth
from homeassistant.config_entries import ConfigEntry
@@ -19,16 +18,31 @@ from homeassistant.helpers.typing import ConfigType
from homeassistant.util.hass_dict import HassKey
from .const import DOMAIN
-from .coordinator import IronOSFirmwareUpdateCoordinator, IronOSLiveDataCoordinator
+from .coordinator import (
+ IronOSCoordinators,
+ IronOSFirmwareUpdateCoordinator,
+ IronOSLiveDataCoordinator,
+ IronOSSettingsCoordinator,
+)
-PLATFORMS: list[Platform] = [Platform.NUMBER, Platform.SENSOR, Platform.UPDATE]
+PLATFORMS: list[Platform] = [
+ Platform.BINARY_SENSOR,
+ Platform.BUTTON,
+ Platform.NUMBER,
+ Platform.SELECT,
+ Platform.SENSOR,
+ Platform.SWITCH,
+ Platform.UPDATE,
+]
-type IronOSConfigEntry = ConfigEntry[IronOSLiveDataCoordinator]
-IRON_OS_KEY: HassKey[IronOSFirmwareUpdateCoordinator] = HassKey(DOMAIN)
-
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
+
+type IronOSConfigEntry = ConfigEntry[IronOSCoordinators]
+IRON_OS_KEY: HassKey[IronOSFirmwareUpdateCoordinator] = HassKey(DOMAIN)
+
+
_LOGGER = logging.getLogger(__name__)
@@ -36,7 +50,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up IronOS firmware update coordinator."""
session = async_get_clientsession(hass)
- github = GitHubAPI(session=session)
+ github = IronOSUpdate(session)
hass.data[IRON_OS_KEY] = IronOSFirmwareUpdateCoordinator(hass, github)
await hass.data[IRON_OS_KEY].async_request_refresh()
@@ -59,10 +73,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: IronOSConfigEntry) -> bo
device = Pynecil(ble_device)
- coordinator = IronOSLiveDataCoordinator(hass, device)
- await coordinator.async_config_entry_first_refresh()
+ live_data = IronOSLiveDataCoordinator(hass, device)
+ await live_data.async_config_entry_first_refresh()
- entry.runtime_data = coordinator
+ settings = IronOSSettingsCoordinator(hass, device)
+ await settings.async_config_entry_first_refresh()
+
+ entry.runtime_data = IronOSCoordinators(
+ live_data=live_data,
+ settings=settings,
+ )
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
diff --git a/homeassistant/components/iron_os/binary_sensor.py b/homeassistant/components/iron_os/binary_sensor.py
new file mode 100644
index 00000000000..81ba0e08c95
--- /dev/null
+++ b/homeassistant/components/iron_os/binary_sensor.py
@@ -0,0 +1,54 @@
+"""Binary sensor platform for IronOS integration."""
+
+from __future__ import annotations
+
+from enum import StrEnum
+
+from homeassistant.components.binary_sensor import (
+ BinarySensorDeviceClass,
+ BinarySensorEntity,
+ BinarySensorEntityDescription,
+)
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+
+from . import IronOSConfigEntry
+from .coordinator import IronOSLiveDataCoordinator
+from .entity import IronOSBaseEntity
+
+# Coordinator is used to centralize the data updates
+PARALLEL_UPDATES = 0
+
+
+class PinecilBinarySensor(StrEnum):
+ """Pinecil Binary Sensors."""
+
+ TIP_CONNECTED = "tip_connected"
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: IronOSConfigEntry,
+ async_add_entities: AddEntitiesCallback,
+) -> None:
+ """Set up binary sensors from a config entry."""
+ coordinator = entry.runtime_data.live_data
+
+ entity_description = BinarySensorEntityDescription(
+ key=PinecilBinarySensor.TIP_CONNECTED,
+ translation_key=PinecilBinarySensor.TIP_CONNECTED,
+ device_class=BinarySensorDeviceClass.CONNECTIVITY,
+ )
+
+ async_add_entities([IronOSBinarySensorEntity(coordinator, entity_description)])
+
+
+class IronOSBinarySensorEntity(IronOSBaseEntity, BinarySensorEntity):
+ """Representation of a IronOS binary sensor entity."""
+
+ coordinator: IronOSLiveDataCoordinator
+
+ @property
+ def is_on(self) -> bool | None:
+ """Return true if the binary sensor is on."""
+ return self.coordinator.has_tip
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 699f5a01704..339cbdcca28 100644
--- a/homeassistant/components/iron_os/coordinator.py
+++ b/homeassistant/components/iron_os/coordinator.py
@@ -2,15 +2,27 @@
from __future__ import annotations
+from dataclasses import dataclass
from datetime import timedelta
import logging
-from typing import TYPE_CHECKING
+from typing import cast
-from aiogithubapi import GitHubAPI, GitHubException, GitHubReleaseModel
-from pynecil import CommunicationError, DeviceInfoResponse, LiveDataResponse, Pynecil
+from pynecil import (
+ CharSetting,
+ CommunicationError,
+ DeviceInfoResponse,
+ IronOSUpdate,
+ LatestRelease,
+ LiveDataResponse,
+ Pynecil,
+ SettingsDataResponse,
+ UpdateException,
+)
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
from .const import DOMAIN
@@ -19,24 +31,58 @@ _LOGGER = logging.getLogger(__name__)
SCAN_INTERVAL = timedelta(seconds=5)
SCAN_INTERVAL_GITHUB = timedelta(hours=3)
+SCAN_INTERVAL_SETTINGS = timedelta(seconds=60)
-class IronOSLiveDataCoordinator(DataUpdateCoordinator[LiveDataResponse]):
- """IronOS live data coordinator."""
+@dataclass
+class IronOSCoordinators:
+ """IronOS data class holding coordinators."""
+
+ live_data: IronOSLiveDataCoordinator
+ settings: IronOSSettingsCoordinator
+
+
+class IronOSBaseCoordinator[_DataT](DataUpdateCoordinator[_DataT]):
+ """IronOS base coordinator."""
device_info: DeviceInfoResponse
config_entry: ConfigEntry
- def __init__(self, hass: HomeAssistant, device: Pynecil) -> None:
+ def __init__(
+ self,
+ hass: HomeAssistant,
+ device: Pynecil,
+ update_interval: timedelta,
+ ) -> None:
"""Initialize IronOS coordinator."""
+
super().__init__(
hass,
_LOGGER,
name=DOMAIN,
- update_interval=SCAN_INTERVAL,
+ update_interval=update_interval,
+ request_refresh_debouncer=Debouncer(
+ hass, _LOGGER, cooldown=3, immediate=False
+ ),
)
self.device = device
+ async def _async_setup(self) -> None:
+ """Set up the coordinator."""
+ try:
+ self.device_info = await self.device.get_device_info()
+
+ except CommunicationError as e:
+ raise UpdateFailed("Cannot connect to device") from e
+
+
+class IronOSLiveDataCoordinator(IronOSBaseCoordinator[LiveDataResponse]):
+ """IronOS coordinator."""
+
+ def __init__(self, hass: HomeAssistant, device: Pynecil) -> None:
+ """Initialize IronOS coordinator."""
+ super().__init__(hass, device=device, update_interval=SCAN_INTERVAL)
+
async def _async_update_data(self) -> LiveDataResponse:
"""Fetch data from Device."""
@@ -50,11 +96,22 @@ class IronOSLiveDataCoordinator(DataUpdateCoordinator[LiveDataResponse]):
except CommunicationError as e:
raise UpdateFailed("Cannot connect to device") from e
+ @property
+ def has_tip(self) -> bool:
+ """Return True if the tip is connected."""
+ if (
+ self.data.max_tip_temp_ability is not None
+ and self.data.live_temp is not None
+ ):
+ threshold = self.data.max_tip_temp_ability - 5
+ return self.data.live_temp <= threshold
+ return False
-class IronOSFirmwareUpdateCoordinator(DataUpdateCoordinator[GitHubReleaseModel]):
+
+class IronOSFirmwareUpdateCoordinator(DataUpdateCoordinator[LatestRelease]):
"""IronOS coordinator for retrieving update information from github."""
- def __init__(self, hass: HomeAssistant, github: GitHubAPI) -> None:
+ def __init__(self, hass: HomeAssistant, github: IronOSUpdate) -> None:
"""Initialize IronOS coordinator."""
super().__init__(
hass,
@@ -65,18 +122,49 @@ class IronOSFirmwareUpdateCoordinator(DataUpdateCoordinator[GitHubReleaseModel])
)
self.github = github
- async def _async_update_data(self) -> GitHubReleaseModel:
+ async def _async_update_data(self) -> LatestRelease:
"""Fetch data from Github."""
try:
- release = await self.github.repos.releases.latest("Ralim/IronOS")
+ return await self.github.latest_release()
+ except UpdateException as e:
+ raise UpdateFailed("Failed to check for latest IronOS update") from e
- except GitHubException as e:
- raise UpdateFailed(
- "Failed to retrieve latest release data from Github"
+
+class IronOSSettingsCoordinator(IronOSBaseCoordinator[SettingsDataResponse]):
+ """IronOS coordinator."""
+
+ def __init__(self, hass: HomeAssistant, device: Pynecil) -> None:
+ """Initialize IronOS coordinator."""
+ super().__init__(hass, device=device, update_interval=SCAN_INTERVAL_SETTINGS)
+
+ async def _async_update_data(self) -> SettingsDataResponse:
+ """Fetch data from Device."""
+
+ characteristics = set(self.async_contexts())
+
+ if self.device.is_connected and characteristics:
+ try:
+ return await self.device.get_settings(list(characteristics))
+ except CommunicationError as e:
+ _LOGGER.debug("Failed to fetch settings", exc_info=e)
+
+ return self.data or SettingsDataResponse()
+
+ async def write(self, characteristic: CharSetting, value: bool) -> 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
- if TYPE_CHECKING:
- assert release.data
-
- return release.data
+ # 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/entity.py b/homeassistant/components/iron_os/entity.py
index 77bebda9390..190a9f33639 100644
--- a/homeassistant/components/iron_os/entity.py
+++ b/homeassistant/components/iron_os/entity.py
@@ -31,7 +31,8 @@ class IronOSBaseEntity(CoordinatorEntity[IronOSLiveDataCoordinator]):
)
if TYPE_CHECKING:
assert coordinator.config_entry.unique_id
- self.device_info = DeviceInfo(
+
+ self._attr_device_info = DeviceInfo(
connections={(CONNECTION_BLUETOOTH, coordinator.config_entry.unique_id)},
manufacturer=MANUFACTURER,
model=MODEL,
diff --git a/homeassistant/components/iron_os/icons.json b/homeassistant/components/iron_os/icons.json
index fa14b8134d0..ee8badf3c89 100644
--- a/homeassistant/components/iron_os/icons.json
+++ b/homeassistant/components/iron_os/icons.json
@@ -1,8 +1,107 @@
{
"entity": {
+ "binary_sensor": {
+ "tip_connected": {
+ "default": "mdi:pencil-outline",
+ "state": {
+ "off": "mdi:pencil-off-outline"
+ }
+ }
+ },
+ "button": {
+ "settings_save": {
+ "default": "mdi:content-save-cog"
+ },
+ "settings_reset": {
+ "default": "mdi:refresh"
+ }
+ },
"number": {
"setpoint_temperature": {
"default": "mdi:thermometer"
+ },
+ "sleep_temperature": {
+ "default": "mdi:thermometer-low"
+ },
+ "sleep_timeout": {
+ "default": "mdi:timer-sand"
+ },
+ "qc_max_voltage": {
+ "default": "mdi:flash-alert-outline"
+ },
+ "pd_timeout": {
+ "default": "mdi:timer-alert-outline"
+ },
+ "boost_temp": {
+ "default": "mdi:thermometer-high"
+ },
+ "shutdown_timeout": {
+ "default": "mdi:thermometer-off"
+ },
+ "display_brightness": {
+ "default": "mdi:brightness-6"
+ },
+ "voltage_div": {
+ "default": "mdi:call-split"
+ },
+ "temp_increment_short": {
+ "default": "mdi:gesture-tap-button"
+ },
+ "temp_increment_long": {
+ "default": "mdi:gesture-tap-button"
+ },
+ "accel_sensitivity": {
+ "default": "mdi:motion"
+ },
+ "calibration_offset": {
+ "default": "mdi:contrast"
+ },
+ "hall_sensitivity": {
+ "default": "mdi:leak"
+ },
+ "keep_awake_pulse_delay": {
+ "default": "mdi:clock-end"
+ },
+ "keep_awake_pulse_duration": {
+ "default": "mdi:clock-start"
+ },
+ "keep_awake_pulse_power": {
+ "default": "mdi:waves-arrow-up"
+ },
+ "min_voltage_per_cell": {
+ "default": "mdi:fuel-cell"
+ },
+ "power_limit": {
+ "default": "mdi:flash-alert"
+ }
+ },
+ "select": {
+ "locking_mode": {
+ "default": "mdi:download-lock"
+ },
+ "orientation_mode": {
+ "default": "mdi:screen-rotation"
+ },
+ "autostart_mode": {
+ "default": "mdi:power-standby"
+ },
+ "animation_speed": {
+ "default": "mdi:image-refresh"
+ },
+ "min_dc_voltage_cells": {
+ "default": "mdi:fuel-cell"
+ },
+ "temp_unit": {
+ "default": "mdi:temperature-celsius",
+ "state": {
+ "fahrenheit": "mdi:temperature-fahrenheit"
+ }
+ },
+ "desc_scroll_speed": {
+ "default": "mdi:message-text-fast"
+ },
+ "logo_duration": {
+ "default": "mdi:clock-digital"
}
},
"sensor": {
@@ -58,6 +157,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 4ec08a43b61..462e75c5b6e 100644
--- a/homeassistant/components/iron_os/manifest.json
+++ b/homeassistant/components/iron_os/manifest.json
@@ -12,6 +12,6 @@
"dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/iron_os",
"iot_class": "local_polling",
- "loggers": ["pynecil", "aiogithubapi"],
- "requirements": ["pynecil==0.2.1", "aiogithubapi==24.6.0"]
+ "loggers": ["pynecil"],
+ "requirements": ["pynecil==4.0.1"]
}
diff --git a/homeassistant/components/iron_os/number.py b/homeassistant/components/iron_os/number.py
index 9230faec1f1..e50b227bbef 100644
--- a/homeassistant/components/iron_os/number.py
+++ b/homeassistant/components/iron_os/number.py
@@ -6,37 +6,76 @@ from collections.abc import Callable
from dataclasses import dataclass
from enum import StrEnum
-from pynecil import CharSetting, CommunicationError, LiveDataResponse
+from pynecil import (
+ CharSetting,
+ CommunicationError,
+ LiveDataResponse,
+ SettingsDataResponse,
+)
from homeassistant.components.number import (
+ DEFAULT_MAX_VALUE,
NumberDeviceClass,
NumberEntity,
NumberEntityDescription,
NumberMode,
)
-from homeassistant.const import UnitOfTemperature
+from homeassistant.const import (
+ EntityCategory,
+ UnitOfElectricPotential,
+ UnitOfPower,
+ UnitOfTemperature,
+ 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 .coordinator import IronOSCoordinators
from .entity import IronOSBaseEntity
+PARALLEL_UPDATES = 0
+
@dataclass(frozen=True, kw_only=True)
class IronOSNumberEntityDescription(NumberEntityDescription):
"""Describes IronOS number entity."""
- value_fn: Callable[[LiveDataResponse], float | int | None]
- max_value_fn: Callable[[LiveDataResponse], float | int]
- set_key: CharSetting
+ value_fn: Callable[[LiveDataResponse, SettingsDataResponse], float | int | None]
+ max_value_fn: Callable[[LiveDataResponse], float | int] | None = None
+ characteristic: CharSetting
+ raw_value_fn: Callable[[float], float | int] | None = None
class PinecilNumber(StrEnum):
"""Number controls for Pinecil device."""
SETPOINT_TEMP = "setpoint_temperature"
+ SLEEP_TEMP = "sleep_temperature"
+ SLEEP_TIMEOUT = "sleep_timeout"
+ QC_MAX_VOLTAGE = "qc_max_voltage"
+ PD_TIMEOUT = "pd_timeout"
+ BOOST_TEMP = "boost_temp"
+ SHUTDOWN_TIMEOUT = "shutdown_timeout"
+ DISPLAY_BRIGHTNESS = "display_brightness"
+ POWER_LIMIT = "power_limit"
+ CALIBRATION_OFFSET = "calibration_offset"
+ HALL_SENSITIVITY = "hall_sensitivity"
+ MIN_VOLTAGE_PER_CELL = "min_voltage_per_cell"
+ ACCEL_SENSITIVITY = "accel_sensitivity"
+ KEEP_AWAKE_PULSE_POWER = "keep_awake_pulse_power"
+ KEEP_AWAKE_PULSE_DELAY = "keep_awake_pulse_delay"
+ KEEP_AWAKE_PULSE_DURATION = "keep_awake_pulse_duration"
+ VOLTAGE_DIV = "voltage_div"
+ TEMP_INCREMENT_SHORT = "temp_increment_short"
+ TEMP_INCREMENT_LONG = "temp_increment_long"
+
+
+def multiply(value: float | None, multiplier: float) -> float | None:
+ """Multiply if not None."""
+ return value * multiplier if value is not None else None
PINECIL_NUMBER_DESCRIPTIONS: tuple[IronOSNumberEntityDescription, ...] = (
@@ -45,13 +84,249 @@ PINECIL_NUMBER_DESCRIPTIONS: tuple[IronOSNumberEntityDescription, ...] = (
translation_key=PinecilNumber.SETPOINT_TEMP,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=NumberDeviceClass.TEMPERATURE,
- value_fn=lambda data: data.setpoint_temp,
- set_key=CharSetting.SETPOINT_TEMP,
+ value_fn=lambda data, _: data.setpoint_temp,
+ characteristic=CharSetting.SETPOINT_TEMP,
mode=NumberMode.BOX,
native_min_value=MIN_TEMP,
native_step=5,
max_value_fn=lambda data: min(data.max_tip_temp_ability or MAX_TEMP, MAX_TEMP),
),
+ IronOSNumberEntityDescription(
+ key=PinecilNumber.SLEEP_TEMP,
+ translation_key=PinecilNumber.SLEEP_TEMP,
+ native_unit_of_measurement=UnitOfTemperature.CELSIUS,
+ device_class=NumberDeviceClass.TEMPERATURE,
+ value_fn=lambda _, settings: settings.get("sleep_temp"),
+ characteristic=CharSetting.SLEEP_TEMP,
+ mode=NumberMode.BOX,
+ native_min_value=MIN_TEMP,
+ native_max_value=MAX_TEMP,
+ native_step=10,
+ entity_category=EntityCategory.CONFIG,
+ ),
+ IronOSNumberEntityDescription(
+ key=PinecilNumber.BOOST_TEMP,
+ translation_key=PinecilNumber.BOOST_TEMP,
+ native_unit_of_measurement=UnitOfTemperature.CELSIUS,
+ device_class=NumberDeviceClass.TEMPERATURE,
+ value_fn=lambda _, settings: settings.get("boost_temp"),
+ characteristic=CharSetting.BOOST_TEMP,
+ mode=NumberMode.BOX,
+ native_min_value=0,
+ native_max_value=MAX_TEMP,
+ native_step=10,
+ entity_category=EntityCategory.CONFIG,
+ ),
+ IronOSNumberEntityDescription(
+ key=PinecilNumber.QC_MAX_VOLTAGE,
+ translation_key=PinecilNumber.QC_MAX_VOLTAGE,
+ native_unit_of_measurement=UnitOfElectricPotential.VOLT,
+ device_class=NumberDeviceClass.VOLTAGE,
+ value_fn=lambda _, settings: settings.get("qc_ideal_voltage"),
+ characteristic=CharSetting.QC_IDEAL_VOLTAGE,
+ mode=NumberMode.BOX,
+ native_min_value=9.0,
+ native_max_value=22.0,
+ native_step=0.1,
+ entity_category=EntityCategory.CONFIG,
+ entity_registry_enabled_default=False,
+ ),
+ IronOSNumberEntityDescription(
+ key=PinecilNumber.PD_TIMEOUT,
+ translation_key=PinecilNumber.PD_TIMEOUT,
+ native_unit_of_measurement=UnitOfTime.SECONDS,
+ device_class=NumberDeviceClass.DURATION,
+ value_fn=lambda _, settings: settings.get("pd_negotiation_timeout"),
+ characteristic=CharSetting.PD_NEGOTIATION_TIMEOUT,
+ mode=NumberMode.BOX,
+ native_min_value=0,
+ native_max_value=5.0,
+ native_step=1,
+ entity_category=EntityCategory.CONFIG,
+ entity_registry_enabled_default=False,
+ ),
+ IronOSNumberEntityDescription(
+ key=PinecilNumber.SHUTDOWN_TIMEOUT,
+ translation_key=PinecilNumber.SHUTDOWN_TIMEOUT,
+ native_unit_of_measurement=UnitOfTime.MINUTES,
+ device_class=NumberDeviceClass.DURATION,
+ value_fn=lambda _, settings: settings.get("shutdown_time"),
+ characteristic=CharSetting.SHUTDOWN_TIME,
+ mode=NumberMode.BOX,
+ native_min_value=0,
+ native_max_value=60,
+ native_step=1,
+ entity_category=EntityCategory.CONFIG,
+ ),
+ IronOSNumberEntityDescription(
+ key=PinecilNumber.DISPLAY_BRIGHTNESS,
+ translation_key=PinecilNumber.DISPLAY_BRIGHTNESS,
+ value_fn=lambda _, settings: settings.get("display_brightness"),
+ characteristic=CharSetting.DISPLAY_BRIGHTNESS,
+ mode=NumberMode.SLIDER,
+ native_min_value=1,
+ native_max_value=5,
+ native_step=1,
+ entity_category=EntityCategory.CONFIG,
+ ),
+ IronOSNumberEntityDescription(
+ key=PinecilNumber.SLEEP_TIMEOUT,
+ translation_key=PinecilNumber.SLEEP_TIMEOUT,
+ value_fn=lambda _, settings: settings.get("sleep_timeout"),
+ characteristic=CharSetting.SLEEP_TIMEOUT,
+ mode=NumberMode.BOX,
+ native_min_value=0,
+ native_max_value=15,
+ native_step=1,
+ entity_category=EntityCategory.CONFIG,
+ native_unit_of_measurement=UnitOfTime.MINUTES,
+ ),
+ IronOSNumberEntityDescription(
+ key=PinecilNumber.POWER_LIMIT,
+ translation_key=PinecilNumber.POWER_LIMIT,
+ value_fn=lambda _, settings: settings.get("power_limit"),
+ characteristic=CharSetting.POWER_LIMIT,
+ mode=NumberMode.BOX,
+ native_min_value=0,
+ native_max_value=120,
+ native_step=5,
+ entity_category=EntityCategory.CONFIG,
+ native_unit_of_measurement=UnitOfPower.WATT,
+ entity_registry_enabled_default=False,
+ ),
+ IronOSNumberEntityDescription(
+ key=PinecilNumber.CALIBRATION_OFFSET,
+ translation_key=PinecilNumber.CALIBRATION_OFFSET,
+ value_fn=lambda _, settings: settings.get("calibration_offset"),
+ characteristic=CharSetting.CALIBRATION_OFFSET,
+ mode=NumberMode.BOX,
+ native_min_value=100,
+ native_max_value=2500,
+ native_step=1,
+ entity_category=EntityCategory.CONFIG,
+ native_unit_of_measurement=UnitOfElectricPotential.MICROVOLT,
+ entity_registry_enabled_default=False,
+ ),
+ IronOSNumberEntityDescription(
+ key=PinecilNumber.HALL_SENSITIVITY,
+ translation_key=PinecilNumber.HALL_SENSITIVITY,
+ value_fn=lambda _, settings: settings.get("hall_sensitivity"),
+ characteristic=CharSetting.HALL_SENSITIVITY,
+ mode=NumberMode.SLIDER,
+ native_min_value=0,
+ native_max_value=9,
+ native_step=1,
+ entity_category=EntityCategory.CONFIG,
+ entity_registry_enabled_default=False,
+ ),
+ IronOSNumberEntityDescription(
+ key=PinecilNumber.MIN_VOLTAGE_PER_CELL,
+ translation_key=PinecilNumber.MIN_VOLTAGE_PER_CELL,
+ value_fn=lambda _, settings: settings.get("min_voltage_per_cell"),
+ characteristic=CharSetting.MIN_VOLTAGE_PER_CELL,
+ mode=NumberMode.BOX,
+ native_min_value=2.4,
+ native_max_value=3.8,
+ native_step=0.1,
+ entity_category=EntityCategory.CONFIG,
+ native_unit_of_measurement=UnitOfElectricPotential.VOLT,
+ entity_registry_enabled_default=False,
+ ),
+ IronOSNumberEntityDescription(
+ key=PinecilNumber.ACCEL_SENSITIVITY,
+ translation_key=PinecilNumber.ACCEL_SENSITIVITY,
+ value_fn=lambda _, settings: settings.get("accel_sensitivity"),
+ characteristic=CharSetting.ACCEL_SENSITIVITY,
+ mode=NumberMode.SLIDER,
+ native_min_value=0,
+ native_max_value=9,
+ native_step=1,
+ entity_category=EntityCategory.CONFIG,
+ ),
+ IronOSNumberEntityDescription(
+ key=PinecilNumber.KEEP_AWAKE_PULSE_POWER,
+ translation_key=PinecilNumber.KEEP_AWAKE_PULSE_POWER,
+ value_fn=lambda _, settings: settings.get("keep_awake_pulse_power"),
+ characteristic=CharSetting.KEEP_AWAKE_PULSE_POWER,
+ mode=NumberMode.BOX,
+ native_min_value=0,
+ native_max_value=9.9,
+ native_step=0.1,
+ entity_category=EntityCategory.CONFIG,
+ native_unit_of_measurement=UnitOfPower.WATT,
+ entity_registry_enabled_default=False,
+ ),
+ IronOSNumberEntityDescription(
+ key=PinecilNumber.KEEP_AWAKE_PULSE_DELAY,
+ translation_key=PinecilNumber.KEEP_AWAKE_PULSE_DELAY,
+ value_fn=(
+ lambda _, settings: multiply(settings.get("keep_awake_pulse_delay"), 2.5)
+ ),
+ characteristic=CharSetting.KEEP_AWAKE_PULSE_DELAY,
+ raw_value_fn=lambda value: value / 2.5,
+ mode=NumberMode.BOX,
+ native_min_value=2.5,
+ native_max_value=22.5,
+ native_step=2.5,
+ entity_category=EntityCategory.CONFIG,
+ native_unit_of_measurement=UnitOfTime.SECONDS,
+ entity_registry_enabled_default=False,
+ ),
+ IronOSNumberEntityDescription(
+ key=PinecilNumber.KEEP_AWAKE_PULSE_DURATION,
+ translation_key=PinecilNumber.KEEP_AWAKE_PULSE_DURATION,
+ value_fn=(
+ lambda _, settings: multiply(settings.get("keep_awake_pulse_duration"), 250)
+ ),
+ characteristic=CharSetting.KEEP_AWAKE_PULSE_DURATION,
+ raw_value_fn=lambda value: value / 250,
+ mode=NumberMode.BOX,
+ native_min_value=250,
+ native_max_value=2250,
+ native_step=250,
+ entity_category=EntityCategory.CONFIG,
+ native_unit_of_measurement=UnitOfTime.MILLISECONDS,
+ entity_registry_enabled_default=False,
+ ),
+ IronOSNumberEntityDescription(
+ key=PinecilNumber.VOLTAGE_DIV,
+ translation_key=PinecilNumber.VOLTAGE_DIV,
+ value_fn=(lambda _, settings: settings.get("voltage_div")),
+ characteristic=CharSetting.VOLTAGE_DIV,
+ raw_value_fn=lambda value: value,
+ mode=NumberMode.BOX,
+ native_min_value=360,
+ native_max_value=900,
+ native_step=1,
+ entity_category=EntityCategory.CONFIG,
+ entity_registry_enabled_default=False,
+ ),
+ IronOSNumberEntityDescription(
+ key=PinecilNumber.TEMP_INCREMENT_SHORT,
+ translation_key=PinecilNumber.TEMP_INCREMENT_SHORT,
+ value_fn=(lambda _, settings: settings.get("temp_increment_short")),
+ characteristic=CharSetting.TEMP_INCREMENT_SHORT,
+ raw_value_fn=lambda value: value,
+ mode=NumberMode.BOX,
+ native_min_value=1,
+ native_max_value=50,
+ native_step=1,
+ entity_category=EntityCategory.CONFIG,
+ native_unit_of_measurement=UnitOfTemperature.CELSIUS,
+ ),
+ IronOSNumberEntityDescription(
+ key=PinecilNumber.TEMP_INCREMENT_LONG,
+ translation_key=PinecilNumber.TEMP_INCREMENT_LONG,
+ value_fn=(lambda _, settings: settings.get("temp_increment_long")),
+ characteristic=CharSetting.TEMP_INCREMENT_LONG,
+ raw_value_fn=lambda value: value,
+ mode=NumberMode.BOX,
+ native_min_value=5,
+ native_max_value=90,
+ native_step=5,
+ entity_category=EntityCategory.CONFIG,
+ native_unit_of_measurement=UnitOfTemperature.CELSIUS,
+ ),
)
@@ -61,10 +336,10 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up number entities from a config entry."""
- coordinator = entry.runtime_data
+ coordinators = entry.runtime_data
async_add_entities(
- IronOSNumberEntity(coordinator, description)
+ IronOSNumberEntity(coordinators, description)
for description in PINECIL_NUMBER_DESCRIPTIONS
)
@@ -74,23 +349,54 @@ class IronOSNumberEntity(IronOSBaseEntity, NumberEntity):
entity_description: IronOSNumberEntityDescription
+ def __init__(
+ self,
+ coordinators: IronOSCoordinators,
+ entity_description: IronOSNumberEntityDescription,
+ ) -> None:
+ """Initialize the number entity."""
+ super().__init__(coordinators.live_data, entity_description)
+
+ self.settings = coordinators.settings
+
async def async_set_native_value(self, value: float) -> None:
"""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.set_key, value)
+ 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
- self.async_write_ha_state()
+ await self.settings.async_request_refresh()
@property
def native_value(self) -> float | int | None:
"""Return sensor state."""
- return self.entity_description.value_fn(self.coordinator.data)
+ return self.entity_description.value_fn(
+ self.coordinator.data, self.settings.data
+ )
@property
def native_max_value(self) -> float:
"""Return sensor state."""
- return self.entity_description.max_value_fn(self.coordinator.data)
+
+ if self.entity_description.max_value_fn is not None:
+ return self.entity_description.max_value_fn(self.coordinator.data)
+
+ return self.entity_description.native_max_value or DEFAULT_MAX_VALUE
+
+ 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/iron_os/quality_scale.yaml b/homeassistant/components/iron_os/quality_scale.yaml
new file mode 100644
index 00000000000..fd89b80d782
--- /dev/null
+++ b/homeassistant/components/iron_os/quality_scale.yaml
@@ -0,0 +1,82 @@
+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: done
+ comment: Integration does register actions aside from entity actions
+ docs-high-level-description: done
+ docs-installation-instructions: done
+ docs-removal-instructions: done
+ entity-event-setup:
+ status: exempt
+ comment: Integration does not register events.
+ entity-unique-id: done
+ has-entity-name: done
+ runtime-data: done
+ test-before-configure: todo
+ test-before-setup: todo
+ unique-config-entry: done
+
+ # Silver
+ 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
+ entity-unavailable: done
+ integration-owner: done
+ log-when-unavailable: done
+ parallel-updates: done
+ reauthentication-flow:
+ status: exempt
+ comment: Devices don't require authentication
+ test-coverage: done
+
+ # Gold
+ devices: done
+ diagnostics: todo
+ 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.
+ 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: 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: done
+ exception-translations: done
+ icon-translations: done
+ reconfiguration-flow:
+ status: exempt
+ comment: Reconfiguration would force a new config entry
+ repair-issues:
+ status: exempt
+ comment: no repairs/issues
+ stale-devices: todo
+
+ # Platinum
+ async-dependency: done
+ inject-websession:
+ status: exempt
+ comment: Device doesn't make http requests.
+ strict-typing: done
diff --git a/homeassistant/components/iron_os/select.py b/homeassistant/components/iron_os/select.py
new file mode 100644
index 00000000000..10d8a6fcef5
--- /dev/null
+++ b/homeassistant/components/iron_os/select.py
@@ -0,0 +1,206 @@
+"""Select platform for IronOS integration."""
+
+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,
+)
+
+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
+
+PARALLEL_UPDATES = 0
+
+
+@dataclass(frozen=True, kw_only=True)
+class IronOSSelectEntityDescription(SelectEntityDescription):
+ """Describes IronOS select entity."""
+
+ value_fn: Callable[[SettingsDataResponse], str | None]
+ characteristic: CharSetting
+ raw_value_fn: Callable[[str], Any] | None = None
+
+
+class PinecilSelect(StrEnum):
+ """Select controls for Pinecil device."""
+
+ MIN_DC_VOLTAGE_CELLS = "min_dc_voltage_cells"
+ ORIENTATION_MODE = "orientation_mode"
+ ANIMATION_SPEED = "animation_speed"
+ AUTOSTART_MODE = "autostart_mode"
+ TEMP_UNIT = "temp_unit"
+ DESC_SCROLL_SPEED = "desc_scroll_speed"
+ LOCKING_MODE = "locking_mode"
+ LOGO_DURATION = "logo_duration"
+
+
+def enum_to_str(enum: Enum | None) -> str | None:
+ """Convert enum name to lower-case string."""
+ return enum.name.lower() if isinstance(enum, Enum) else None
+
+
+PINECIL_SELECT_DESCRIPTIONS: tuple[IronOSSelectEntityDescription, ...] = (
+ IronOSSelectEntityDescription(
+ key=PinecilSelect.MIN_DC_VOLTAGE_CELLS,
+ translation_key=PinecilSelect.MIN_DC_VOLTAGE_CELLS,
+ characteristic=CharSetting.MIN_DC_VOLTAGE_CELLS,
+ value_fn=lambda x: enum_to_str(x.get("min_dc_voltage_cells")),
+ raw_value_fn=lambda value: BatteryType[value.upper()],
+ options=[x.name.lower() for x in BatteryType],
+ entity_category=EntityCategory.CONFIG,
+ entity_registry_enabled_default=False,
+ ),
+ IronOSSelectEntityDescription(
+ key=PinecilSelect.ORIENTATION_MODE,
+ translation_key=PinecilSelect.ORIENTATION_MODE,
+ characteristic=CharSetting.ORIENTATION_MODE,
+ value_fn=lambda x: enum_to_str(x.get("orientation_mode")),
+ raw_value_fn=lambda value: ScreenOrientationMode[value.upper()],
+ options=[x.name.lower() for x in ScreenOrientationMode],
+ entity_category=EntityCategory.CONFIG,
+ ),
+ IronOSSelectEntityDescription(
+ key=PinecilSelect.ANIMATION_SPEED,
+ translation_key=PinecilSelect.ANIMATION_SPEED,
+ characteristic=CharSetting.ANIMATION_SPEED,
+ value_fn=lambda x: enum_to_str(x.get("animation_speed")),
+ raw_value_fn=lambda value: AnimationSpeed[value.upper()],
+ options=[x.name.lower() for x in AnimationSpeed],
+ entity_category=EntityCategory.CONFIG,
+ entity_registry_enabled_default=False,
+ ),
+ IronOSSelectEntityDescription(
+ key=PinecilSelect.AUTOSTART_MODE,
+ translation_key=PinecilSelect.AUTOSTART_MODE,
+ characteristic=CharSetting.AUTOSTART_MODE,
+ value_fn=lambda x: enum_to_str(x.get("autostart_mode")),
+ raw_value_fn=lambda value: AutostartMode[value.upper()],
+ options=[x.name.lower() for x in AutostartMode],
+ entity_category=EntityCategory.CONFIG,
+ ),
+ IronOSSelectEntityDescription(
+ key=PinecilSelect.TEMP_UNIT,
+ translation_key=PinecilSelect.TEMP_UNIT,
+ characteristic=CharSetting.TEMP_UNIT,
+ value_fn=lambda x: enum_to_str(x.get("temp_unit")),
+ raw_value_fn=lambda value: TempUnit[value.upper()],
+ options=[x.name.lower() for x in TempUnit],
+ entity_category=EntityCategory.CONFIG,
+ entity_registry_enabled_default=False,
+ ),
+ IronOSSelectEntityDescription(
+ key=PinecilSelect.DESC_SCROLL_SPEED,
+ translation_key=PinecilSelect.DESC_SCROLL_SPEED,
+ characteristic=CharSetting.DESC_SCROLL_SPEED,
+ value_fn=lambda x: enum_to_str(x.get("desc_scroll_speed")),
+ raw_value_fn=lambda value: ScrollSpeed[value.upper()],
+ options=[x.name.lower() for x in ScrollSpeed],
+ entity_category=EntityCategory.CONFIG,
+ entity_registry_enabled_default=False,
+ ),
+ IronOSSelectEntityDescription(
+ key=PinecilSelect.LOCKING_MODE,
+ translation_key=PinecilSelect.LOCKING_MODE,
+ characteristic=CharSetting.LOCKING_MODE,
+ value_fn=lambda x: enum_to_str(x.get("locking_mode")),
+ raw_value_fn=lambda value: LockingMode[value.upper()],
+ options=[x.name.lower() for x in LockingMode],
+ entity_category=EntityCategory.CONFIG,
+ ),
+ IronOSSelectEntityDescription(
+ key=PinecilSelect.LOGO_DURATION,
+ translation_key=PinecilSelect.LOGO_DURATION,
+ characteristic=CharSetting.LOGO_DURATION,
+ value_fn=lambda x: enum_to_str(x.get("logo_duration")),
+ raw_value_fn=lambda value: LogoDuration[value.upper()],
+ options=[x.name.lower() for x in LogoDuration],
+ entity_category=EntityCategory.CONFIG,
+ entity_registry_enabled_default=False,
+ ),
+)
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: IronOSConfigEntry,
+ async_add_entities: AddEntitiesCallback,
+) -> None:
+ """Set up select entities from a config entry."""
+ coordinator = entry.runtime_data
+
+ async_add_entities(
+ IronOSSelectEntity(coordinator, description)
+ for description in PINECIL_SELECT_DESCRIPTIONS
+ )
+
+
+class IronOSSelectEntity(IronOSBaseEntity, SelectEntity):
+ """Implementation of a IronOS select entity."""
+
+ entity_description: IronOSSelectEntityDescription
+
+ def __init__(
+ self,
+ coordinators: IronOSCoordinators,
+ entity_description: IronOSSelectEntityDescription,
+ ) -> None:
+ """Initialize the select entity."""
+ super().__init__(coordinators.live_data, entity_description)
+
+ self.settings = coordinators.settings
+
+ @property
+ def current_option(self) -> str | None:
+ """Return the selected entity option to represent the entity state."""
+
+ return self.entity_description.value_fn(self.settings.data)
+
+ 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()
+
+ 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/iron_os/sensor.py b/homeassistant/components/iron_os/sensor.py
index 095ffd254df..d178b46723f 100644
--- a/homeassistant/components/iron_os/sensor.py
+++ b/homeassistant/components/iron_os/sensor.py
@@ -28,8 +28,12 @@ from homeassistant.helpers.typing import StateType
from . import IronOSConfigEntry
from .const import OHM
+from .coordinator import IronOSLiveDataCoordinator
from .entity import IronOSBaseEntity
+# Coordinator is used to centralize the data updates
+PARALLEL_UPDATES = 0
+
class PinecilSensor(StrEnum):
"""Pinecil Sensors."""
@@ -54,7 +58,7 @@ class PinecilSensor(StrEnum):
class IronOSSensorEntityDescription(SensorEntityDescription):
"""IronOS sensor entity descriptions."""
- value_fn: Callable[[LiveDataResponse], StateType]
+ value_fn: Callable[[LiveDataResponse, bool], StateType]
PINECIL_SENSOR_DESCRIPTIONS: tuple[IronOSSensorEntityDescription, ...] = (
@@ -64,7 +68,7 @@ PINECIL_SENSOR_DESCRIPTIONS: tuple[IronOSSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
- value_fn=lambda data: data.live_temp,
+ value_fn=lambda data, has_tip: data.live_temp if has_tip else None,
),
IronOSSensorEntityDescription(
key=PinecilSensor.DC_VOLTAGE,
@@ -72,7 +76,7 @@ PINECIL_SENSOR_DESCRIPTIONS: tuple[IronOSSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
device_class=SensorDeviceClass.VOLTAGE,
state_class=SensorStateClass.MEASUREMENT,
- value_fn=lambda data: data.dc_voltage,
+ value_fn=lambda data, _: data.dc_voltage,
entity_category=EntityCategory.DIAGNOSTIC,
),
IronOSSensorEntityDescription(
@@ -81,7 +85,7 @@ PINECIL_SENSOR_DESCRIPTIONS: tuple[IronOSSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
- value_fn=lambda data: data.handle_temp,
+ value_fn=lambda data, _: data.handle_temp,
),
IronOSSensorEntityDescription(
key=PinecilSensor.PWMLEVEL,
@@ -90,7 +94,7 @@ PINECIL_SENSOR_DESCRIPTIONS: tuple[IronOSSensorEntityDescription, ...] = (
suggested_display_precision=0,
device_class=SensorDeviceClass.POWER_FACTOR,
state_class=SensorStateClass.MEASUREMENT,
- value_fn=lambda data: data.pwm_level,
+ value_fn=lambda data, _: data.pwm_level,
entity_category=EntityCategory.DIAGNOSTIC,
),
IronOSSensorEntityDescription(
@@ -98,15 +102,18 @@ PINECIL_SENSOR_DESCRIPTIONS: tuple[IronOSSensorEntityDescription, ...] = (
translation_key=PinecilSensor.POWER_SRC,
device_class=SensorDeviceClass.ENUM,
options=[item.name.lower() for item in PowerSource],
- value_fn=lambda data: data.power_src.name.lower() if data.power_src else None,
+ value_fn=(
+ lambda data, _: data.power_src.name.lower() if data.power_src else None
+ ),
entity_category=EntityCategory.DIAGNOSTIC,
),
IronOSSensorEntityDescription(
key=PinecilSensor.TIP_RESISTANCE,
translation_key=PinecilSensor.TIP_RESISTANCE,
native_unit_of_measurement=OHM,
- value_fn=lambda data: data.tip_resistance,
+ value_fn=lambda data, has_tip: data.tip_resistance if has_tip else None,
entity_category=EntityCategory.DIAGNOSTIC,
+ state_class=SensorStateClass.MEASUREMENT,
),
IronOSSensorEntityDescription(
key=PinecilSensor.UPTIME,
@@ -114,7 +121,7 @@ PINECIL_SENSOR_DESCRIPTIONS: tuple[IronOSSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfTime.SECONDS,
device_class=SensorDeviceClass.DURATION,
state_class=SensorStateClass.TOTAL_INCREASING,
- value_fn=lambda data: data.uptime,
+ value_fn=lambda data, _: data.uptime,
entity_category=EntityCategory.DIAGNOSTIC,
),
IronOSSensorEntityDescription(
@@ -123,7 +130,7 @@ PINECIL_SENSOR_DESCRIPTIONS: tuple[IronOSSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfTime.SECONDS,
device_class=SensorDeviceClass.DURATION,
state_class=SensorStateClass.MEASUREMENT,
- value_fn=lambda data: data.movement_time,
+ value_fn=lambda data, _: data.movement_time,
entity_category=EntityCategory.DIAGNOSTIC,
),
IronOSSensorEntityDescription(
@@ -131,17 +138,17 @@ PINECIL_SENSOR_DESCRIPTIONS: tuple[IronOSSensorEntityDescription, ...] = (
translation_key=PinecilSensor.MAX_TIP_TEMP_ABILITY,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
- value_fn=lambda data: data.max_tip_temp_ability,
+ value_fn=lambda data, has_tip: data.max_tip_temp_ability if has_tip else None,
entity_category=EntityCategory.DIAGNOSTIC,
),
IronOSSensorEntityDescription(
key=PinecilSensor.TIP_VOLTAGE,
translation_key=PinecilSensor.TIP_VOLTAGE,
- native_unit_of_measurement=UnitOfElectricPotential.MILLIVOLT,
+ native_unit_of_measurement=UnitOfElectricPotential.MICROVOLT,
device_class=SensorDeviceClass.VOLTAGE,
state_class=SensorStateClass.MEASUREMENT,
- suggested_display_precision=3,
- value_fn=lambda data: data.tip_voltage,
+ suggested_display_precision=0,
+ value_fn=lambda data, has_tip: data.tip_voltage if has_tip else None,
entity_category=EntityCategory.DIAGNOSTIC,
),
IronOSSensorEntityDescription(
@@ -149,7 +156,7 @@ PINECIL_SENSOR_DESCRIPTIONS: tuple[IronOSSensorEntityDescription, ...] = (
translation_key=PinecilSensor.HALL_SENSOR,
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
- value_fn=lambda data: data.hall_sensor,
+ value_fn=lambda data, _: data.hall_sensor,
entity_category=EntityCategory.DIAGNOSTIC,
),
IronOSSensorEntityDescription(
@@ -158,7 +165,7 @@ PINECIL_SENSOR_DESCRIPTIONS: tuple[IronOSSensorEntityDescription, ...] = (
device_class=SensorDeviceClass.ENUM,
options=[item.name.lower() for item in OperatingMode],
value_fn=(
- lambda data: data.operating_mode.name.lower()
+ lambda data, _: data.operating_mode.name.lower()
if data.operating_mode
else None
),
@@ -169,7 +176,7 @@ PINECIL_SENSOR_DESCRIPTIONS: tuple[IronOSSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfPower.WATT,
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
- value_fn=lambda data: data.estimated_power,
+ value_fn=lambda data, _: data.estimated_power,
),
)
@@ -180,7 +187,7 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up sensors from a config entry."""
- coordinator = entry.runtime_data
+ coordinator = entry.runtime_data.live_data
async_add_entities(
IronOSSensorEntity(coordinator, description)
@@ -192,8 +199,11 @@ class IronOSSensorEntity(IronOSBaseEntity, SensorEntity):
"""Representation of a IronOS sensor entity."""
entity_description: IronOSSensorEntityDescription
+ coordinator: IronOSLiveDataCoordinator
@property
def native_value(self) -> StateType:
"""Return sensor state."""
- return self.entity_description.value_fn(self.coordinator.data)
+ return self.entity_description.value_fn(
+ self.coordinator.data, self.coordinator.has_tip
+ )
diff --git a/homeassistant/components/iron_os/strings.json b/homeassistant/components/iron_os/strings.json
index 75584fe191c..548ba1d8127 100644
--- a/homeassistant/components/iron_os/strings.json
+++ b/homeassistant/components/iron_os/strings.json
@@ -1,14 +1,21 @@
{
+ "common": {
+ "slow": "Slow",
+ "fast": "Fast"
+ },
"config": {
"step": {
"user": {
"description": "[%key:component::bluetooth::config::step::user::description%]",
"data": {
"address": "[%key:common::config_flow::data::device%]"
+ },
+ "data_description": {
+ "address": "Ensure your device is powered on and within Bluetooth range before continuing"
}
},
"bluetooth_confirm": {
- "description": "[%key:component::bluetooth::config::step::bluetooth_confirm::description%]"
+ "description": "Do you want to set up {name}?\n\n*Ensure your device is powered on and within Bluetooth range before continuing*"
}
},
"abort": {
@@ -17,9 +24,148 @@
}
},
"entity": {
+ "binary_sensor": {
+ "tip_connected": {
+ "name": "Soldering tip"
+ }
+ },
+ "button": {
+ "settings_save": {
+ "name": "Save settings"
+ },
+ "settings_reset": {
+ "name": "Restore default settings"
+ }
+ },
"number": {
"setpoint_temperature": {
"name": "Setpoint temperature"
+ },
+ "sleep_temperature": {
+ "name": "Sleep temperature"
+ },
+ "sleep_timeout": {
+ "name": "Sleep timeout"
+ },
+ "qc_max_voltage": {
+ "name": "Quick Charge voltage"
+ },
+ "pd_timeout": {
+ "name": "Power Delivery timeout"
+ },
+ "boost_temp": {
+ "name": "Boost temperature"
+ },
+ "shutdown_timeout": {
+ "name": "Shutdown timeout"
+ },
+ "display_brightness": {
+ "name": "Display brightness"
+ },
+ "power_limit": {
+ "name": "Power limit"
+ },
+ "calibration_offset": {
+ "name": "Calibration offset"
+ },
+ "hall_sensitivity": {
+ "name": "Hall effect sensitivity"
+ },
+ "min_voltage_per_cell": {
+ "name": "Min. voltage per cell"
+ },
+ "accel_sensitivity": {
+ "name": "Motion sensitivity"
+ },
+ "keep_awake_pulse_power": {
+ "name": "Keep-awake pulse intensity"
+ },
+ "keep_awake_pulse_delay": {
+ "name": "Keep-awake pulse delay"
+ },
+ "keep_awake_pulse_duration": {
+ "name": "Keep-awake pulse duration"
+ },
+ "voltage_div": {
+ "name": "Voltage divider"
+ },
+ "temp_increment_short": {
+ "name": "Short-press temperature step"
+ },
+ "temp_increment_long": {
+ "name": "Long-press temperature step"
+ }
+ },
+ "select": {
+ "min_dc_voltage_cells": {
+ "name": "Power source",
+ "state": {
+ "no_battery": "External power supply (DC)",
+ "battery_3s": "3S (3 cells)",
+ "battery_4s": "4S (4 cells)",
+ "battery_5s": "5S (5 cells)",
+ "battery_6s": "6S (6 cells)"
+ }
+ },
+ "orientation_mode": {
+ "name": "Display orientation mode",
+ "state": {
+ "right_handed": "Right-handed",
+ "left_handed": "Left-handed",
+ "auto": "Auto"
+ }
+ },
+ "animation_speed": {
+ "name": "Animation speed",
+ "state": {
+ "off": "[%key:common::state::off%]",
+ "slow": "[%key:component::iron_os::common::slow%]",
+ "medium": "Medium",
+ "fast": "[%key:component::iron_os::common::fast%]"
+ }
+ },
+ "autostart_mode": {
+ "name": "Start-up behavior",
+ "state": {
+ "disabled": "[%key:common::state::disabled%]",
+ "soldering": "Soldering mode",
+ "sleeping": "Sleeping mode",
+ "idle": "Idle mode"
+ }
+ },
+ "temp_unit": {
+ "name": "Temperature display unit",
+ "state": {
+ "celsius": "Celsius (°C)",
+ "fahrenheit": "Fahrenheit (°F)"
+ }
+ },
+ "desc_scroll_speed": {
+ "name": "Scrolling speed",
+ "state": {
+ "slow": "[%key:component::iron_os::common::slow%]",
+ "fast": "[%key:component::iron_os::common::fast%]"
+ }
+ },
+ "locking_mode": {
+ "name": "Button locking mode",
+ "state": {
+ "off": "[%key:common::state::off%]",
+ "boost_only": "Boost only",
+ "full_locking": "Full locking"
+ }
+ },
+ "logo_duration": {
+ "name": "Boot logo duration",
+ "state": {
+ "off": "[%key:common::state::off%]",
+ "seconds_1": "1 second",
+ "seconds_2": "2 second",
+ "seconds_3": "3 second",
+ "seconds_4": "4 second",
+ "seconds_5": "5 second",
+ "loop": "Loop"
+ }
}
},
"sensor": {
@@ -76,6 +222,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/iron_os/update.py b/homeassistant/components/iron_os/update.py
index 786ba86f730..b431d321f24 100644
--- a/homeassistant/components/iron_os/update.py
+++ b/homeassistant/components/iron_os/update.py
@@ -15,6 +15,8 @@ from . import IRON_OS_KEY, IronOSConfigEntry, IronOSLiveDataCoordinator
from .coordinator import IronOSFirmwareUpdateCoordinator
from .entity import IronOSBaseEntity
+PARALLEL_UPDATES = 0
+
UPDATE_DESCRIPTION = UpdateEntityDescription(
key="firmware",
device_class=UpdateDeviceClass.FIRMWARE,
@@ -28,7 +30,7 @@ async def async_setup_entry(
) -> None:
"""Set up IronOS update platform."""
- coordinator = entry.runtime_data
+ coordinator = entry.runtime_data.live_data
async_add_entities(
[IronOSUpdate(coordinator, hass.data[IRON_OS_KEY], UPDATE_DESCRIPTION)]
diff --git a/homeassistant/components/ista_ecotrend/quality_scale.yaml b/homeassistant/components/ista_ecotrend/quality_scale.yaml
new file mode 100644
index 00000000000..b942ecba487
--- /dev/null
+++ b/homeassistant/components/ista_ecotrend/quality_scale.yaml
@@ -0,0 +1,80 @@
+rules:
+ # Bronze
+ action-setup:
+ status: exempt
+ comment: The integration registers no actions.
+ appropriate-polling: done
+ brands: done
+ common-modules:
+ status: todo
+ comment: Group the 3 different executor jobs as one executor job
+ config-flow-test-coverage:
+ status: todo
+ comment: test_form/docstrings outdated, test already_configuret, test abort conditions in reauth,
+ config-flow: done
+ dependency-transparency: done
+ docs-actions:
+ status: exempt
+ comment: The integration registers no actions.
+ docs-high-level-description: done
+ docs-installation-instructions: done
+ docs-removal-instructions: done
+ entity-event-setup:
+ status: exempt
+ comment: The integration registers no 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: The integration registers no actions.
+ config-entry-unloading: done
+ docs-configuration-parameters:
+ status: exempt
+ comment: Integration has no configuration parameters
+ 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: The integration is a web service, there are no discoverable devices.
+ discovery:
+ status: exempt
+ comment: The integration is a web service, there are no discoverable devices.
+ docs-data-update: todo
+ docs-examples: todo
+ docs-known-limitations: done
+ docs-supported-devices: done
+ docs-supported-functions: done
+ docs-troubleshooting: todo
+ docs-use-cases: done
+ dynamic-devices: todo
+ entity-category:
+ status: done
+ comment: The default category is appropriate.
+ entity-device-class: done
+ entity-disabled-by-default: done
+ entity-translations: done
+ exception-translations: done
+ icon-translations: done
+ reconfiguration-flow: todo
+ repair-issues: todo
+ stale-devices: todo
+
+ # Platinum
+ async-dependency: todo
+ inject-websession: todo
+ strict-typing: todo
diff --git a/homeassistant/components/ista_ecotrend/sensor.py b/homeassistant/components/ista_ecotrend/sensor.py
index 7aa1adfe4c9..eb06fabe373 100644
--- a/homeassistant/components/ista_ecotrend/sensor.py
+++ b/homeassistant/components/ista_ecotrend/sensor.py
@@ -40,6 +40,8 @@ from .coordinator import IstaCoordinator
from .util import IstaConsumptionType, IstaValueType, get_native_value, get_statistics
_LOGGER = logging.getLogger(__name__)
+# Coordinator is used to centralize the data updates
+PARALLEL_UPDATES = 0
@dataclass(kw_only=True, frozen=True)
@@ -71,7 +73,6 @@ SENSOR_DESCRIPTIONS: tuple[IstaSensorEntityDescription, ...] = (
translation_key=IstaSensorEntity.HEATING,
suggested_display_precision=0,
consumption_type=IstaConsumptionType.HEATING,
- native_unit_of_measurement="units",
state_class=SensorStateClass.TOTAL,
),
IstaSensorEntityDescription(
diff --git a/homeassistant/components/ista_ecotrend/strings.json b/homeassistant/components/ista_ecotrend/strings.json
index f76cf5286cb..e7c37461b19 100644
--- a/homeassistant/components/ista_ecotrend/strings.json
+++ b/homeassistant/components/ista_ecotrend/strings.json
@@ -14,14 +14,23 @@
"data": {
"email": "[%key:common::config_flow::data::email%]",
"password": "[%key:common::config_flow::data::password%]"
- }
+ },
+ "data_description": {
+ "email": "Enter the email address associated with your ista EcoTrend account",
+ "password": "Enter the password for your ista EcoTrend account"
+ },
+ "description": "Connect your **ista EcoTrend** account to Home Assistant to access your monthly heating and water usage data."
},
"reauth_confirm": {
"title": "[%key:common::config_flow::title::reauth%]",
- "description": "Please reenter the password for: {email}",
+ "description": "Re-enter your password for `{email}` to reconnect your ista EcoTrend account to Home Assistant.",
"data": {
"email": "[%key:common::config_flow::data::email%]",
"password": "[%key:common::config_flow::data::password%]"
+ },
+ "data_description": {
+ "email": "[%key:component::ista_ecotrend::config::step::user::data_description::email%]",
+ "password": "[%key:component::ista_ecotrend::config::step::user::data_description::password%]"
}
}
}
@@ -29,7 +38,8 @@
"entity": {
"sensor": {
"heating": {
- "name": "Heating"
+ "name": "Heating",
+ "unit_of_measurement": "units"
},
"heating_cost": {
"name": "Heating cost"
diff --git a/homeassistant/components/isy994/climate.py b/homeassistant/components/isy994/climate.py
index d4376b5a3b4..d5deba56284 100644
--- a/homeassistant/components/isy994/climate.py
+++ b/homeassistant/components/isy994/climate.py
@@ -88,7 +88,6 @@ class ISYThermostatEntity(ISYNodeEntity, ClimateEntity):
)
_attr_target_temperature_step = 1.0
_attr_fan_modes = [FAN_AUTO, FAN_ON]
- _enable_turn_on_off_backwards_compatibility = False
def __init__(self, node: Node, device_info: DeviceInfo | None = None) -> None:
"""Initialize the ISY Thermostat entity."""
diff --git a/homeassistant/components/isy994/fan.py b/homeassistant/components/isy994/fan.py
index 1d8af78f83c..fc0406e2d5f 100644
--- a/homeassistant/components/isy994/fan.py
+++ b/homeassistant/components/isy994/fan.py
@@ -53,7 +53,6 @@ class ISYFanEntity(ISYNodeEntity, FanEntity):
| FanEntityFeature.TURN_OFF
| FanEntityFeature.TURN_ON
)
- _enable_turn_on_off_backwards_compatibility = False
@property
def percentage(self) -> int | None:
diff --git a/homeassistant/components/itach/manifest.json b/homeassistant/components/itach/manifest.json
index 2928620b952..68b34b4321e 100644
--- a/homeassistant/components/itach/manifest.json
+++ b/homeassistant/components/itach/manifest.json
@@ -4,5 +4,6 @@
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/itach",
"iot_class": "assumed_state",
+ "quality_scale": "legacy",
"requirements": ["pyitachip2ir==0.0.7"]
}
diff --git a/homeassistant/components/itunes/manifest.json b/homeassistant/components/itunes/manifest.json
index f1135dbf847..a12271d04d7 100644
--- a/homeassistant/components/itunes/manifest.json
+++ b/homeassistant/components/itunes/manifest.json
@@ -3,5 +3,6 @@
"name": "Apple iTunes",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/itunes",
- "iot_class": "local_polling"
+ "iot_class": "local_polling",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/ituran/__init__.py b/homeassistant/components/ituran/__init__.py
new file mode 100644
index 00000000000..bf9cff238cd
--- /dev/null
+++ b/homeassistant/components/ituran/__init__.py
@@ -0,0 +1,29 @@
+"""The Ituran integration."""
+
+from __future__ import annotations
+
+from homeassistant.const import Platform
+from homeassistant.core import HomeAssistant
+
+from .coordinator import IturanConfigEntry, IturanDataUpdateCoordinator
+
+PLATFORMS: list[Platform] = [
+ Platform.DEVICE_TRACKER,
+ Platform.SENSOR,
+]
+
+
+async def async_setup_entry(hass: HomeAssistant, entry: IturanConfigEntry) -> bool:
+ """Set up Ituran from a config entry."""
+
+ coordinator = IturanDataUpdateCoordinator(hass, entry=entry)
+ await coordinator.async_config_entry_first_refresh()
+ entry.runtime_data = coordinator
+ await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
+
+ return True
+
+
+async def async_unload_entry(hass: HomeAssistant, entry: IturanConfigEntry) -> bool:
+ """Unload a config entry."""
+ return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
diff --git a/homeassistant/components/ituran/config_flow.py b/homeassistant/components/ituran/config_flow.py
new file mode 100644
index 00000000000..9709e471503
--- /dev/null
+++ b/homeassistant/components/ituran/config_flow.py
@@ -0,0 +1,137 @@
+"""Config flow for Ituran integration."""
+
+from __future__ import annotations
+
+from collections.abc import Mapping
+import logging
+from typing import Any
+
+from pyituran import Ituran
+from pyituran.exceptions import IturanApiError, IturanAuthError
+import voluptuous as vol
+
+from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult
+
+from .const import (
+ CONF_ID_OR_PASSPORT,
+ CONF_MOBILE_ID,
+ CONF_OTP,
+ CONF_PHONE_NUMBER,
+ DOMAIN,
+)
+
+_LOGGER = logging.getLogger(__name__)
+
+STEP_USER_DATA_SCHEMA = vol.Schema(
+ {
+ vol.Required(CONF_ID_OR_PASSPORT): str,
+ vol.Required(CONF_PHONE_NUMBER): str,
+ }
+)
+
+STEP_OTP_DATA_SCHEMA = vol.Schema(
+ {
+ vol.Required(CONF_OTP): str,
+ }
+)
+
+
+class IturanConfigFlow(ConfigFlow, domain=DOMAIN):
+ """Handle a config flow for Ituran."""
+
+ _user_info: dict[str, Any]
+
+ async def async_step_user(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Handle the initial step."""
+ errors: dict[str, str] = {}
+ if user_input is not None:
+ await self.async_set_unique_id(user_input[CONF_ID_OR_PASSPORT])
+ if self.source != SOURCE_REAUTH:
+ self._abort_if_unique_id_configured()
+
+ ituran = Ituran(
+ user_input[CONF_ID_OR_PASSPORT],
+ user_input[CONF_PHONE_NUMBER],
+ )
+ user_input[CONF_MOBILE_ID] = ituran.mobile_id
+ try:
+ authenticated = await ituran.is_authenticated()
+ if not authenticated:
+ await ituran.request_otp()
+ except IturanApiError:
+ errors["base"] = "cannot_connect"
+ except IturanAuthError:
+ errors["base"] = "invalid_auth"
+ except Exception:
+ _LOGGER.exception("Unexpected exception")
+ errors["base"] = "unknown"
+ else:
+ if authenticated:
+ return self.async_create_entry(
+ title=f"Ituran {user_input[CONF_ID_OR_PASSPORT]}",
+ data=user_input,
+ )
+ self._user_info = user_input
+ return await self.async_step_otp()
+
+ return self.async_show_form(
+ step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
+ )
+
+ async def async_step_otp(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Handle the OTP step."""
+ errors: dict[str, str] = {}
+ if user_input is not None:
+ ituran = Ituran(
+ self._user_info[CONF_ID_OR_PASSPORT],
+ self._user_info[CONF_PHONE_NUMBER],
+ self._user_info[CONF_MOBILE_ID],
+ )
+ try:
+ await ituran.authenticate(user_input[CONF_OTP])
+ except IturanApiError:
+ errors["base"] = "cannot_connect"
+ except IturanAuthError:
+ errors["base"] = "invalid_otp"
+ except Exception:
+ _LOGGER.exception("Unexpected exception")
+ errors["base"] = "unknown"
+ else:
+ if self.source == SOURCE_REAUTH:
+ return self.async_update_reload_and_abort(
+ self._get_reauth_entry(), data=self._user_info
+ )
+ return self.async_create_entry(
+ title=f"Ituran {self._user_info[CONF_ID_OR_PASSPORT]}",
+ data=self._user_info,
+ )
+
+ return self.async_show_form(
+ step_id="otp", data_schema=STEP_OTP_DATA_SCHEMA, errors=errors
+ )
+
+ async def async_step_reauth(
+ self, entry_data: Mapping[str, Any]
+ ) -> ConfigFlowResult:
+ """Handle configuration by re-auth."""
+ self._user_info = dict(entry_data)
+ return await self.async_step_reauth_confirm()
+
+ async def async_step_reauth_confirm(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Handle reauth confirmation message."""
+ if user_input is not None:
+ return await self.async_step_user(self._user_info)
+
+ return self.async_show_form(
+ step_id="reauth_confirm",
+ data_schema=vol.Schema({}),
+ description_placeholders={
+ "phone_number": self._user_info[CONF_PHONE_NUMBER]
+ },
+ )
diff --git a/homeassistant/components/ituran/const.py b/homeassistant/components/ituran/const.py
new file mode 100644
index 00000000000..b17271490ee
--- /dev/null
+++ b/homeassistant/components/ituran/const.py
@@ -0,0 +1,13 @@
+"""Constants for the Ituran integration."""
+
+from datetime import timedelta
+from typing import Final
+
+DOMAIN = "ituran"
+
+CONF_ID_OR_PASSPORT: Final = "id_or_passport"
+CONF_PHONE_NUMBER: Final = "phone_number"
+CONF_MOBILE_ID: Final = "mobile_id"
+CONF_OTP: Final = "otp"
+
+UPDATE_INTERVAL = timedelta(seconds=300)
diff --git a/homeassistant/components/ituran/coordinator.py b/homeassistant/components/ituran/coordinator.py
new file mode 100644
index 00000000000..cd0949eb4c2
--- /dev/null
+++ b/homeassistant/components/ituran/coordinator.py
@@ -0,0 +1,76 @@
+"""Coordinator for Ituran."""
+
+import logging
+
+from pyituran import Ituran, Vehicle
+from pyituran.exceptions import IturanApiError, IturanAuthError
+
+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.update_coordinator import DataUpdateCoordinator, UpdateFailed
+
+from .const import (
+ CONF_ID_OR_PASSPORT,
+ CONF_MOBILE_ID,
+ CONF_PHONE_NUMBER,
+ DOMAIN,
+ UPDATE_INTERVAL,
+)
+
+_LOGGER = logging.getLogger(__name__)
+
+type IturanConfigEntry = ConfigEntry[IturanDataUpdateCoordinator]
+
+
+class IturanDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Vehicle]]):
+ """Class to manage fetching Ituran data."""
+
+ config_entry: IturanConfigEntry
+
+ def __init__(self, hass: HomeAssistant, entry: IturanConfigEntry) -> None:
+ """Initialize account-wide Ituran data updater."""
+ super().__init__(
+ hass,
+ _LOGGER,
+ name=f"{DOMAIN}-{entry.data[CONF_ID_OR_PASSPORT]}",
+ update_interval=UPDATE_INTERVAL,
+ config_entry=entry,
+ )
+ self.ituran = Ituran(
+ entry.data[CONF_ID_OR_PASSPORT],
+ entry.data[CONF_PHONE_NUMBER],
+ entry.data[CONF_MOBILE_ID],
+ )
+
+ async def _async_update_data(self) -> dict[str, Vehicle]:
+ """Fetch data from Ituran."""
+
+ try:
+ vehicles = await self.ituran.get_vehicles()
+ except IturanApiError as e:
+ raise UpdateFailed(
+ translation_domain=DOMAIN, translation_key="api_error"
+ ) from e
+ except IturanAuthError as e:
+ raise ConfigEntryAuthFailed(
+ translation_domain=DOMAIN, translation_key="auth_error"
+ ) from e
+
+ updated_data = {vehicle.license_plate: vehicle for vehicle in vehicles}
+ self._cleanup_removed_vehicles(updated_data)
+
+ return updated_data
+
+ def _cleanup_removed_vehicles(self, data: dict[str, Vehicle]) -> None:
+ account_vehicles = {(DOMAIN, license_plate) for license_plate in data}
+ device_registry = dr.async_get(self.hass)
+ device_entries = dr.async_entries_for_config_entry(
+ device_registry, config_entry_id=self.config_entry.entry_id
+ )
+ for device in device_entries:
+ if not device.identifiers.intersection(account_vehicles):
+ device_registry.async_update_device(
+ device.id, remove_config_entry_id=self.config_entry.entry_id
+ )
diff --git a/homeassistant/components/ituran/device_tracker.py b/homeassistant/components/ituran/device_tracker.py
new file mode 100644
index 00000000000..37796570c61
--- /dev/null
+++ b/homeassistant/components/ituran/device_tracker.py
@@ -0,0 +1,49 @@
+"""Device tracker for Ituran vehicles."""
+
+from __future__ import annotations
+
+from homeassistant.components.device_tracker import TrackerEntity
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+
+from . import IturanConfigEntry
+from .coordinator import IturanDataUpdateCoordinator
+from .entity import IturanBaseEntity
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ config_entry: IturanConfigEntry,
+ async_add_entities: AddEntitiesCallback,
+) -> None:
+ """Set up the Ituran tracker from config entry."""
+ coordinator = config_entry.runtime_data
+ async_add_entities(
+ IturanDeviceTracker(coordinator, license_plate)
+ for license_plate in coordinator.data
+ )
+
+
+class IturanDeviceTracker(IturanBaseEntity, TrackerEntity):
+ """Ituran device tracker."""
+
+ _attr_translation_key = "car"
+ _attr_name = None
+
+ def __init__(
+ self,
+ coordinator: IturanDataUpdateCoordinator,
+ license_plate: str,
+ ) -> None:
+ """Initialize the device tracker."""
+ super().__init__(coordinator, license_plate, "device_tracker")
+
+ @property
+ def latitude(self) -> float | None:
+ """Return latitude value of the device."""
+ return self.vehicle.gps_coordinates[0]
+
+ @property
+ def longitude(self) -> float | None:
+ """Return longitude value of the device."""
+ return self.vehicle.gps_coordinates[1]
diff --git a/homeassistant/components/ituran/entity.py b/homeassistant/components/ituran/entity.py
new file mode 100644
index 00000000000..597cdac9513
--- /dev/null
+++ b/homeassistant/components/ituran/entity.py
@@ -0,0 +1,47 @@
+"""Base for all turan entities."""
+
+from __future__ import annotations
+
+from pyituran import Vehicle
+
+from homeassistant.helpers.device_registry import DeviceInfo
+from homeassistant.helpers.update_coordinator import CoordinatorEntity
+
+from .const import DOMAIN
+from .coordinator import IturanDataUpdateCoordinator
+
+
+class IturanBaseEntity(CoordinatorEntity[IturanDataUpdateCoordinator]):
+ """Common base for Ituran entities."""
+
+ _attr_has_entity_name = True
+
+ def __init__(
+ self,
+ coordinator: IturanDataUpdateCoordinator,
+ license_plate: str,
+ unique_key: str,
+ ) -> None:
+ """Initialize the entity."""
+ super().__init__(coordinator)
+
+ self._license_plate = license_plate
+ self._attr_unique_id = f"{license_plate}-{unique_key}"
+
+ self._attr_device_info = DeviceInfo(
+ identifiers={(DOMAIN, self.vehicle.license_plate)},
+ manufacturer=self.vehicle.make,
+ model=self.vehicle.model,
+ name=self.vehicle.model,
+ serial_number=self.vehicle.license_plate,
+ )
+
+ @property
+ def available(self) -> bool:
+ """Return True if vehicle is still included in the account."""
+ return super().available and self._license_plate in self.coordinator.data
+
+ @property
+ def vehicle(self) -> Vehicle:
+ """Return the vehicle information associated with this entity."""
+ return self.coordinator.data[self._license_plate]
diff --git a/homeassistant/components/ituran/icons.json b/homeassistant/components/ituran/icons.json
new file mode 100644
index 00000000000..bd9182f1569
--- /dev/null
+++ b/homeassistant/components/ituran/icons.json
@@ -0,0 +1,20 @@
+{
+ "entity": {
+ "device_tracker": {
+ "car": {
+ "default": "mdi:car"
+ }
+ },
+ "sensor": {
+ "address": {
+ "default": "mdi:map-marker"
+ },
+ "battery_voltage": {
+ "default": "mdi:car-battery"
+ },
+ "heading": {
+ "default": "mdi:compass"
+ }
+ }
+ }
+}
diff --git a/homeassistant/components/ituran/manifest.json b/homeassistant/components/ituran/manifest.json
new file mode 100644
index 00000000000..0cf20d3c6b2
--- /dev/null
+++ b/homeassistant/components/ituran/manifest.json
@@ -0,0 +1,11 @@
+{
+ "domain": "ituran",
+ "name": "Ituran",
+ "codeowners": ["@shmuelzon"],
+ "config_flow": true,
+ "documentation": "https://www.home-assistant.io/integrations/ituran",
+ "integration_type": "hub",
+ "iot_class": "cloud_polling",
+ "quality_scale": "silver",
+ "requirements": ["pyituran==0.1.4"]
+}
diff --git a/homeassistant/components/ituran/quality_scale.yaml b/homeassistant/components/ituran/quality_scale.yaml
new file mode 100644
index 00000000000..cd7e17c3b12
--- /dev/null
+++ b/homeassistant/components/ituran/quality_scale.yaml
@@ -0,0 +1,89 @@
+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 of this integration does not explicitly subscribe to events.
+ dependency-transparency: done
+ action-setup:
+ status: exempt
+ comment: |
+ This integration does not provide additional actions.
+ common-modules: done
+ docs-high-level-description: done
+ docs-installation-instructions: done
+ docs-removal-instructions: done
+ docs-actions:
+ status: exempt
+ comment: |
+ This integration does not provide additional actions.
+ brands: done
+ # Silver
+ config-entry-unloading: done
+ log-when-unavailable: done
+ entity-unavailable: done
+ action-exceptions:
+ status: exempt
+ comment: |
+ This integration does not provide additional actions.
+ reauthentication-flow: done
+ parallel-updates:
+ status: exempt
+ comment: |
+ Read only platforms and coordinator.
+ test-coverage: done
+ integration-owner: done
+ docs-installation-parameters: done
+ docs-configuration-parameters:
+ status: exempt
+ comment: |
+ No options flow.
+ # Gold
+ entity-translations: done
+ entity-device-class:
+ status: exempt
+ comment: |
+ Only device_tracker platform.
+ devices: done
+ entity-category: todo
+ entity-disabled-by-default: done
+ discovery:
+ status: exempt
+ comment: |
+ This integration cannot be discovered, it is a connecting to a service
+ provider, which uses the users credentials to get the data.
+ stale-devices: todo
+ diagnostics: todo
+ exception-translations: done
+ icon-translations: done
+ reconfiguration-flow: todo
+ dynamic-devices: done
+ discovery-update-info:
+ status: exempt
+ comment: |
+ This integration cannot be discovered, it is a connecting to a service
+ provider, which uses the users credentials to get the data.
+ repair-issues:
+ status: exempt
+ comment: |
+ No repairs/issues.
+ docs-use-cases: todo
+ docs-supported-devices: done
+ docs-supported-functions: done
+ docs-data-update: done
+ docs-known-limitations: done
+ docs-troubleshooting: todo
+ docs-examples: todo
+ # Platinum
+ async-dependency: done
+ inject-websession: todo
+ strict-typing: todo
diff --git a/homeassistant/components/ituran/sensor.py b/homeassistant/components/ituran/sensor.py
new file mode 100644
index 00000000000..e962f5bd561
--- /dev/null
+++ b/homeassistant/components/ituran/sensor.py
@@ -0,0 +1,119 @@
+"""Sensors for Ituran vehicles."""
+
+from __future__ import annotations
+
+from collections.abc import Callable
+from dataclasses import dataclass
+from datetime import datetime
+
+from pyituran import Vehicle
+
+from homeassistant.components.sensor import (
+ SensorDeviceClass,
+ SensorEntity,
+ SensorEntityDescription,
+)
+from homeassistant.const import (
+ DEGREE,
+ UnitOfElectricPotential,
+ UnitOfLength,
+ UnitOfSpeed,
+)
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.typing import StateType
+
+from . import IturanConfigEntry
+from .coordinator import IturanDataUpdateCoordinator
+from .entity import IturanBaseEntity
+
+
+@dataclass(frozen=True, kw_only=True)
+class IturanSensorEntityDescription(SensorEntityDescription):
+ """Describes Ituran sensor entity."""
+
+ value_fn: Callable[[Vehicle], StateType | datetime]
+
+
+SENSOR_TYPES: list[IturanSensorEntityDescription] = [
+ IturanSensorEntityDescription(
+ key="address",
+ translation_key="address",
+ entity_registry_enabled_default=False,
+ value_fn=lambda vehicle: vehicle.address,
+ ),
+ IturanSensorEntityDescription(
+ key="battery_voltage",
+ translation_key="battery_voltage",
+ device_class=SensorDeviceClass.VOLTAGE,
+ native_unit_of_measurement=UnitOfElectricPotential.VOLT,
+ suggested_display_precision=0,
+ entity_registry_enabled_default=False,
+ value_fn=lambda vehicle: vehicle.battery_voltage,
+ ),
+ IturanSensorEntityDescription(
+ key="heading",
+ translation_key="heading",
+ native_unit_of_measurement=DEGREE,
+ suggested_display_precision=0,
+ entity_registry_enabled_default=False,
+ value_fn=lambda vehicle: vehicle.heading,
+ ),
+ IturanSensorEntityDescription(
+ key="last_update_from_vehicle",
+ translation_key="last_update_from_vehicle",
+ device_class=SensorDeviceClass.TIMESTAMP,
+ entity_registry_enabled_default=False,
+ value_fn=lambda vehicle: vehicle.last_update,
+ ),
+ IturanSensorEntityDescription(
+ key="mileage",
+ translation_key="mileage",
+ device_class=SensorDeviceClass.DISTANCE,
+ native_unit_of_measurement=UnitOfLength.KILOMETERS,
+ suggested_display_precision=2,
+ value_fn=lambda vehicle: vehicle.mileage,
+ ),
+ IturanSensorEntityDescription(
+ key="speed",
+ device_class=SensorDeviceClass.SPEED,
+ native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR,
+ suggested_display_precision=0,
+ value_fn=lambda vehicle: vehicle.speed,
+ ),
+]
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ config_entry: IturanConfigEntry,
+ async_add_entities: AddEntitiesCallback,
+) -> None:
+ """Set up the Ituran sensors from config entry."""
+ coordinator = config_entry.runtime_data
+ async_add_entities(
+ IturanSensor(coordinator, license_plate, description)
+ for description in SENSOR_TYPES
+ for license_plate in coordinator.data
+ )
+
+
+class IturanSensor(IturanBaseEntity, SensorEntity):
+ """Ituran device tracker."""
+
+ entity_description: IturanSensorEntityDescription
+
+ def __init__(
+ self,
+ coordinator: IturanDataUpdateCoordinator,
+ license_plate: str,
+ description: IturanSensorEntityDescription,
+ ) -> None:
+ """Initialize the sensor."""
+ super().__init__(coordinator, license_plate, description.key)
+ self.entity_description = description
+
+ @property
+ def native_value(self) -> StateType | datetime:
+ """Return the state of the device."""
+ return self.entity_description.value_fn(self.vehicle)
diff --git a/homeassistant/components/ituran/strings.json b/homeassistant/components/ituran/strings.json
new file mode 100644
index 00000000000..efc60ef454b
--- /dev/null
+++ b/homeassistant/components/ituran/strings.json
@@ -0,0 +1,65 @@
+{
+ "config": {
+ "step": {
+ "user": {
+ "data": {
+ "id_or_passport": "ID or passport number",
+ "phone_number": "Mobile phone number"
+ },
+ "data_description": {
+ "id_or_passport": "The government ID or passport number provided when registering with Ituran.",
+ "phone_number": "The mobile phone number provided when registering with Ituran. A one-time password will be sent to this mobile number."
+ }
+ },
+ "otp": {
+ "data": {
+ "otp": "OTP"
+ },
+ "data_description": {
+ "otp": "A one-time-password sent as a text message to the mobile phone number provided before."
+ }
+ },
+ "reauth_confirm": {
+ "title": "[%key:common::config_flow::title::reauth%]",
+ "description": "A new one-time password will be sent to {phone_number}."
+ }
+ },
+ "error": {
+ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
+ "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
+ "invalid_otp": "OTP invalid",
+ "unknown": "[%key:common::config_flow::error::unknown%]"
+ },
+ "abort": {
+ "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
+ "already_configured": "[%key:common::config_flow::abort::already_configured_account%]"
+ }
+ },
+ "entity": {
+ "sensor": {
+ "address": {
+ "name": "Address"
+ },
+ "battery_voltage": {
+ "name": "Battery voltage"
+ },
+ "heading": {
+ "name": "Heading"
+ },
+ "last_update_from_vehicle": {
+ "name": "Last update from vehicle"
+ },
+ "mileage": {
+ "name": "Mileage"
+ }
+ }
+ },
+ "exceptions": {
+ "api_error": {
+ "message": "An error occurred while communicating with the Ituran service."
+ },
+ "auth_error": {
+ "message": "Failed authenticating with the Ituran service, please reauthenticate the integration."
+ }
+ }
+}
diff --git a/homeassistant/components/izone/climate.py b/homeassistant/components/izone/climate.py
index 2a602939250..e61917c825b 100644
--- a/homeassistant/components/izone/climate.py
+++ b/homeassistant/components/izone/climate.py
@@ -141,7 +141,6 @@ class ControllerDevice(ClimateEntity):
_attr_has_entity_name = True
_attr_name = None
_attr_target_temperature_step = 0.5
- _enable_turn_on_off_backwards_compatibility = False
def __init__(self, controller: Controller) -> None:
"""Initialise ControllerDevice."""
diff --git a/homeassistant/components/jellyfin/sensor.py b/homeassistant/components/jellyfin/sensor.py
index 24aeecab7e5..5c519f661ee 100644
--- a/homeassistant/components/jellyfin/sensor.py
+++ b/homeassistant/components/jellyfin/sensor.py
@@ -36,7 +36,6 @@ SENSOR_TYPES: tuple[JellyfinSensorEntityDescription, ...] = (
key="watching",
translation_key="watching",
value_fn=_count_now_playing,
- native_unit_of_measurement="clients",
),
)
diff --git a/homeassistant/components/jellyfin/strings.json b/homeassistant/components/jellyfin/strings.json
index f2afa0c8ad5..a9816b1fb78 100644
--- a/homeassistant/components/jellyfin/strings.json
+++ b/homeassistant/components/jellyfin/strings.json
@@ -29,7 +29,8 @@
"entity": {
"sensor": {
"watching": {
- "name": "Active clients"
+ "name": "Active clients",
+ "unit_of_measurement": "clients"
}
}
},
diff --git a/homeassistant/components/jewish_calendar/__init__.py b/homeassistant/components/jewish_calendar/__init__.py
index 4598cf7cd91..823e9bd59be 100644
--- a/homeassistant/components/jewish_calendar/__init__.py
+++ b/homeassistant/components/jewish_calendar/__init__.py
@@ -5,25 +5,17 @@ from __future__ import annotations
from functools import partial
from hdate import Location
-import voluptuous as vol
-from homeassistant.config_entries import SOURCE_IMPORT
from homeassistant.const import (
CONF_ELEVATION,
CONF_LANGUAGE,
CONF_LATITUDE,
CONF_LONGITUDE,
- CONF_NAME,
CONF_TIME_ZONE,
Platform,
)
-from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback
-import homeassistant.helpers.config_validation as cv
-import homeassistant.helpers.entity_registry as er
-from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
-from homeassistant.helpers.typing import ConfigType
+from homeassistant.core import HomeAssistant
-from .binary_sensor import BINARY_SENSORS
from .const import (
CONF_CANDLE_LIGHT_MINUTES,
CONF_DIASPORA,
@@ -32,93 +24,11 @@ from .const import (
DEFAULT_DIASPORA,
DEFAULT_HAVDALAH_OFFSET_MINUTES,
DEFAULT_LANGUAGE,
- DEFAULT_NAME,
- DOMAIN,
)
from .entity import JewishCalendarConfigEntry, JewishCalendarData
-from .sensor import INFO_SENSORS, TIME_SENSORS
PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR]
-CONFIG_SCHEMA = vol.Schema(
- {
- DOMAIN: vol.All(
- cv.deprecated(DOMAIN),
- {
- vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
- vol.Optional(CONF_DIASPORA, default=DEFAULT_DIASPORA): cv.boolean,
- vol.Inclusive(CONF_LATITUDE, "coordinates"): cv.latitude,
- vol.Inclusive(CONF_LONGITUDE, "coordinates"): cv.longitude,
- vol.Optional(CONF_LANGUAGE, default=DEFAULT_LANGUAGE): vol.In(
- ["hebrew", "english"]
- ),
- vol.Optional(
- CONF_CANDLE_LIGHT_MINUTES, default=DEFAULT_CANDLE_LIGHT
- ): int,
- # Default of 0 means use 8.5 degrees / 'three_stars' time.
- vol.Optional(
- CONF_HAVDALAH_OFFSET_MINUTES,
- default=DEFAULT_HAVDALAH_OFFSET_MINUTES,
- ): int,
- },
- )
- },
- extra=vol.ALLOW_EXTRA,
-)
-
-
-def get_unique_prefix(
- location: Location,
- language: str,
- candle_lighting_offset: int | None,
- havdalah_offset: int | None,
-) -> str:
- """Create a prefix for unique ids."""
- # location.altitude was unset before 2024.6 when this method
- # was used to create the unique id. As such it would always
- # use the default altitude of 754.
- config_properties = [
- location.latitude,
- location.longitude,
- location.timezone,
- 754,
- location.diaspora,
- language,
- candle_lighting_offset,
- havdalah_offset,
- ]
- prefix = "_".join(map(str, config_properties))
- return f"{prefix}"
-
-
-async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
- """Set up the Jewish Calendar component."""
- if DOMAIN not in config:
- return True
-
- async_create_issue(
- hass,
- HOMEASSISTANT_DOMAIN,
- f"deprecated_yaml_{DOMAIN}",
- is_fixable=False,
- issue_domain=DOMAIN,
- breaks_in_ha_version="2024.12.0",
- severity=IssueSeverity.WARNING,
- translation_key="deprecated_yaml",
- translation_placeholders={
- "domain": DOMAIN,
- "integration_title": DEFAULT_NAME,
- },
- )
-
- hass.async_create_task(
- hass.config_entries.flow.async_init(
- DOMAIN, context={"source": SOURCE_IMPORT}, data=config[DOMAIN]
- )
- )
-
- return True
-
async def async_setup_entry(
hass: HomeAssistant, config_entry: JewishCalendarConfigEntry
@@ -153,16 +63,6 @@ async def async_setup_entry(
havdalah_offset,
)
- # Update unique ID to be unrelated to user defined options
- old_prefix = get_unique_prefix(
- location, language, candle_lighting_offset, havdalah_offset
- )
-
- ent_reg = er.async_get(hass)
- entries = er.async_entries_for_config_entry(ent_reg, config_entry.entry_id)
- if not entries or any(entry.unique_id.startswith(old_prefix) for entry in entries):
- async_update_unique_ids(ent_reg, config_entry.entry_id, old_prefix)
-
await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
async def update_listener(
@@ -180,25 +80,3 @@ async def async_unload_entry(
) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS)
-
-
-@callback
-def async_update_unique_ids(
- ent_reg: er.EntityRegistry, new_prefix: str, old_prefix: str
-) -> None:
- """Update unique ID to be unrelated to user defined options.
-
- Introduced with release 2024.6
- """
- platform_descriptions = {
- Platform.BINARY_SENSOR: BINARY_SENSORS,
- Platform.SENSOR: (*INFO_SENSORS, *TIME_SENSORS),
- }
- for platform, descriptions in platform_descriptions.items():
- for description in descriptions:
- new_unique_id = f"{new_prefix}-{description.key}"
- old_unique_id = f"{old_prefix}_{description.key}"
- if entity_id := ent_reg.async_get_entity_id(
- platform, DOMAIN, old_unique_id
- ):
- ent_reg.async_update_entity(entity_id, new_unique_id=new_unique_id)
diff --git a/homeassistant/components/jewish_calendar/config_flow.py b/homeassistant/components/jewish_calendar/config_flow.py
index 9673fc6cf22..a2eadbf57bd 100644
--- a/homeassistant/components/jewish_calendar/config_flow.py
+++ b/homeassistant/components/jewish_calendar/config_flow.py
@@ -101,23 +101,10 @@ class JewishCalendarConfigFlow(ConfigFlow, domain=DOMAIN):
) -> ConfigFlowResult:
"""Handle the initial step."""
if user_input is not None:
- _options = {}
- if CONF_CANDLE_LIGHT_MINUTES in user_input:
- _options[CONF_CANDLE_LIGHT_MINUTES] = user_input[
- CONF_CANDLE_LIGHT_MINUTES
- ]
- del user_input[CONF_CANDLE_LIGHT_MINUTES]
- if CONF_HAVDALAH_OFFSET_MINUTES in user_input:
- _options[CONF_HAVDALAH_OFFSET_MINUTES] = user_input[
- CONF_HAVDALAH_OFFSET_MINUTES
- ]
- del user_input[CONF_HAVDALAH_OFFSET_MINUTES]
if CONF_LOCATION in user_input:
user_input[CONF_LATITUDE] = user_input[CONF_LOCATION][CONF_LATITUDE]
user_input[CONF_LONGITUDE] = user_input[CONF_LOCATION][CONF_LONGITUDE]
- return self.async_create_entry(
- title=DEFAULT_NAME, data=user_input, options=_options
- )
+ return self.async_create_entry(title=DEFAULT_NAME, data=user_input)
return self.async_show_form(
step_id="user",
@@ -126,10 +113,6 @@ class JewishCalendarConfigFlow(ConfigFlow, domain=DOMAIN):
),
)
- 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_reconfigure(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
diff --git a/homeassistant/components/jewish_calendar/entity.py b/homeassistant/components/jewish_calendar/entity.py
index ad5ac8e2137..1d2a6e45c0a 100644
--- a/homeassistant/components/jewish_calendar/entity.py
+++ b/homeassistant/components/jewish_calendar/entity.py
@@ -44,6 +44,7 @@ class JewishCalendarEntity(Entity):
data = config_entry.runtime_data
self._location = data.location
self._hebrew = data.language == "hebrew"
+ self._language = data.language
self._candle_lighting_offset = data.candle_lighting_offset
self._havdalah_offset = data.havdalah_offset
self._diaspora = data.diaspora
diff --git a/homeassistant/components/jewish_calendar/manifest.json b/homeassistant/components/jewish_calendar/manifest.json
index 2642f6c81e9..aca45320002 100644
--- a/homeassistant/components/jewish_calendar/manifest.json
+++ b/homeassistant/components/jewish_calendar/manifest.json
@@ -6,7 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/jewish_calendar",
"iot_class": "calculated",
"loggers": ["hdate"],
- "quality_scale": "silver",
- "requirements": ["hdate==0.10.9"],
+ "requirements": ["hdate==0.11.1"],
"single_config_entry": true
}
diff --git a/homeassistant/components/jewish_calendar/sensor.py b/homeassistant/components/jewish_calendar/sensor.py
index c32647af07c..d3e70eb411c 100644
--- a/homeassistant/components/jewish_calendar/sensor.py
+++ b/homeassistant/components/jewish_calendar/sensor.py
@@ -275,15 +275,18 @@ class JewishCalendarSensor(JewishCalendarEntity, SensorEntity):
# Compute the weekly portion based on the upcoming shabbat.
return after_tzais_date.upcoming_shabbat.parasha
if self.entity_description.key == "holiday":
- self._attrs = {
- "id": after_shkia_date.holiday_name,
- "type": after_shkia_date.holiday_type.name,
- "type_id": after_shkia_date.holiday_type.value,
- }
- self._attr_options = [
- h.description.hebrew.long if self._hebrew else h.description.english
- for h in htables.HOLIDAYS
- ]
+ _id = _type = _type_id = ""
+ _holiday_type = after_shkia_date.holiday_type
+ if isinstance(_holiday_type, list):
+ _id = ", ".join(after_shkia_date.holiday_name)
+ _type = ", ".join([_htype.name for _htype in _holiday_type])
+ _type_id = ", ".join([str(_htype.value) for _htype in _holiday_type])
+ else:
+ _id = after_shkia_date.holiday_name
+ _type = _holiday_type.name
+ _type_id = _holiday_type.value
+ self._attrs = {"id": _id, "type": _type, "type_id": _type_id}
+ self._attr_options = htables.get_all_holidays(self._language)
return after_shkia_date.holiday_description
if self.entity_description.key == "omer_count":
diff --git a/homeassistant/components/joaoapps_join/manifest.json b/homeassistant/components/joaoapps_join/manifest.json
index 36d54ec6d55..55a908bf090 100644
--- a/homeassistant/components/joaoapps_join/manifest.json
+++ b/homeassistant/components/joaoapps_join/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/joaoapps_join",
"iot_class": "cloud_push",
"loggers": ["pyjoin"],
+ "quality_scale": "legacy",
"requirements": ["python-join-api==0.0.9"]
}
diff --git a/homeassistant/components/kaiterra/manifest.json b/homeassistant/components/kaiterra/manifest.json
index 12ac1559fd7..88651565cd0 100644
--- a/homeassistant/components/kaiterra/manifest.json
+++ b/homeassistant/components/kaiterra/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/kaiterra",
"iot_class": "cloud_polling",
"loggers": ["kaiterra_async_client"],
+ "quality_scale": "legacy",
"requirements": ["kaiterra-async-client==1.0.0"]
}
diff --git a/homeassistant/components/kankun/manifest.json b/homeassistant/components/kankun/manifest.json
index c15a87eacaa..473209508ac 100644
--- a/homeassistant/components/kankun/manifest.json
+++ b/homeassistant/components/kankun/manifest.json
@@ -3,5 +3,6 @@
"name": "Kankun",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/kankun",
- "iot_class": "local_polling"
+ "iot_class": "local_polling",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/keba/manifest.json b/homeassistant/components/keba/manifest.json
index 42f2762ef3d..6427a30f000 100644
--- a/homeassistant/components/keba/manifest.json
+++ b/homeassistant/components/keba/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/keba",
"iot_class": "local_polling",
"loggers": ["keba_kecontact"],
- "requirements": ["keba-kecontact==1.1.0"]
+ "quality_scale": "legacy",
+ "requirements": ["keba-kecontact==1.3.0"]
}
diff --git a/homeassistant/components/kef/manifest.json b/homeassistant/components/kef/manifest.json
index 29e398994f4..1bbce2ff35d 100644
--- a/homeassistant/components/kef/manifest.json
+++ b/homeassistant/components/kef/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/kef",
"iot_class": "local_polling",
"loggers": ["aiokef", "tenacity"],
+ "quality_scale": "legacy",
"requirements": ["aiokef==0.2.16", "getmac==0.9.4"]
}
diff --git a/homeassistant/components/kef/strings.json b/homeassistant/components/kef/strings.json
index e5ffff68162..c8aa644333a 100644
--- a/homeassistant/components/kef/strings.json
+++ b/homeassistant/components/kef/strings.json
@@ -22,14 +22,14 @@
},
"high_pass": {
"name": "High pass",
- "description": "High-pass mode\"."
+ "description": "High-pass mode."
},
"sub_polarity": {
"name": "Subwoofer polarity",
"description": "Sub polarity."
},
"bass_extension": {
- "name": "Base extension",
+ "name": "Bass extension",
"description": "Bass extension."
}
}
diff --git a/homeassistant/components/keyboard/manifest.json b/homeassistant/components/keyboard/manifest.json
index ea6d0aa20c2..e4a6606fb80 100644
--- a/homeassistant/components/keyboard/manifest.json
+++ b/homeassistant/components/keyboard/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/keyboard",
"iot_class": "local_push",
"loggers": ["pykeyboard"],
+ "quality_scale": "legacy",
"requirements": ["pyuserinput==0.1.11"]
}
diff --git a/homeassistant/components/keyboard_remote/manifest.json b/homeassistant/components/keyboard_remote/manifest.json
index bb84b32defc..b405f36bb23 100644
--- a/homeassistant/components/keyboard_remote/manifest.json
+++ b/homeassistant/components/keyboard_remote/manifest.json
@@ -6,5 +6,6 @@
"integration_type": "hub",
"iot_class": "local_push",
"loggers": ["aionotify", "evdev"],
+ "quality_scale": "legacy",
"requirements": ["evdev==1.6.1", "asyncinotify==4.0.2"]
}
diff --git a/homeassistant/components/kira/manifest.json b/homeassistant/components/kira/manifest.json
index c8a476b07c9..60901d13f4e 100644
--- a/homeassistant/components/kira/manifest.json
+++ b/homeassistant/components/kira/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/kira",
"iot_class": "local_push",
"loggers": ["pykira"],
+ "quality_scale": "legacy",
"requirements": ["pykira==0.1.1"]
}
diff --git a/homeassistant/components/kitchen_sink/__init__.py b/homeassistant/components/kitchen_sink/__init__.py
index 2c3887bb383..88d0c868636 100644
--- a/homeassistant/components/kitchen_sink/__init__.py
+++ b/homeassistant/components/kitchen_sink/__init__.py
@@ -26,8 +26,7 @@ from homeassistant.helpers.issue_registry import IssueSeverity, async_create_iss
from homeassistant.helpers.typing import ConfigType
import homeassistant.util.dt as dt_util
-DOMAIN = "kitchen_sink"
-
+from .const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN
COMPONENTS_WITH_DEMO_PLATFORM = [
Platform.BUTTON,
@@ -88,9 +87,27 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
# Start a reauth flow
config_entry.async_start_reauth(hass)
+ # Notify backup listeners
+ hass.async_create_task(_notify_backup_listeners(hass), eager_start=False)
+
return True
+async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+ """Unload config entry."""
+ # Notify backup listeners
+ hass.async_create_task(_notify_backup_listeners(hass), eager_start=False)
+
+ return await hass.config_entries.async_unload_platforms(
+ entry, COMPONENTS_WITH_DEMO_PLATFORM
+ )
+
+
+async def _notify_backup_listeners(hass: HomeAssistant) -> None:
+ for listener in hass.data.get(DATA_BACKUP_AGENT_LISTENERS, []):
+ listener()
+
+
def _create_issues(hass: HomeAssistant) -> None:
"""Create some issue registry issues."""
async_create_issue(
diff --git a/homeassistant/components/kitchen_sink/backup.py b/homeassistant/components/kitchen_sink/backup.py
new file mode 100644
index 00000000000..c4a045aeefc
--- /dev/null
+++ b/homeassistant/components/kitchen_sink/backup.py
@@ -0,0 +1,118 @@
+"""Backup platform for the kitchen_sink integration."""
+
+from __future__ import annotations
+
+import asyncio
+from collections.abc import AsyncIterator, Callable, Coroutine
+import logging
+from typing import Any
+
+from homeassistant.components.backup import AddonInfo, AgentBackup, BackupAgent, Folder
+from homeassistant.core import HomeAssistant, callback
+
+from . import DATA_BACKUP_AGENT_LISTENERS, DOMAIN
+
+LOGGER = logging.getLogger(__name__)
+
+
+async def async_get_backup_agents(
+ hass: HomeAssistant,
+) -> list[BackupAgent]:
+ """Register the backup agents."""
+ if not hass.config_entries.async_loaded_entries(DOMAIN):
+ LOGGER.info("No config entry found or entry is not loaded")
+ return []
+ return [KitchenSinkBackupAgent("syncer")]
+
+
+@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)
+
+ return remove_listener
+
+
+class KitchenSinkBackupAgent(BackupAgent):
+ """Kitchen sink backup agent."""
+
+ domain = DOMAIN
+
+ def __init__(self, name: str) -> None:
+ """Initialize the kitchen sink backup sync agent."""
+ super().__init__()
+ self.name = name
+ self._uploads = [
+ AgentBackup(
+ addons=[AddonInfo(name="Test", slug="test", version="1.0.0")],
+ backup_id="abc123",
+ database_included=False,
+ date="1970-01-01T00:00:00Z",
+ extra_metadata={},
+ folders=[Folder.MEDIA, Folder.SHARE],
+ homeassistant_included=True,
+ homeassistant_version="2024.12.0",
+ name="Kitchen sink syncer",
+ protected=False,
+ size=1234,
+ )
+ ]
+
+ async def async_download_backup(
+ self,
+ backup_id: str,
+ **kwargs: Any,
+ ) -> AsyncIterator[bytes]:
+ """Download a backup file."""
+ LOGGER.info("Downloading backup %s", backup_id)
+ reader = asyncio.StreamReader()
+ reader.feed_data(b"backup data")
+ reader.feed_eof()
+ return reader
+
+ async def async_upload_backup(
+ self,
+ *,
+ open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]],
+ backup: AgentBackup,
+ **kwargs: Any,
+ ) -> None:
+ """Upload a backup."""
+ LOGGER.info("Uploading backup %s %s", backup.backup_id, backup)
+ self._uploads.append(backup)
+
+ async def async_delete_backup(
+ self,
+ backup_id: str,
+ **kwargs: Any,
+ ) -> None:
+ """Delete a backup file."""
+ self._uploads = [
+ upload for upload in self._uploads if upload.backup_id != backup_id
+ ]
+ LOGGER.info("Deleted backup %s", backup_id)
+
+ async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]:
+ """List synced backups."""
+ return self._uploads
+
+ async def async_get_backup(
+ self,
+ backup_id: str,
+ **kwargs: Any,
+ ) -> AgentBackup | None:
+ """Return a backup."""
+ for backup in self._uploads:
+ if backup.backup_id == backup_id:
+ return backup
+ return None
diff --git a/homeassistant/components/kitchen_sink/const.py b/homeassistant/components/kitchen_sink/const.py
new file mode 100644
index 00000000000..e6edaca46ce
--- /dev/null
+++ b/homeassistant/components/kitchen_sink/const.py
@@ -0,0 +1,12 @@
+"""Constants for the Kitchen Sink integration."""
+
+from __future__ import annotations
+
+from collections.abc import Callable
+
+from homeassistant.util.hass_dict import HassKey
+
+DOMAIN = "kitchen_sink"
+DATA_BACKUP_AGENT_LISTENERS: HassKey[list[Callable[[], None]]] = HassKey(
+ f"{DOMAIN}.backup_agent_listeners"
+)
diff --git a/homeassistant/components/kitchen_sink/strings.json b/homeassistant/components/kitchen_sink/strings.json
index 63e27e04637..c03f909e617 100644
--- a/homeassistant/components/kitchen_sink/strings.json
+++ b/homeassistant/components/kitchen_sink/strings.json
@@ -21,6 +21,9 @@
"bool": "Optional boolean",
"int": "Numeric input"
},
+ "data_description": {
+ "int": "A longer description for the numeric input"
+ },
"description": "This section allows input of some extra data",
"name": "Collapsible section"
}
@@ -77,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/kiwi/manifest.json b/homeassistant/components/kiwi/manifest.json
index 60b0d1fd28b..74a27776128 100644
--- a/homeassistant/components/kiwi/manifest.json
+++ b/homeassistant/components/kiwi/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/kiwi",
"iot_class": "cloud_polling",
"loggers": ["kiwiki"],
+ "quality_scale": "legacy",
"requirements": ["kiwiki-client==0.1.1"]
}
diff --git a/homeassistant/components/knocki/__init__.py b/homeassistant/components/knocki/__init__.py
index 42c3956bd68..dfdf060e3b5 100644
--- a/homeassistant/components/knocki/__init__.py
+++ b/homeassistant/components/knocki/__init__.py
@@ -41,13 +41,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: KnockiConfigEntry) -> bo
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
- entry.async_create_background_task(
- hass, client.start_websocket(), "knocki-websocket"
- )
+ await client.start_websocket()
return True
async def async_unload_entry(hass: HomeAssistant, entry: KnockiConfigEntry) -> bool:
"""Unload a config entry."""
+ await entry.runtime_data.client.close()
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
diff --git a/homeassistant/components/knocki/manifest.json b/homeassistant/components/knocki/manifest.json
index d9a45b18f0e..a91119ca831 100644
--- a/homeassistant/components/knocki/manifest.json
+++ b/homeassistant/components/knocki/manifest.json
@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "cloud_push",
"loggers": ["knocki"],
- "requirements": ["knocki==0.3.5"]
+ "requirements": ["knocki==0.4.2"]
}
diff --git a/homeassistant/components/knocki/quality_scale.yaml b/homeassistant/components/knocki/quality_scale.yaml
new file mode 100644
index 00000000000..45b3764d786
--- /dev/null
+++ b/homeassistant/components/knocki/quality_scale.yaml
@@ -0,0 +1,92 @@
+rules:
+ # Bronze
+ action-setup:
+ status: exempt
+ comment: |
+ This integration does not provide additional actions.
+ appropriate-polling:
+ status: exempt
+ comment: |
+ This integration is push-based.
+ brands: done
+ common-modules: done
+ config-flow-test-coverage: done
+ config-flow:
+ status: todo
+ comment: data_descriptions are missing
+ 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: 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:
+ status: exempt
+ comment: |
+ This integration does not provide actions.
+ config-entry-unloading: done
+ docs-configuration-parameters:
+ status: exempt
+ comment: |
+ This integration does not have any configuration parameters.
+ docs-installation-parameters: todo
+ entity-unavailable: todo
+ integration-owner: done
+ log-when-unavailable: todo
+ parallel-updates: todo
+ reauthentication-flow: todo
+ test-coverage: done
+ # Gold
+ devices: done
+ diagnostics: todo
+ discovery-update-info:
+ status: exempt
+ comment: This is a cloud service and does not benefit from device updates.
+ 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: done
+ entity-category:
+ status: exempt
+ comment: |
+ The default ones are good.
+ entity-device-class:
+ status: exempt
+ comment: |
+ Knocki does not have a device class.
+ entity-disabled-by-default:
+ status: exempt
+ comment: |
+ This integration does not have any entities that are disabled by default.
+ entity-translations:
+ status: exempt
+ comment: |
+ This integration does not have any translatable entities.
+ exception-translations: todo
+ icon-translations: done
+ reconfiguration-flow: todo
+ repair-issues:
+ status: exempt
+ comment: |
+ This integration doesn't have any cases where raising an issue is needed.
+ stale-devices: todo
+ # Platinum
+ async-dependency: done
+ inject-websession: done
+ strict-typing: done
diff --git a/homeassistant/components/knx/__init__.py b/homeassistant/components/knx/__init__.py
index fe6f3ad8892..7925628c079 100644
--- a/homeassistant/components/knx/__init__.py
+++ b/homeassistant/components/knx/__init__.py
@@ -29,7 +29,6 @@ from homeassistant.const import (
)
from homeassistant.core import Event, HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
-import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.device_registry import DeviceEntry
from homeassistant.helpers.reload import async_integration_yaml_config
from homeassistant.helpers.storage import STORAGE_DIR
@@ -55,6 +54,7 @@ from .const import (
CONF_KNX_SECURE_USER_PASSWORD,
CONF_KNX_STATE_UPDATER,
CONF_KNX_TELEGRAM_LOG_SIZE,
+ CONF_KNX_TUNNEL_ENDPOINT_IA,
CONF_KNX_TUNNELING,
CONF_KNX_TUNNELING_TCP,
CONF_KNX_TUNNELING_TCP_SECURE,
@@ -91,7 +91,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
@@ -102,20 +102,6 @@ _KNX_YAML_CONFIG: Final = "knx_yaml_config"
CONFIG_SCHEMA = vol.Schema(
{
DOMAIN: vol.All(
- # deprecated since 2021.12
- cv.deprecated(CONF_KNX_STATE_UPDATER),
- cv.deprecated(CONF_KNX_RATE_LIMIT),
- cv.deprecated(CONF_KNX_ROUTING),
- cv.deprecated(CONF_KNX_TUNNELING),
- cv.deprecated(CONF_KNX_INDIVIDUAL_ADDRESS),
- cv.deprecated(CONF_KNX_MCAST_GRP),
- cv.deprecated(CONF_KNX_MCAST_PORT),
- cv.deprecated("event_filter"),
- # deprecated since 2021.4
- cv.deprecated("config_file"),
- # deprecated since 2021.2
- cv.deprecated("fire_event"),
- cv.deprecated("fire_event_filter"),
vol.Schema(
{
**EventSchema.SCHEMA,
@@ -240,6 +226,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):
@@ -367,6 +355,7 @@ class KNXModule:
if _conn_type == CONF_KNX_TUNNELING_TCP:
return ConnectionConfig(
connection_type=ConnectionType.TUNNELING_TCP,
+ individual_address=self.entry.data.get(CONF_KNX_TUNNEL_ENDPOINT_IA),
gateway_ip=self.entry.data[CONF_HOST],
gateway_port=self.entry.data[CONF_PORT],
auto_reconnect=True,
@@ -379,6 +368,7 @@ class KNXModule:
if _conn_type == CONF_KNX_TUNNELING_TCP_SECURE:
return ConnectionConfig(
connection_type=ConnectionType.TUNNELING_TCP_SECURE,
+ individual_address=self.entry.data.get(CONF_KNX_TUNNEL_ENDPOINT_IA),
gateway_ip=self.entry.data[CONF_HOST],
gateway_port=self.entry.data[CONF_PORT],
secure_config=SecureConfig(
@@ -413,6 +403,9 @@ class KNXModule:
)
return ConnectionConfig(
auto_reconnect=True,
+ individual_address=self.entry.data.get(
+ CONF_KNX_TUNNEL_ENDPOINT_IA, # may be configured at knxkey upload
+ ),
secure_config=SecureConfig(
knxkeys_password=self.entry.data.get(CONF_KNX_KNXKEY_PASSWORD),
knxkeys_file_path=_knxkeys_file,
diff --git a/homeassistant/components/knx/climate.py b/homeassistant/components/knx/climate.py
index 0e0da4d5c0c..2c0153c5d2b 100644
--- a/homeassistant/components/knx/climate.py
+++ b/homeassistant/components/knx/climate.py
@@ -148,7 +148,6 @@ class KNXClimate(KnxYamlEntity, ClimateEntity):
_device: XknxClimate
_attr_temperature_unit = UnitOfTemperature.CELSIUS
_attr_translation_key = "knx_climate"
- _enable_turn_on_off_backwards_compatibility = False
def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
"""Initialize of a KNX climate device."""
@@ -428,7 +427,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(
@@ -436,4 +435,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/config_flow.py b/homeassistant/components/knx/config_flow.py
index feeb7626577..eda160cd1a6 100644
--- a/homeassistant/components/knx/config_flow.py
+++ b/homeassistant/components/knx/config_flow.py
@@ -4,7 +4,7 @@ from __future__ import annotations
from abc import ABC, abstractmethod
from collections.abc import AsyncGenerator
-from typing import Any, Final
+from typing import Any, Final, Literal
import voluptuous as vol
from xknx import XKNX
@@ -121,6 +121,15 @@ class KNXCommonFlow(ABC, ConfigEntryBaseFlow):
self._gatewayscanner: GatewayScanner | None = None
self._async_scan_gen: AsyncGenerator[GatewayDescriptor] | None = None
+ @property
+ def _xknx(self) -> XKNX:
+ """Return XKNX instance."""
+ if isinstance(self, OptionsFlow) and (
+ knx_module := self.hass.data.get(KNX_MODULE_KEY)
+ ):
+ return knx_module.xknx
+ return XKNX()
+
@abstractmethod
def finish_flow(self) -> ConfigFlowResult:
"""Finish the flow."""
@@ -183,14 +192,8 @@ class KNXCommonFlow(ABC, ConfigEntryBaseFlow):
CONF_KNX_ROUTING: CONF_KNX_ROUTING.capitalize(),
}
- if isinstance(self, OptionsFlow) and (
- knx_module := self.hass.data.get(KNX_MODULE_KEY)
- ):
- xknx = knx_module.xknx
- else:
- xknx = XKNX()
self._gatewayscanner = GatewayScanner(
- xknx, stop_on_found=0, timeout_in_seconds=2
+ self._xknx, stop_on_found=0, timeout_in_seconds=2
)
# keep a reference to the generator to scan in background until user selects a connection type
self._async_scan_gen = self._gatewayscanner.async_scan()
@@ -204,8 +207,25 @@ class KNXCommonFlow(ABC, ConfigEntryBaseFlow):
CONF_KNX_AUTOMATIC: CONF_KNX_AUTOMATIC.capitalize()
} | supported_connection_types
+ default_connection_type: Literal["automatic", "tunneling", "routing"]
+ _current_conn = self.initial_data.get(CONF_KNX_CONNECTION_TYPE)
+ if _current_conn in (
+ CONF_KNX_TUNNELING,
+ CONF_KNX_TUNNELING_TCP,
+ CONF_KNX_TUNNELING_TCP_SECURE,
+ ):
+ default_connection_type = CONF_KNX_TUNNELING
+ elif _current_conn in (CONF_KNX_ROUTING, CONF_KNX_ROUTING_SECURE):
+ default_connection_type = CONF_KNX_ROUTING
+ elif CONF_KNX_AUTOMATIC in supported_connection_types:
+ default_connection_type = CONF_KNX_AUTOMATIC
+ else:
+ default_connection_type = CONF_KNX_TUNNELING
+
fields = {
- vol.Required(CONF_KNX_CONNECTION_TYPE): vol.In(supported_connection_types)
+ vol.Required(
+ CONF_KNX_CONNECTION_TYPE, default=default_connection_type
+ ): vol.In(supported_connection_types)
}
return self.async_show_form(
step_id="connection_type", data_schema=vol.Schema(fields)
@@ -216,8 +236,7 @@ class KNXCommonFlow(ABC, ConfigEntryBaseFlow):
) -> ConfigFlowResult:
"""Select a tunnel from a list.
- Will be skipped if the gateway scan was unsuccessful
- or if only one gateway was found.
+ Will be skipped if the gateway scan was unsuccessful.
"""
if user_input is not None:
if user_input[CONF_KNX_GATEWAY] == OPTION_MANUAL_TUNNEL:
@@ -247,6 +266,8 @@ class KNXCommonFlow(ABC, ConfigEntryBaseFlow):
user_password=None,
tunnel_endpoint_ia=None,
)
+ if connection_type == CONF_KNX_TUNNELING_TCP:
+ return await self.async_step_tcp_tunnel_endpoint()
if connection_type == CONF_KNX_TUNNELING_TCP_SECURE:
return await self.async_step_secure_key_source_menu_tunnel()
self.new_title = f"Tunneling @ {self._selected_tunnel}"
@@ -255,16 +276,99 @@ class KNXCommonFlow(ABC, ConfigEntryBaseFlow):
if not self._found_tunnels:
return await self.async_step_manual_tunnel()
- errors: dict = {}
- tunnel_options = {
- str(tunnel): f"{tunnel}{' 🔐' if tunnel.tunnelling_requires_secure else ''}"
+ tunnel_options = [
+ selector.SelectOptionDict(
+ value=str(tunnel),
+ label=(
+ f"{tunnel}"
+ f"{' TCP' if tunnel.supports_tunnelling_tcp else ' UDP'}"
+ f"{' 🔐 Secure tunneling' if tunnel.tunnelling_requires_secure else ''}"
+ ),
+ )
for tunnel in self._found_tunnels
+ ]
+ tunnel_options.append(
+ selector.SelectOptionDict(
+ value=OPTION_MANUAL_TUNNEL, label=OPTION_MANUAL_TUNNEL
+ )
+ )
+ default_tunnel = next(
+ (
+ str(tunnel)
+ for tunnel in self._found_tunnels
+ if tunnel.ip_addr == self.initial_data.get(CONF_HOST)
+ ),
+ vol.UNDEFINED,
+ )
+ fields = {
+ vol.Required(
+ CONF_KNX_GATEWAY, default=default_tunnel
+ ): selector.SelectSelector(
+ selector.SelectSelectorConfig(
+ options=tunnel_options,
+ mode=selector.SelectSelectorMode.LIST,
+ )
+ )
}
- tunnel_options |= {OPTION_MANUAL_TUNNEL: OPTION_MANUAL_TUNNEL}
- fields = {vol.Required(CONF_KNX_GATEWAY): vol.In(tunnel_options)}
+ return self.async_show_form(step_id="tunnel", data_schema=vol.Schema(fields))
+
+ async def async_step_tcp_tunnel_endpoint(
+ self, user_input: dict | None = None
+ ) -> ConfigFlowResult:
+ """Select specific tunnel endpoint for plain TCP connection."""
+ if user_input is not None:
+ selected_tunnel_ia: str | None = (
+ None
+ if user_input[CONF_KNX_TUNNEL_ENDPOINT_IA] == CONF_KNX_AUTOMATIC
+ else user_input[CONF_KNX_TUNNEL_ENDPOINT_IA]
+ )
+ self.new_entry_data |= KNXConfigEntryData(
+ tunnel_endpoint_ia=selected_tunnel_ia,
+ )
+ self.new_title = (
+ f"{selected_tunnel_ia or 'Tunneling'} @ {self._selected_tunnel}"
+ )
+ return self.finish_flow()
+
+ # this step is only called from async_step_tunnel so self._selected_tunnel is always set
+ assert self._selected_tunnel
+ # skip if only one tunnel endpoint or no tunnelling slot infos
+ if len(self._selected_tunnel.tunnelling_slots) <= 1:
+ return self.finish_flow()
+
+ tunnel_endpoint_options = [
+ selector.SelectOptionDict(
+ value=CONF_KNX_AUTOMATIC, label=CONF_KNX_AUTOMATIC.capitalize()
+ )
+ ]
+ _current_ia = self._xknx.current_address
+ tunnel_endpoint_options.extend(
+ selector.SelectOptionDict(
+ value=str(slot),
+ label=(
+ f"{slot} - {'current connection' if slot == _current_ia else 'occupied' if not slot_status.free else 'free'}"
+ ),
+ )
+ for slot, slot_status in self._selected_tunnel.tunnelling_slots.items()
+ )
+ default_endpoint = (
+ self.initial_data.get(CONF_KNX_TUNNEL_ENDPOINT_IA) or CONF_KNX_AUTOMATIC
+ )
return self.async_show_form(
- step_id="tunnel", data_schema=vol.Schema(fields), errors=errors
+ step_id="tcp_tunnel_endpoint",
+ data_schema=vol.Schema(
+ {
+ vol.Required(
+ CONF_KNX_TUNNEL_ENDPOINT_IA, default=default_endpoint
+ ): selector.SelectSelector(
+ selector.SelectSelectorConfig(
+ options=tunnel_endpoint_options,
+ mode=selector.SelectSelectorMode.LIST,
+ )
+ ),
+ }
+ ),
)
async def async_step_manual_tunnel(
@@ -612,12 +716,15 @@ class KNXCommonFlow(ABC, ConfigEntryBaseFlow):
)
for endpoint in self._tunnel_endpoints
)
+ default_endpoint = (
+ self.initial_data.get(CONF_KNX_TUNNEL_ENDPOINT_IA) or CONF_KNX_AUTOMATIC
+ )
return self.async_show_form(
step_id="knxkeys_tunnel_select",
data_schema=vol.Schema(
{
vol.Required(
- CONF_KNX_TUNNEL_ENDPOINT_IA, default=CONF_KNX_AUTOMATIC
+ CONF_KNX_TUNNEL_ENDPOINT_IA, default=default_endpoint
): selector.SelectSelector(
selector.SelectSelectorConfig(
options=tunnel_endpoint_options,
diff --git a/homeassistant/components/knx/const.py b/homeassistant/components/knx/const.py
index e22546d3806..a946ded0359 100644
--- a/homeassistant/components/knx/const.py
+++ b/homeassistant/components/knx/const.py
@@ -52,8 +52,8 @@ CONF_KNX_DEFAULT_RATE_LIMIT: Final = 0
DEFAULT_ROUTING_IA: Final = "0.0.240"
CONF_KNX_TELEGRAM_LOG_SIZE: Final = "telegram_log_size"
-TELEGRAM_LOG_DEFAULT: Final = 200
-TELEGRAM_LOG_MAX: Final = 5000 # ~2 MB or ~5 hours of reasonable bus load
+TELEGRAM_LOG_DEFAULT: Final = 1000
+TELEGRAM_LOG_MAX: Final = 25000 # ~10 MB or ~25 hours of reasonable bus load
##
# Secure constants
@@ -104,7 +104,7 @@ class KNXConfigEntryData(TypedDict, total=False):
route_back: bool # not required
host: str # only required for tunnelling
port: int # only required for tunnelling
- tunnel_endpoint_ia: str | None
+ tunnel_endpoint_ia: str | None # tunnelling only - not required (use get())
# KNX secure
user_id: int | None # not required
user_password: str | None # not required
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/fan.py b/homeassistant/components/knx/fan.py
index ce17517b970..75d91e48048 100644
--- a/homeassistant/components/knx/fan.py
+++ b/homeassistant/components/knx/fan.py
@@ -43,7 +43,6 @@ class KNXFan(KnxYamlEntity, FanEntity):
"""Representation of a KNX fan."""
_device: XknxFan
- _enable_turn_on_off_backwards_compatibility = False
def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
"""Initialize of KNX fan."""
diff --git a/homeassistant/components/knx/light.py b/homeassistant/components/knx/light.py
index ba1194220c2..8e64b46c890 100644
--- a/homeassistant/components/knx/light.py
+++ b/homeassistant/components/knx/light.py
@@ -4,6 +4,7 @@ from __future__ import annotations
from typing import Any, cast
+from propcache import cached_property
from xknx import XKNX
from xknx.devices.light import ColorTemperatureType, Light as XknxLight, XYYColor
@@ -389,39 +390,47 @@ class _KnxLight(LightEntity):
)
return None
- @property
- def color_mode(self) -> ColorMode:
- """Return the color mode of the light."""
- if self._device.supports_xyy_color:
- return ColorMode.XY
- if self._device.supports_hs_color:
- return ColorMode.HS
- if self._device.supports_rgbw:
- return ColorMode.RGBW
- if self._device.supports_color:
- return ColorMode.RGB
+ @cached_property
+ def supported_color_modes(self) -> set[ColorMode]:
+ """Get supported color modes."""
+ color_mode = set()
if (
self._device.supports_color_temperature
or self._device.supports_tunable_white
):
- return ColorMode.COLOR_TEMP
- if self._device.supports_brightness:
- return ColorMode.BRIGHTNESS
- return ColorMode.ONOFF
-
- @property
- def supported_color_modes(self) -> set[ColorMode]:
- """Flag supported color modes."""
- return {self.color_mode}
+ color_mode.add(ColorMode.COLOR_TEMP)
+ if self._device.supports_xyy_color:
+ color_mode.add(ColorMode.XY)
+ if self._device.supports_rgbw:
+ color_mode.add(ColorMode.RGBW)
+ elif self._device.supports_color:
+ # one of RGB or RGBW so individual color configurations work properly
+ color_mode.add(ColorMode.RGB)
+ if self._device.supports_hs_color:
+ color_mode.add(ColorMode.HS)
+ if not color_mode:
+ # brightness or on/off must be the only supported mode
+ if self._device.supports_brightness:
+ color_mode.add(ColorMode.BRIGHTNESS)
+ else:
+ color_mode.add(ColorMode.ONOFF)
+ return color_mode
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the light on."""
brightness = kwargs.get(ATTR_BRIGHTNESS)
- color_temp = kwargs.get(ATTR_COLOR_TEMP_KELVIN)
- rgb = kwargs.get(ATTR_RGB_COLOR)
- rgbw = kwargs.get(ATTR_RGBW_COLOR)
- hs_color = kwargs.get(ATTR_HS_COLOR)
- xy_color = kwargs.get(ATTR_XY_COLOR)
+ # LightEntity color translation will ensure that only attributes of supported
+ # color modes are passed to this method - so we can't set unsupported mode here
+ if color_temp := kwargs.get(ATTR_COLOR_TEMP_KELVIN):
+ self._attr_color_mode = ColorMode.COLOR_TEMP
+ if rgb := kwargs.get(ATTR_RGB_COLOR):
+ self._attr_color_mode = ColorMode.RGB
+ if rgbw := kwargs.get(ATTR_RGBW_COLOR):
+ self._attr_color_mode = ColorMode.RGBW
+ if hs_color := kwargs.get(ATTR_HS_COLOR):
+ self._attr_color_mode = ColorMode.HS
+ if xy_color := kwargs.get(ATTR_XY_COLOR):
+ self._attr_color_mode = ColorMode.XY
if (
not self.is_on
@@ -500,17 +509,17 @@ class _KnxLight(LightEntity):
await self._device.set_brightness(brightness)
return
# brightness without color in kwargs; set via color
- if self.color_mode == ColorMode.XY:
+ if self._attr_color_mode == ColorMode.XY:
await self._device.set_xyy_color(XYYColor(brightness=brightness))
return
# default to white if color not known for RGB(W)
- if self.color_mode == ColorMode.RGBW:
+ if self._attr_color_mode == ColorMode.RGBW:
_rgbw = self.rgbw_color
if not _rgbw or not any(_rgbw):
_rgbw = (0, 0, 0, 255)
await set_color(_rgbw[:3], _rgbw[3], brightness)
return
- if self.color_mode == ColorMode.RGB:
+ if self._attr_color_mode == ColorMode.RGB:
_rgb = self.rgb_color
if not _rgb or not any(_rgb):
_rgb = (255, 255, 255)
@@ -533,6 +542,7 @@ class KnxYamlLight(_KnxLight, KnxYamlEntity):
knx_module=knx_module,
device=_create_yaml_light(knx_module.xknx, config),
)
+ self._attr_color_mode = next(iter(self.supported_color_modes))
self._attr_max_color_temp_kelvin: int = config[LightSchema.CONF_MAX_KELVIN]
self._attr_min_color_temp_kelvin: int = config[LightSchema.CONF_MIN_KELVIN]
self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY)
@@ -566,5 +576,6 @@ class KnxUiLight(_KnxLight, KnxUiEntity):
self._device = _create_ui_light(
knx_module.xknx, config[DOMAIN], config[CONF_ENTITY][CONF_NAME]
)
+ self._attr_color_mode = next(iter(self.supported_color_modes))
self._attr_max_color_temp_kelvin: int = config[DOMAIN][CONF_COLOR_TEMP_MAX]
self._attr_min_color_temp_kelvin: int = config[DOMAIN][CONF_COLOR_TEMP_MIN]
diff --git a/homeassistant/components/knx/manifest.json b/homeassistant/components/knx/manifest.json
index df895282a2b..8d18f11c798 100644
--- a/homeassistant/components/knx/manifest.json
+++ b/homeassistant/components/knx/manifest.json
@@ -9,11 +9,10 @@
"integration_type": "hub",
"iot_class": "local_push",
"loggers": ["xknx", "xknxproject"],
- "quality_scale": "platinum",
"requirements": [
- "xknx==3.3.0",
+ "xknx==3.4.0",
"xknxproject==3.8.1",
- "knx-frontend==2024.9.10.221729"
+ "knx-frontend==2024.12.26.233449"
],
"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 bf2fc55e5c9..9311046e410 100644
--- a/homeassistant/components/knx/schema.py
+++ b/homeassistant/components/knx/schema.py
@@ -222,9 +222,6 @@ class BinarySensorSchema(KNXPlatformSchema):
DEFAULT_NAME = "KNX Binary Sensor"
ENTITY_SCHEMA = vol.All(
- # deprecated since September 2020
- cv.deprecated("significant_bit"),
- cv.deprecated("automation"),
vol.Schema(
{
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
@@ -358,10 +355,6 @@ class ClimateSchema(KNXPlatformSchema):
DEFAULT_FAN_SPEED_MODE = "percent"
ENTITY_SCHEMA = vol.All(
- # deprecated since September 2020
- cv.deprecated("setpoint_shift_step", replacement_key=CONF_TEMPERATURE_STEP),
- # deprecated since 2021.6
- cv.deprecated("create_temperature_sensors"),
vol.Schema(
{
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
@@ -969,8 +962,6 @@ class WeatherSchema(KNXPlatformSchema):
DEFAULT_NAME = "KNX Weather Station"
ENTITY_SCHEMA = vol.All(
- # deprecated since 2021.6
- cv.deprecated("create_sensors"),
vol.Schema(
{
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
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 113be9709ee..6c392902737 100644
--- a/homeassistant/components/knx/services.py
+++ b/homeassistant/components/knx/services.py
@@ -2,7 +2,6 @@
from __future__ import annotations
-from functools import partial
import logging
from typing import TYPE_CHECKING
@@ -47,14 +46,14 @@ def register_knx_services(hass: HomeAssistant) -> None:
hass.services.async_register(
DOMAIN,
SERVICE_KNX_SEND,
- partial(service_send_to_knx_bus, hass),
+ service_send_to_knx_bus,
schema=SERVICE_KNX_SEND_SCHEMA,
)
hass.services.async_register(
DOMAIN,
SERVICE_KNX_READ,
- partial(service_read_to_knx_bus, hass),
+ service_read_to_knx_bus,
schema=SERVICE_KNX_READ_SCHEMA,
)
@@ -62,7 +61,7 @@ def register_knx_services(hass: HomeAssistant) -> None:
hass,
DOMAIN,
SERVICE_KNX_EVENT_REGISTER,
- partial(service_event_register_modify, hass),
+ service_event_register_modify,
schema=SERVICE_KNX_EVENT_REGISTER_SCHEMA,
)
@@ -70,7 +69,7 @@ def register_knx_services(hass: HomeAssistant) -> None:
hass,
DOMAIN,
SERVICE_KNX_EXPOSURE_REGISTER,
- partial(service_exposure_register_modify, hass),
+ service_exposure_register_modify,
schema=SERVICE_KNX_EXPOSURE_REGISTER_SCHEMA,
)
@@ -78,7 +77,7 @@ def register_knx_services(hass: HomeAssistant) -> None:
hass,
DOMAIN,
SERVICE_RELOAD,
- partial(service_reload_integration, hass),
+ service_reload_integration,
)
@@ -103,9 +102,9 @@ SERVICE_KNX_EVENT_REGISTER_SCHEMA = vol.Schema(
)
-async def service_event_register_modify(hass: HomeAssistant, call: ServiceCall) -> None:
+async def service_event_register_modify(call: ServiceCall) -> None:
"""Service for adding or removing a GroupAddress to the knx_event filter."""
- knx_module = get_knx_module(hass)
+ knx_module = get_knx_module(call.hass)
attr_address = call.data[KNX_ADDRESS]
group_addresses = list(map(parse_device_group_address, attr_address))
@@ -156,11 +155,9 @@ SERVICE_KNX_EXPOSURE_REGISTER_SCHEMA = vol.Any(
)
-async def service_exposure_register_modify(
- hass: HomeAssistant, call: ServiceCall
-) -> None:
+async def service_exposure_register_modify(call: ServiceCall) -> None:
"""Service for adding or removing an exposure to KNX bus."""
- knx_module = get_knx_module(hass)
+ knx_module = get_knx_module(call.hass)
group_address = call.data[KNX_ADDRESS]
@@ -223,9 +220,9 @@ SERVICE_KNX_SEND_SCHEMA = vol.Any(
)
-async def service_send_to_knx_bus(hass: HomeAssistant, call: ServiceCall) -> None:
+async def service_send_to_knx_bus(call: ServiceCall) -> None:
"""Service for sending an arbitrary KNX message to the KNX bus."""
- knx_module = get_knx_module(hass)
+ knx_module = get_knx_module(call.hass)
attr_address = call.data[KNX_ADDRESS]
attr_payload = call.data[SERVICE_KNX_ATTR_PAYLOAD]
@@ -271,9 +268,9 @@ SERVICE_KNX_READ_SCHEMA = vol.Schema(
)
-async def service_read_to_knx_bus(hass: HomeAssistant, call: ServiceCall) -> None:
+async def service_read_to_knx_bus(call: ServiceCall) -> None:
"""Service for sending a GroupValueRead telegram to the KNX bus."""
- knx_module = get_knx_module(hass)
+ knx_module = get_knx_module(call.hass)
for address in call.data[KNX_ADDRESS]:
telegram = Telegram(
@@ -284,8 +281,8 @@ async def service_read_to_knx_bus(hass: HomeAssistant, call: ServiceCall) -> Non
await knx_module.xknx.telegrams.put(telegram)
-async def service_reload_integration(hass: HomeAssistant, call: ServiceCall) -> None:
+async def service_reload_integration(call: ServiceCall) -> None:
"""Reload the integration."""
- knx_module = get_knx_module(hass)
- await hass.config_entries.async_reload(knx_module.entry.entry_id)
- hass.bus.async_fire(f"event_{DOMAIN}_reloaded", context=call.context)
+ knx_module = get_knx_module(call.hass)
+ await call.hass.config_entries.async_reload(knx_module.entry.entry_id)
+ call.hass.bus.async_fire(f"event_{DOMAIN}_reloaded", context=call.context)
diff --git a/homeassistant/components/knx/strings.json b/homeassistant/components/knx/strings.json
index 8d8692f6b7a..e7fbfcf5f2f 100644
--- a/homeassistant/components/knx/strings.json
+++ b/homeassistant/components/knx/strings.json
@@ -3,41 +3,56 @@
"step": {
"connection_type": {
"title": "KNX connection",
- "description": "Please enter the connection type we should use for your KNX connection. \n AUTOMATIC - The integration takes care of the connectivity to your KNX Bus by performing a gateway scan. \n TUNNELING - The integration will connect to your KNX bus via tunneling. \n ROUTING - The integration will connect to your KNX bus via routing.",
+ "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."
}
},
"tunnel": {
"title": "Tunnel",
- "description": "Please select a gateway from the list.",
"data": {
- "gateway": "KNX Tunnel Connection"
+ "gateway": "Please select a gateway from the list."
+ },
+ "data_description": {
+ "gateway": "Select a KNX tunneling interface you want use for the connection."
+ }
+ },
+ "tcp_tunnel_endpoint": {
+ "title": "Tunnel endpoint",
+ "data": {
+ "tunnel_endpoint_ia": "Select the tunnel endpoint used for the connection."
+ },
+ "data_description": {
+ "tunnel_endpoint_ia": "'Automatic' selects a free tunnel endpoint for you when connecting. If you're unsure, this is the best option."
}
},
"manual_tunnel": {
"title": "Tunnel settings",
"description": "Please enter the connection information of your tunneling device.",
"data": {
- "tunneling_type": "KNX Tunneling Type",
- "port": "[%key:common::config_flow::data::port%]",
+ "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",
"local_ip": "Local IP interface"
},
"data_description": {
- "port": "Port of the KNX/IP tunneling device.",
+ "tunneling_type": "Select the tunneling type of your KNX/IP tunneling device. Older interfaces may only support `UDP`.",
"host": "IP address or hostname of the KNX/IP tunneling device.",
+ "port": "Port used by the KNX/IP tunneling device.",
"route_back": "Enable if your KNXnet/IP tunneling server is behind NAT. Only applies for UDP connections.",
"local_ip": "Local IP or interface name used for the connection from Home Assistant. Leave blank to use auto-discovery."
}
},
"secure_key_source_menu_tunnel": {
- "title": "KNX IP-Secure",
- "description": "Select how you want to configure 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 containing 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": {
@@ -45,37 +60,40 @@
"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": {
"title": "Import KNX Keyring",
- "description": "Please select a `.knxkeys` file to import.",
+ "description": "The Keyring is used to encrypt and decrypt KNX IP Secure communication.",
"data": {
"knxkeys_file": "Keyring file",
- "knxkeys_password": "The password to decrypt the `.knxkeys` file"
+ "knxkeys_password": "Keyring password"
},
"data_description": {
- "knxkeys_password": "This was set when exporting the file from ETS."
+ "knxkeys_file": "Select a `.knxkeys` file. This can be exported from ETS.",
+ "knxkeys_password": "The password to open the `.knxkeys` file was set when exporting."
}
},
"knxkeys_tunnel_select": {
- "title": "Tunnel endpoint",
- "description": "Select the tunnel used for connection.",
+ "title": "[%key:component::knx::config::step::tcp_tunnel_endpoint::title%]",
"data": {
- "user_id": "`Automatic` will use the first free tunnel endpoint."
+ "tunnel_endpoint_ia": "[%key:component::knx::config::step::tcp_tunnel_endpoint::data::tunnel_endpoint_ia%]"
+ },
+ "data_description": {
+ "tunnel_endpoint_ia": "[%key:component::knx::config::step::tcp_tunnel_endpoint::data_description::tunnel_endpoint_ia%]"
}
},
"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",
"device_authentication": "Device authentication password"
},
"data_description": {
- "user_id": "This is often tunnel number +1. So 'Tunnel 2' would have User-ID '3'.",
+ "user_id": "This usually is tunnel number +1. So first tunnel in the list presented in ETS would have User-ID `2`.",
"user_password": "Password for the specific tunnel connection set in the 'Properties' panel of the tunnel in ETS.",
"device_authentication": "This is set in the 'IP' panel of the interface in ETS."
}
@@ -88,8 +106,8 @@
"sync_latency_tolerance": "Network latency tolerance"
},
"data_description": {
- "backbone_key": "Can be seen in the 'Security' report of an ETS project. Eg. '00112233445566778899AABBCCDDEEFF'",
- "sync_latency_tolerance": "Default is 1000."
+ "backbone_key": "Can be seen in the 'Security' report of your ETS project. Eg. `00112233445566778899AABBCCDDEEFF`",
+ "sync_latency_tolerance": "Should be equal to the backbone configuration of your ETS project. Default is `1000`"
}
},
"routing": {
@@ -97,13 +115,16 @@
"description": "Please configure the routing options.",
"data": {
"individual_address": "Individual address",
- "routing_secure": "Use KNX IP Secure",
+ "routing_secure": "KNX IP Secure Routing",
"multicast_group": "Multicast group",
"multicast_port": "Multicast port",
"local_ip": "[%key:component::knx::config::step::manual_tunnel::data::local_ip%]"
},
"data_description": {
"individual_address": "KNX address to be used by Home Assistant, e.g. `0.0.4`",
+ "routing_secure": "Select if your installation uses encrypted communication according to the KNX IP Secure standard. This setting requires compatible devices and configuration. You'll be prompted for credentials in the next step.",
+ "multicast_group": "Multicast group used by your installation. Default is `224.0.23.12`",
+ "multicast_port": "Multicast port used by your installation. Default is `3671`",
"local_ip": "[%key:component::knx::config::step::manual_tunnel::data_description::local_ip%]"
}
}
@@ -141,7 +162,7 @@
},
"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.",
- "rate_limit": "Maximum outgoing telegrams per second.\n`0` to disable limit. Recommended: 0 or 20 to 40",
+ "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}"
}
},
@@ -150,13 +171,27 @@
"description": "[%key:component::knx::config::step::connection_type::description%]",
"data": {
"connection_type": "[%key:component::knx::config::step::connection_type::data::connection_type%]"
+ },
+ "data_description": {
+ "connection_type": "[%key:component::knx::config::step::connection_type::data_description::connection_type%]"
}
},
"tunnel": {
"title": "[%key:component::knx::config::step::tunnel::title%]",
- "description": "[%key:component::knx::config::step::tunnel::description%]",
"data": {
"gateway": "[%key:component::knx::config::step::tunnel::data::gateway%]"
+ },
+ "data_description": {
+ "gateway": "[%key:component::knx::config::step::tunnel::data_description::gateway%]"
+ }
+ },
+ "tcp_tunnel_endpoint": {
+ "title": "[%key:component::knx::config::step::tcp_tunnel_endpoint::title%]",
+ "data": {
+ "tunnel_endpoint_ia": "[%key:component::knx::config::step::tcp_tunnel_endpoint::data::tunnel_endpoint_ia%]"
+ },
+ "data_description": {
+ "tunnel_endpoint_ia": "[%key:component::knx::config::step::tcp_tunnel_endpoint::data_description::tunnel_endpoint_ia%]"
}
},
"manual_tunnel": {
@@ -170,6 +205,7 @@
"local_ip": "[%key:component::knx::config::step::manual_tunnel::data::local_ip%]"
},
"data_description": {
+ "tunneling_type": "[%key:component::knx::config::step::manual_tunnel::data_description::tunneling_type%]",
"port": "[%key:component::knx::config::step::manual_tunnel::data_description::port%]",
"host": "[%key:component::knx::config::step::manual_tunnel::data_description::host%]",
"route_back": "[%key:component::knx::config::step::manual_tunnel::data_description::route_back%]",
@@ -200,14 +236,17 @@
"knxkeys_password": "[%key:component::knx::config::step::secure_knxkeys::data::knxkeys_password%]"
},
"data_description": {
+ "knxkeys_file": "[%key:component::knx::config::step::secure_knxkeys::data_description::knxkeys_file%]",
"knxkeys_password": "[%key:component::knx::config::step::secure_knxkeys::data_description::knxkeys_password%]"
}
},
"knxkeys_tunnel_select": {
- "title": "[%key:component::knx::config::step::knxkeys_tunnel_select::title%]",
- "description": "[%key:component::knx::config::step::knxkeys_tunnel_select::description%]",
+ "title": "[%key:component::knx::config::step::tcp_tunnel_endpoint::title%]",
"data": {
- "user_id": "[%key:component::knx::config::step::knxkeys_tunnel_select::data::user_id%]"
+ "tunnel_endpoint_ia": "[%key:component::knx::config::step::tcp_tunnel_endpoint::data::tunnel_endpoint_ia%]"
+ },
+ "data_description": {
+ "tunnel_endpoint_ia": "[%key:component::knx::config::step::tcp_tunnel_endpoint::data_description::tunnel_endpoint_ia%]"
}
},
"secure_tunnel_manual": {
@@ -248,6 +287,9 @@
},
"data_description": {
"individual_address": "[%key:component::knx::config::step::routing::data_description::individual_address%]",
+ "routing_secure": "[%key:component::knx::config::step::routing::data_description::routing_secure%]",
+ "multicast_group": "[%key:component::knx::config::step::routing::data_description::multicast_group%]",
+ "multicast_port": "[%key:component::knx::config::step::routing::data_description::multicast_port%]",
"local_ip": "[%key:component::knx::config::step::manual_tunnel::data_description::local_ip%]"
}
}
@@ -294,19 +336,24 @@
"name": "Connection type"
},
"telegrams_incoming": {
- "name": "Incoming telegrams"
+ "name": "Incoming telegrams",
+ "unit_of_measurement": "[%key:component::knx::entity::sensor::telegram_count::unit_of_measurement%]"
},
"telegrams_incoming_error": {
- "name": "Incoming telegram errors"
+ "name": "Incoming telegram errors",
+ "unit_of_measurement": "errors"
},
"telegrams_outgoing": {
- "name": "Outgoing telegrams"
+ "name": "Outgoing telegrams",
+ "unit_of_measurement": "[%key:component::knx::entity::sensor::telegram_count::unit_of_measurement%]"
},
"telegrams_outgoing_error": {
- "name": "Outgoing telegram errors"
+ "name": "Outgoing telegram errors",
+ "unit_of_measurement": "[%key:component::knx::entity::sensor::telegrams_incoming_error::unit_of_measurement%]"
},
"telegram_count": {
- "name": "Telegrams"
+ "name": "Telegrams",
+ "unit_of_measurement": "telegrams"
}
}
},
@@ -355,8 +402,8 @@
}
},
"read": {
- "name": "Reads from KNX bus",
- "description": "Send GroupValueRead requests to the KNX bus. Response can be used from `knx_event` and will be processed in KNX entities.",
+ "name": "Read from KNX bus",
+ "description": "Sends GroupValueRead requests to the KNX bus. Response can be used from `knx_event` and will be processed in KNX entities.",
"fields": {
"address": {
"name": "[%key:component::knx::services::send::fields::address::name%]",
@@ -365,8 +412,8 @@
}
},
"event_register": {
- "name": "Registers knx_event",
- "description": "Add or remove group addresses to knx_event filter for triggering `knx_event`s. Only addresses added with this service can be removed.",
+ "name": "Register knx_event",
+ "description": "Adds or removes group addresses to knx_event filter for triggering `knx_event`s. Only addresses added with this action can be removed.",
"fields": {
"address": {
"name": "[%key:component::knx::services::send::fields::address::name%]",
@@ -384,7 +431,7 @@
},
"exposure_register": {
"name": "Expose to KNX bus",
- "description": "Adds or remove exposures to KNX bus. Only exposures added with this service can be removed.",
+ "description": "Adds or removes exposures to KNX bus. Only exposures added with this action can be removed.",
"fields": {
"address": {
"name": "[%key:component::knx::services::send::fields::address::name%]",
@@ -396,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 f4b31fd11f9..dcd5f477679 100644
--- a/homeassistant/components/knx/telegrams.py
+++ b/homeassistant/components/knx/telegrams.py
@@ -75,6 +75,7 @@ class Telegrams:
)
)
self.recent_telegrams: deque[TelegramDict] = deque(maxlen=log_size)
+ self.last_ga_telegrams: dict[str, TelegramDict] = {}
async def load_history(self) -> None:
"""Load history from store."""
@@ -88,6 +89,9 @@ class Telegrams:
if isinstance(telegram["payload"], list):
telegram["payload"] = tuple(telegram["payload"]) # type: ignore[unreachable]
self.recent_telegrams.extend(telegrams)
+ self.last_ga_telegrams = {
+ t["destination"]: t for t in telegrams if t["payload"] is not None
+ }
async def save_history(self) -> None:
"""Save history to store."""
@@ -98,6 +102,9 @@ class Telegrams:
"""Handle incoming and outgoing telegrams from xknx."""
telegram_dict = self.telegram_to_dict(telegram)
self.recent_telegrams.append(telegram_dict)
+ if telegram_dict["payload"] is not None:
+ # exclude GroupValueRead telegrams
+ self.last_ga_telegrams[telegram_dict["destination"]] = telegram_dict
async_dispatcher_send(self.hass, SIGNAL_KNX_TELEGRAM, telegram, telegram_dict)
def telegram_to_dict(self, telegram: Telegram) -> TelegramDict:
diff --git a/homeassistant/components/knx/websocket.py b/homeassistant/components/knx/websocket.py
index 6cb2218b221..9ba3e0ccff6 100644
--- a/homeassistant/components/knx/websocket.py
+++ b/homeassistant/components/knx/websocket.py
@@ -47,6 +47,7 @@ async def register_panel(hass: HomeAssistant) -> None:
websocket_api.async_register_command(hass, ws_project_file_process)
websocket_api.async_register_command(hass, ws_project_file_remove)
websocket_api.async_register_command(hass, ws_group_monitor_info)
+ websocket_api.async_register_command(hass, ws_group_telegrams)
websocket_api.async_register_command(hass, ws_subscribe_telegram)
websocket_api.async_register_command(hass, ws_get_knx_project)
websocket_api.async_register_command(hass, ws_validate_entity)
@@ -287,6 +288,27 @@ def ws_group_monitor_info(
)
+@websocket_api.require_admin
+@websocket_api.websocket_command(
+ {
+ vol.Required("type"): "knx/group_telegrams",
+ }
+)
+@provide_knx
+@callback
+def ws_group_telegrams(
+ hass: HomeAssistant,
+ knx: KNXModule,
+ connection: websocket_api.ActiveConnection,
+ msg: dict,
+) -> None:
+ """Handle get group telegrams command."""
+ connection.send_result(
+ msg["id"],
+ knx.telegrams.last_ga_telegrams,
+ )
+
+
@websocket_api.require_admin
@websocket_api.websocket_command(
{
diff --git a/homeassistant/components/kodi/config_flow.py b/homeassistant/components/kodi/config_flow.py
index ef0798220dd..f87b94b23fd 100644
--- a/homeassistant/components/kodi/config_flow.py
+++ b/homeassistant/components/kodi/config_flow.py
@@ -145,6 +145,7 @@ class KodiConfigFlow(ConfigFlow, domain=DOMAIN):
) -> ConfigFlowResult:
"""Handle user-confirmation of discovered node."""
if user_input is None:
+ assert self._name is not None
return self.async_show_form(
step_id="discovery_confirm",
description_placeholders={"name": self._name},
diff --git a/homeassistant/components/kostal_plenticore/manifest.json b/homeassistant/components/kostal_plenticore/manifest.json
index d65368e7ee4..09352fa7a80 100644
--- a/homeassistant/components/kostal_plenticore/manifest.json
+++ b/homeassistant/components/kostal_plenticore/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/kostal_plenticore",
"iot_class": "local_polling",
"loggers": ["kostal"],
- "requirements": ["pykoplenti==1.2.2"]
+ "requirements": ["pykoplenti==1.3.0"]
}
diff --git a/homeassistant/components/kostal_plenticore/sensor.py b/homeassistant/components/kostal_plenticore/sensor.py
index fbbfb03fb3e..67de34f2fce 100644
--- a/homeassistant/components/kostal_plenticore/sensor.py
+++ b/homeassistant/components/kostal_plenticore/sensor.py
@@ -17,6 +17,7 @@ from homeassistant.components.sensor import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
PERCENTAGE,
+ EntityCategory,
UnitOfElectricCurrent,
UnitOfElectricPotential,
UnitOfEnergy,
@@ -747,6 +748,15 @@ SENSOR_PROCESS_DATA = [
state_class=SensorStateClass.TOTAL_INCREASING,
formatter="format_energy",
),
+ PlenticoreSensorEntityDescription(
+ module_id="scb:event",
+ key="Event:ActiveErrorCnt",
+ name="Active Alarms",
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ icon="mdi:alert",
+ formatter="format_round",
+ ),
PlenticoreSensorEntityDescription(
module_id="_virt_",
key="pv_P",
diff --git a/homeassistant/components/kwb/manifest.json b/homeassistant/components/kwb/manifest.json
index 36d3a0af2d7..6a11e08555f 100644
--- a/homeassistant/components/kwb/manifest.json
+++ b/homeassistant/components/kwb/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/kwb",
"iot_class": "local_polling",
"loggers": ["pykwb"],
+ "quality_scale": "legacy",
"requirements": ["pykwb==0.0.8"]
}
diff --git a/homeassistant/components/lacrosse/manifest.json b/homeassistant/components/lacrosse/manifest.json
index 0c7cf8b6dc6..b4023b533ca 100644
--- a/homeassistant/components/lacrosse/manifest.json
+++ b/homeassistant/components/lacrosse/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/lacrosse",
"iot_class": "local_polling",
"loggers": ["pylacrosse"],
+ "quality_scale": "legacy",
"requirements": ["pylacrosse==0.4"]
}
diff --git a/homeassistant/components/lamarzocco/__init__.py b/homeassistant/components/lamarzocco/__init__.py
index 82a91c0003f..d20616e1940 100644
--- a/homeassistant/components/lamarzocco/__init__.py
+++ b/homeassistant/components/lamarzocco/__init__.py
@@ -2,15 +2,15 @@
import logging
-from lmcloud.client_bluetooth import LaMarzoccoBluetoothClient
-from lmcloud.client_cloud import LaMarzoccoCloudClient
-from lmcloud.client_local import LaMarzoccoLocalClient
-from lmcloud.const import BT_MODEL_PREFIXES, FirmwareType
-from lmcloud.exceptions import AuthFail, RequestNotSuccessful
from packaging import version
+from pylamarzocco.clients.bluetooth import LaMarzoccoBluetoothClient
+from pylamarzocco.clients.cloud import LaMarzoccoCloudClient
+from pylamarzocco.clients.local import LaMarzoccoLocalClient
+from pylamarzocco.const import BT_MODEL_PREFIXES, FirmwareType
+from pylamarzocco.devices.machine import LaMarzoccoMachine
+from pylamarzocco.exceptions import AuthFail, RequestNotSuccessful
from homeassistant.components.bluetooth import async_discovered_service_info
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONF_HOST,
CONF_MAC,
@@ -23,10 +23,16 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import issue_registry as ir
-from homeassistant.helpers.httpx_client import get_async_client
+from homeassistant.helpers.aiohttp_client import async_create_clientsession
from .const import CONF_USE_BLUETOOTH, DOMAIN
-from .coordinator import LaMarzoccoConfigEntry, LaMarzoccoUpdateCoordinator
+from .coordinator import (
+ LaMarzoccoConfigEntry,
+ LaMarzoccoConfigUpdateCoordinator,
+ LaMarzoccoFirmwareUpdateCoordinator,
+ LaMarzoccoRuntimeData,
+ LaMarzoccoStatisticsUpdateCoordinator,
+)
PLATFORMS = [
Platform.BINARY_SENSOR,
@@ -48,10 +54,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: LaMarzoccoConfigEntry) -
assert entry.unique_id
serial = entry.unique_id
+ client = async_create_clientsession(hass)
cloud_client = LaMarzoccoCloudClient(
username=entry.data[CONF_USERNAME],
password=entry.data[CONF_PASSWORD],
- client=get_async_client(hass),
+ client=client,
)
# initialize local API
@@ -61,7 +68,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: LaMarzoccoConfigEntry) -
local_client = LaMarzoccoLocalClient(
host=host,
local_bearer=entry.data[CONF_TOKEN],
- client=get_async_client(hass),
+ client=client,
)
# initialize Bluetooth
@@ -99,18 +106,29 @@ async def async_setup_entry(hass: HomeAssistant, entry: LaMarzoccoConfigEntry) -
address_or_ble_device=entry.data[CONF_MAC],
)
- coordinator = LaMarzoccoUpdateCoordinator(
- hass=hass,
- entry=entry,
- local_client=local_client,
+ device = LaMarzoccoMachine(
+ model=entry.data[CONF_MODEL],
+ serial_number=entry.unique_id,
+ name=entry.data[CONF_NAME],
cloud_client=cloud_client,
+ local_client=local_client,
bluetooth_client=bluetooth_client,
)
- await coordinator.async_config_entry_first_refresh()
- entry.runtime_data = coordinator
+ coordinators = LaMarzoccoRuntimeData(
+ LaMarzoccoConfigUpdateCoordinator(hass, entry, device, local_client),
+ LaMarzoccoFirmwareUpdateCoordinator(hass, entry, device),
+ LaMarzoccoStatisticsUpdateCoordinator(hass, entry, device),
+ )
- gateway_version = coordinator.device.firmware[FirmwareType.GATEWAY].current_version
+ # API does not like concurrent requests, so no asyncio.gather here
+ await coordinators.config_coordinator.async_config_entry_first_refresh()
+ await coordinators.firmware_coordinator.async_config_entry_first_refresh()
+ await coordinators.statistics_coordinator.async_config_entry_first_refresh()
+
+ entry.runtime_data = coordinators
+
+ gateway_version = device.firmware[FirmwareType.GATEWAY].current_version
if version.parse(gateway_version) < version.parse("v3.4-rc5"):
# incompatible gateway firmware, create an issue
ir.async_create_issue(
@@ -125,7 +143,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: LaMarzoccoConfigEntry) -
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
- async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
+ async def update_listener(
+ hass: HomeAssistant, entry: LaMarzoccoConfigEntry
+ ) -> None:
await hass.config_entries.async_reload(entry.entry_id)
entry.async_on_unload(entry.add_update_listener(update_listener))
@@ -133,12 +153,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: LaMarzoccoConfigEntry) -
return True
-async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_unload_entry(hass: HomeAssistant, entry: LaMarzoccoConfigEntry) -> bool:
"""Unload a 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: LaMarzoccoConfigEntry
+) -> bool:
"""Migrate config entry."""
if entry.version > 2:
# guard against downgrade from a future version
diff --git a/homeassistant/components/lamarzocco/binary_sensor.py b/homeassistant/components/lamarzocco/binary_sensor.py
index c48453214bd..e36b53bc993 100644
--- a/homeassistant/components/lamarzocco/binary_sensor.py
+++ b/homeassistant/components/lamarzocco/binary_sensor.py
@@ -3,7 +3,8 @@
from collections.abc import Callable
from dataclasses import dataclass
-from lmcloud.models import LaMarzoccoMachineConfig
+from pylamarzocco.const import MachineModel
+from pylamarzocco.models import LaMarzoccoMachineConfig
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
@@ -15,7 +16,10 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .coordinator import LaMarzoccoConfigEntry
-from .entity import LaMarzoccoEntity, LaMarzoccoEntityDescription
+from .entity import LaMarzoccoEntity, LaMarzoccoEntityDescription, LaMarzoccScaleEntity
+
+# Coordinator is used to centralize the data updates
+PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
@@ -25,7 +29,7 @@ class LaMarzoccoBinarySensorEntityDescription(
):
"""Description of a La Marzocco binary sensor."""
- is_on_fn: Callable[[LaMarzoccoMachineConfig], bool]
+ is_on_fn: Callable[[LaMarzoccoMachineConfig], bool | None]
ENTITIES: tuple[LaMarzoccoBinarySensorEntityDescription, ...] = (
@@ -54,6 +58,15 @@ ENTITIES: tuple[LaMarzoccoBinarySensorEntityDescription, ...] = (
),
)
+SCALE_ENTITIES: tuple[LaMarzoccoBinarySensorEntityDescription, ...] = (
+ LaMarzoccoBinarySensorEntityDescription(
+ key="connected",
+ device_class=BinarySensorDeviceClass.CONNECTIVITY,
+ is_on_fn=lambda config: config.scale.connected if config.scale else None,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
+)
+
async def async_setup_entry(
hass: HomeAssistant,
@@ -61,13 +74,32 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up binary sensor entities."""
- coordinator = entry.runtime_data
+ coordinator = entry.runtime_data.config_coordinator
- async_add_entities(
+ entities = [
LaMarzoccoBinarySensorEntity(coordinator, description)
for description in ENTITIES
if description.supported_fn(coordinator)
- )
+ ]
+
+ if (
+ coordinator.device.model == MachineModel.LINEA_MINI
+ and coordinator.device.config.scale
+ ):
+ entities.extend(
+ LaMarzoccoScaleBinarySensorEntity(coordinator, description)
+ for description in SCALE_ENTITIES
+ )
+
+ def _async_add_new_scale() -> None:
+ async_add_entities(
+ LaMarzoccoScaleBinarySensorEntity(coordinator, description)
+ for description in SCALE_ENTITIES
+ )
+
+ coordinator.new_device_callback.append(_async_add_new_scale)
+
+ async_add_entities(entities)
class LaMarzoccoBinarySensorEntity(LaMarzoccoEntity, BinarySensorEntity):
@@ -76,6 +108,14 @@ class LaMarzoccoBinarySensorEntity(LaMarzoccoEntity, BinarySensorEntity):
entity_description: LaMarzoccoBinarySensorEntityDescription
@property
- def is_on(self) -> bool:
+ def is_on(self) -> bool | None:
"""Return true if the binary sensor is on."""
return self.entity_description.is_on_fn(self.coordinator.device.config)
+
+
+class LaMarzoccoScaleBinarySensorEntity(
+ LaMarzoccoBinarySensorEntity, LaMarzoccScaleEntity
+):
+ """Binary sensor for La Marzocco scales."""
+
+ entity_description: LaMarzoccoBinarySensorEntityDescription
diff --git a/homeassistant/components/lamarzocco/button.py b/homeassistant/components/lamarzocco/button.py
index 60374a85e1e..22e92f656ff 100644
--- a/homeassistant/components/lamarzocco/button.py
+++ b/homeassistant/components/lamarzocco/button.py
@@ -1,11 +1,11 @@
"""Button platform for La Marzocco espresso machines."""
+import asyncio
from collections.abc import Callable, Coroutine
from dataclasses import dataclass
from typing import Any
-from lmcloud.exceptions import RequestNotSuccessful
-from lmcloud.lm_machine import LaMarzoccoMachine
+from pylamarzocco.exceptions import RequestNotSuccessful
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
from homeassistant.core import HomeAssistant
@@ -13,9 +13,12 @@ from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN
-from .coordinator import LaMarzoccoConfigEntry
+from .coordinator import LaMarzoccoConfigEntry, LaMarzoccoUpdateCoordinator
from .entity import LaMarzoccoEntity, LaMarzoccoEntityDescription
+PARALLEL_UPDATES = 1
+BACKFLUSH_ENABLED_DURATION = 15
+
@dataclass(frozen=True, kw_only=True)
class LaMarzoccoButtonEntityDescription(
@@ -24,14 +27,25 @@ class LaMarzoccoButtonEntityDescription(
):
"""Description of a La Marzocco button."""
- press_fn: Callable[[LaMarzoccoMachine], Coroutine[Any, Any, None]]
+ press_fn: Callable[[LaMarzoccoUpdateCoordinator], Coroutine[Any, Any, None]]
+
+
+async def async_backflush_and_update(coordinator: LaMarzoccoUpdateCoordinator) -> None:
+ """Press backflush button."""
+ await coordinator.device.start_backflush()
+ # lib will set state optimistically
+ coordinator.async_set_updated_data(None)
+ # backflush is enabled for 15 seconds
+ # then turns off automatically
+ await asyncio.sleep(BACKFLUSH_ENABLED_DURATION + 1)
+ await coordinator.async_request_refresh()
ENTITIES: tuple[LaMarzoccoButtonEntityDescription, ...] = (
LaMarzoccoButtonEntityDescription(
key="start_backflush",
translation_key="start_backflush",
- press_fn=lambda machine: machine.start_backflush(),
+ press_fn=async_backflush_and_update,
),
)
@@ -43,7 +57,7 @@ async def async_setup_entry(
) -> None:
"""Set up button entities."""
- coordinator = entry.runtime_data
+ coordinator = entry.runtime_data.config_coordinator
async_add_entities(
LaMarzoccoButtonEntity(coordinator, description)
for description in ENTITIES
@@ -59,7 +73,7 @@ class LaMarzoccoButtonEntity(LaMarzoccoEntity, ButtonEntity):
async def async_press(self) -> None:
"""Press button."""
try:
- await self.entity_description.press_fn(self.coordinator.device)
+ await self.entity_description.press_fn(self.coordinator)
except RequestNotSuccessful as exc:
raise HomeAssistantError(
translation_domain=DOMAIN,
@@ -68,4 +82,3 @@ class LaMarzoccoButtonEntity(LaMarzoccoEntity, ButtonEntity):
"key": self.entity_description.key,
},
) from exc
- await self.coordinator.async_request_refresh()
diff --git a/homeassistant/components/lamarzocco/calendar.py b/homeassistant/components/lamarzocco/calendar.py
index 3d8b2474c94..1dcc7c324ac 100644
--- a/homeassistant/components/lamarzocco/calendar.py
+++ b/homeassistant/components/lamarzocco/calendar.py
@@ -3,7 +3,7 @@
from collections.abc import Iterator
from datetime import datetime, timedelta
-from lmcloud.models import LaMarzoccoWakeUpSleepEntry
+from pylamarzocco.models import LaMarzoccoWakeUpSleepEntry
from homeassistant.components.calendar import CalendarEntity, CalendarEvent
from homeassistant.core import HomeAssistant
@@ -13,6 +13,9 @@ from homeassistant.util import dt as dt_util
from .coordinator import LaMarzoccoConfigEntry, LaMarzoccoUpdateCoordinator
from .entity import LaMarzoccoBaseEntity
+# Coordinator is used to centralize the data updates
+PARALLEL_UPDATES = 0
+
CALENDAR_KEY = "auto_on_off_schedule"
DAY_OF_WEEK = [
@@ -33,7 +36,7 @@ async def async_setup_entry(
) -> None:
"""Set up switch entities and services."""
- coordinator = entry.runtime_data
+ coordinator = entry.runtime_data.config_coordinator
async_add_entities(
LaMarzoccoCalendarEntity(coordinator, CALENDAR_KEY, wake_up_sleep_entry)
for wake_up_sleep_entry in coordinator.device.config.wake_up_sleep_entries.values()
diff --git a/homeassistant/components/lamarzocco/config_flow.py b/homeassistant/components/lamarzocco/config_flow.py
index 4fadd3a9a32..70fb2c08b34 100644
--- a/homeassistant/components/lamarzocco/config_flow.py
+++ b/homeassistant/components/lamarzocco/config_flow.py
@@ -6,10 +6,11 @@ from collections.abc import Mapping
import logging
from typing import Any
-from lmcloud.client_cloud import LaMarzoccoCloudClient
-from lmcloud.client_local import LaMarzoccoLocalClient
-from lmcloud.exceptions import AuthFail, RequestNotSuccessful
-from lmcloud.models import LaMarzoccoDeviceInfo
+from aiohttp import ClientSession
+from pylamarzocco.clients.cloud import LaMarzoccoCloudClient
+from pylamarzocco.clients.local import LaMarzoccoLocalClient
+from pylamarzocco.exceptions import AuthFail, RequestNotSuccessful
+from pylamarzocco.models import LaMarzoccoDeviceInfo
import voluptuous as vol
from homeassistant.components.bluetooth import (
@@ -20,12 +21,12 @@ from homeassistant.components.dhcp import DhcpServiceInfo
from homeassistant.config_entries import (
SOURCE_REAUTH,
SOURCE_RECONFIGURE,
- ConfigEntry,
ConfigFlow,
ConfigFlowResult,
OptionsFlow,
)
from homeassistant.const import (
+ CONF_ADDRESS,
CONF_HOST,
CONF_MAC,
CONF_MODEL,
@@ -36,15 +37,19 @@ from homeassistant.const import (
)
from homeassistant.core import callback
from homeassistant.helpers import config_validation as cv
-from homeassistant.helpers.httpx_client import get_async_client
+from homeassistant.helpers.aiohttp_client import async_create_clientsession
from homeassistant.helpers.selector import (
SelectOptionDict,
SelectSelector,
SelectSelectorConfig,
SelectSelectorMode,
+ TextSelector,
+ TextSelectorConfig,
+ TextSelectorType,
)
from .const import CONF_USE_BLUETOOTH, DOMAIN
+from .coordinator import LaMarzoccoConfigEntry
CONF_MACHINE = "machine"
@@ -56,6 +61,8 @@ class LmConfigFlow(ConfigFlow, domain=DOMAIN):
VERSION = 2
+ _client: ClientSession
+
def __init__(self) -> None:
"""Initialize the config flow."""
self._config: dict[str, Any] = {}
@@ -79,9 +86,11 @@ class LmConfigFlow(ConfigFlow, domain=DOMAIN):
**self._discovered,
}
+ self._client = async_create_clientsession(self.hass)
cloud_client = LaMarzoccoCloudClient(
username=data[CONF_USERNAME],
password=data[CONF_PASSWORD],
+ client=self._client,
)
try:
self._fleet = await cloud_client.get_customer_fleet()
@@ -125,15 +134,31 @@ class LmConfigFlow(ConfigFlow, domain=DOMAIN):
self._config = data
return await self.async_step_machine_selection()
+ placeholders: dict[str, str] | None = None
+ if self._discovered:
+ self.context["title_placeholders"] = placeholders = {
+ CONF_NAME: self._discovered[CONF_MACHINE]
+ }
+
return self.async_show_form(
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,
+ description_placeholders=placeholders,
)
async def async_step_machine_selection(
@@ -155,7 +180,7 @@ class LmConfigFlow(ConfigFlow, domain=DOMAIN):
# validate local connection if host is provided
if user_input.get(CONF_HOST):
if not await LaMarzoccoLocalClient.validate_connection(
- client=get_async_client(self.hass),
+ client=self._client,
host=user_input[CONF_HOST],
token=selected_device.communication_key,
):
@@ -277,7 +302,13 @@ class LmConfigFlow(ConfigFlow, domain=DOMAIN):
serial = discovery_info.hostname.upper()
await self.async_set_unique_id(serial)
- self._abort_if_unique_id_configured()
+ self._abort_if_unique_id_configured(
+ updates={
+ CONF_HOST: discovery_info.ip,
+ CONF_ADDRESS: discovery_info.macaddress,
+ }
+ )
+ self._async_abort_entries_match({CONF_ADDRESS: discovery_info.macaddress})
_LOGGER.debug(
"Discovered La Marzocco machine %s through DHCP at address %s",
@@ -287,6 +318,7 @@ class LmConfigFlow(ConfigFlow, domain=DOMAIN):
self._discovered[CONF_MACHINE] = serial
self._discovered[CONF_HOST] = discovery_info.ip
+ self._discovered[CONF_ADDRESS] = discovery_info.macaddress
return await self.async_step_user()
@@ -323,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",
+ ),
+ ),
}
),
)
@@ -339,7 +378,7 @@ class LmConfigFlow(ConfigFlow, domain=DOMAIN):
@staticmethod
@callback
def async_get_options_flow(
- config_entry: ConfigEntry,
+ config_entry: LaMarzoccoConfigEntry,
) -> LmOptionsFlowHandler:
"""Create the options flow."""
return LmOptionsFlowHandler()
diff --git a/homeassistant/components/lamarzocco/coordinator.py b/homeassistant/components/lamarzocco/coordinator.py
index e2ff8791a05..2385039f53d 100644
--- a/homeassistant/components/lamarzocco/coordinator.py
+++ b/homeassistant/components/lamarzocco/coordinator.py
@@ -2,47 +2,56 @@
from __future__ import annotations
-from collections.abc import Callable, Coroutine
+from abc import abstractmethod
+from collections.abc import Callable
+from dataclasses import dataclass
from datetime import timedelta
import logging
-from time import time
from typing import Any
-from lmcloud.client_bluetooth import LaMarzoccoBluetoothClient
-from lmcloud.client_cloud import LaMarzoccoCloudClient
-from lmcloud.client_local import LaMarzoccoLocalClient
-from lmcloud.exceptions import AuthFail, RequestNotSuccessful
-from lmcloud.lm_machine import LaMarzoccoMachine
+from pylamarzocco.clients.local import LaMarzoccoLocalClient
+from pylamarzocco.devices.machine import LaMarzoccoMachine
+from pylamarzocco.exceptions import AuthFail, RequestNotSuccessful
from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import CONF_MODEL, CONF_NAME, EVENT_HOMEASSISTANT_STOP
-from homeassistant.core import HomeAssistant
+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.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN
SCAN_INTERVAL = timedelta(seconds=30)
-FIRMWARE_UPDATE_INTERVAL = 3600
-STATISTICS_UPDATE_INTERVAL = 300
-
+FIRMWARE_UPDATE_INTERVAL = timedelta(hours=1)
+STATISTICS_UPDATE_INTERVAL = timedelta(minutes=5)
_LOGGER = logging.getLogger(__name__)
-type LaMarzoccoConfigEntry = ConfigEntry[LaMarzoccoUpdateCoordinator]
+
+@dataclass
+class LaMarzoccoRuntimeData:
+ """Runtime data for La Marzocco."""
+
+ config_coordinator: LaMarzoccoConfigUpdateCoordinator
+ firmware_coordinator: LaMarzoccoFirmwareUpdateCoordinator
+ statistics_coordinator: LaMarzoccoStatisticsUpdateCoordinator
+
+
+type LaMarzoccoConfigEntry = ConfigEntry[LaMarzoccoRuntimeData]
class LaMarzoccoUpdateCoordinator(DataUpdateCoordinator[None]):
- """Class to handle fetching data from the La Marzocco API centrally."""
+ """Base class for La Marzocco coordinators."""
+ _default_update_interval = SCAN_INTERVAL
config_entry: LaMarzoccoConfigEntry
def __init__(
self,
hass: HomeAssistant,
entry: LaMarzoccoConfigEntry,
- cloud_client: LaMarzoccoCloudClient,
- local_client: LaMarzoccoLocalClient | None,
- bluetooth_client: LaMarzoccoBluetoothClient | None,
+ device: LaMarzoccoMachine,
+ local_client: LaMarzoccoLocalClient | None = None,
) -> None:
"""Initialize coordinator."""
super().__init__(
@@ -50,27 +59,43 @@ class LaMarzoccoUpdateCoordinator(DataUpdateCoordinator[None]):
_LOGGER,
config_entry=entry,
name=DOMAIN,
- update_interval=SCAN_INTERVAL,
+ update_interval=self._default_update_interval,
)
+ self.device = device
self.local_connection_configured = local_client is not None
-
- assert self.config_entry.unique_id
- self.device = LaMarzoccoMachine(
- model=self.config_entry.data[CONF_MODEL],
- serial_number=self.config_entry.unique_id,
- name=self.config_entry.data[CONF_NAME],
- cloud_client=cloud_client,
- local_client=local_client,
- bluetooth_client=bluetooth_client,
- )
-
- self._last_firmware_data_update: float | None = None
- self._last_statistics_data_update: float | None = None
self._local_client = local_client
+ self.new_device_callback: list[Callable] = []
- async def _async_setup(self) -> None:
+ async def _async_update_data(self) -> None:
+ """Do the data update."""
+ try:
+ await self._internal_async_update_data()
+ except AuthFail as ex:
+ _LOGGER.debug("Authentication failed", exc_info=True)
+ raise ConfigEntryAuthFailed(
+ translation_domain=DOMAIN, translation_key="authentication_failed"
+ ) from ex
+ except RequestNotSuccessful as ex:
+ _LOGGER.debug(ex, exc_info=True)
+ raise UpdateFailed(
+ translation_domain=DOMAIN, translation_key="api_error"
+ ) from ex
+
+ @abstractmethod
+ async def _internal_async_update_data(self) -> None:
+ """Actual data update logic."""
+
+
+class LaMarzoccoConfigUpdateCoordinator(LaMarzoccoUpdateCoordinator):
+ """Class to handle fetching data from the La Marzocco API centrally."""
+
+ _scale_address: str | None = None
+
+ async def _async_connect_websocket(self) -> None:
"""Set up the coordinator."""
- if self._local_client is not None:
+ if self._local_client is not None and (
+ self._local_client.websocket is None or self._local_client.websocket.closed
+ ):
_LOGGER.debug("Init WebSocket in background task")
self.config_entry.async_create_background_task(
@@ -85,9 +110,8 @@ class LaMarzoccoUpdateCoordinator(DataUpdateCoordinator[None]):
if (
self._local_client is not None
and self._local_client.websocket is not None
- and self._local_client.websocket.open
+ and not self._local_client.websocket.closed
):
- self._local_client.terminating = True
await self._local_client.websocket.close()
self.config_entry.async_on_unload(
@@ -97,38 +121,49 @@ class LaMarzoccoUpdateCoordinator(DataUpdateCoordinator[None]):
)
self.config_entry.async_on_unload(websocket_close)
- async def _async_update_data(self) -> None:
+ async def _internal_async_update_data(self) -> None:
"""Fetch data from API endpoint."""
- await self._async_handle_request(self.device.get_config)
-
- if (
- self._last_firmware_data_update is None
- or (self._last_firmware_data_update + FIRMWARE_UPDATE_INTERVAL) < time()
- ):
- await self._async_handle_request(self.device.get_firmware)
- self._last_firmware_data_update = time()
-
- if (
- self._last_statistics_data_update is None
- or (self._last_statistics_data_update + STATISTICS_UPDATE_INTERVAL) < time()
- ):
- await self._async_handle_request(self.device.get_statistics)
- self._last_statistics_data_update = time()
-
+ await self.device.get_config()
_LOGGER.debug("Current status: %s", str(self.device.config))
+ await self._async_connect_websocket()
+ self._async_add_remove_scale()
- async def _async_handle_request[**_P](
- self,
- func: Callable[_P, Coroutine[None, None, None]],
- *args: _P.args,
- **kwargs: _P.kwargs,
- ) -> None:
- try:
- await func(*args, **kwargs)
- except AuthFail as ex:
- msg = "Authentication failed."
- _LOGGER.debug(msg, exc_info=True)
- raise ConfigEntryAuthFailed(msg) from ex
- except RequestNotSuccessful as ex:
- _LOGGER.debug(ex, exc_info=True)
- raise UpdateFailed(f"Querying API failed. Error: {ex}") from ex
+ @callback
+ def _async_add_remove_scale(self) -> None:
+ """Add or remove a scale when added or removed."""
+ if self.device.config.scale and not self._scale_address:
+ self._scale_address = self.device.config.scale.address
+ for scale_callback in self.new_device_callback:
+ scale_callback()
+ elif not self.device.config.scale and self._scale_address:
+ device_registry = dr.async_get(self.hass)
+ if device := device_registry.async_get_device(
+ identifiers={(DOMAIN, self._scale_address)}
+ ):
+ device_registry.async_update_device(
+ device_id=device.id,
+ remove_config_entry_id=self.config_entry.entry_id,
+ )
+ self._scale_address = None
+
+
+class LaMarzoccoFirmwareUpdateCoordinator(LaMarzoccoUpdateCoordinator):
+ """Coordinator for La Marzocco firmware."""
+
+ _default_update_interval = FIRMWARE_UPDATE_INTERVAL
+
+ async def _internal_async_update_data(self) -> None:
+ """Fetch data from API endpoint."""
+ await self.device.get_firmware()
+ _LOGGER.debug("Current firmware: %s", str(self.device.firmware))
+
+
+class LaMarzoccoStatisticsUpdateCoordinator(LaMarzoccoUpdateCoordinator):
+ """Coordinator for La Marzocco statistics."""
+
+ _default_update_interval = STATISTICS_UPDATE_INTERVAL
+
+ async def _internal_async_update_data(self) -> None:
+ """Fetch data from API endpoint."""
+ await self.device.get_statistics()
+ _LOGGER.debug("Current statistics: %s", str(self.device.statistics))
diff --git a/homeassistant/components/lamarzocco/diagnostics.py b/homeassistant/components/lamarzocco/diagnostics.py
index edce6a349aa..204a8b7142a 100644
--- a/homeassistant/components/lamarzocco/diagnostics.py
+++ b/homeassistant/components/lamarzocco/diagnostics.py
@@ -5,7 +5,7 @@ from __future__ import annotations
from dataclasses import asdict
from typing import Any, TypedDict
-from lmcloud.const import FirmwareType
+from pylamarzocco.const import FirmwareType
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.core import HomeAssistant
@@ -31,7 +31,7 @@ async def async_get_config_entry_diagnostics(
entry: LaMarzoccoConfigEntry,
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
- coordinator = entry.runtime_data
+ coordinator = entry.runtime_data.config_coordinator
device = coordinator.device
# collect all data sources
diagnostics_data = DiagnosticsData(
diff --git a/homeassistant/components/lamarzocco/entity.py b/homeassistant/components/lamarzocco/entity.py
index f7e6ff9e2b8..3e70ff1acdf 100644
--- a/homeassistant/components/lamarzocco/entity.py
+++ b/homeassistant/components/lamarzocco/entity.py
@@ -2,11 +2,17 @@
from collections.abc import Callable
from dataclasses import dataclass
+from typing import TYPE_CHECKING
-from lmcloud.const import FirmwareType
-from lmcloud.lm_machine import LaMarzoccoMachine
+from pylamarzocco.const import FirmwareType
+from pylamarzocco.devices.machine import LaMarzoccoMachine
-from homeassistant.helpers.device_registry import DeviceInfo
+from homeassistant.const import CONF_ADDRESS, CONF_MAC
+from homeassistant.helpers.device_registry import (
+ CONNECTION_BLUETOOTH,
+ CONNECTION_NETWORK_MAC,
+ DeviceInfo,
+)
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -47,6 +53,17 @@ class LaMarzoccoBaseEntity(
serial_number=device.serial_number,
sw_version=device.firmware[FirmwareType.MACHINE].current_version,
)
+ connections: set[tuple[str, str]] = set()
+ if coordinator.config_entry.data.get(CONF_ADDRESS):
+ connections.add(
+ (CONNECTION_NETWORK_MAC, coordinator.config_entry.data[CONF_ADDRESS])
+ )
+ if coordinator.config_entry.data.get(CONF_MAC):
+ connections.add(
+ (CONNECTION_BLUETOOTH, coordinator.config_entry.data[CONF_MAC])
+ )
+ if connections:
+ self._attr_device_info.update(DeviceInfo(connections=connections))
class LaMarzoccoEntity(LaMarzoccoBaseEntity):
@@ -69,3 +86,26 @@ class LaMarzoccoEntity(LaMarzoccoBaseEntity):
"""Initialize the entity."""
super().__init__(coordinator, entity_description.key)
self.entity_description = entity_description
+
+
+class LaMarzoccScaleEntity(LaMarzoccoEntity):
+ """Common class for scale."""
+
+ def __init__(
+ self,
+ coordinator: LaMarzoccoUpdateCoordinator,
+ entity_description: LaMarzoccoEntityDescription,
+ ) -> None:
+ """Initialize the entity."""
+ super().__init__(coordinator, entity_description)
+ scale = coordinator.device.config.scale
+ if TYPE_CHECKING:
+ assert scale
+ self._attr_device_info = DeviceInfo(
+ identifiers={(DOMAIN, scale.address)},
+ name=scale.name,
+ manufacturer="Acaia",
+ model="Lunar",
+ model_id="Y.301",
+ via_device=(DOMAIN, coordinator.device.serial_number),
+ )
diff --git a/homeassistant/components/lamarzocco/icons.json b/homeassistant/components/lamarzocco/icons.json
index 860da12ddd9..79267b4abd4 100644
--- a/homeassistant/components/lamarzocco/icons.json
+++ b/homeassistant/components/lamarzocco/icons.json
@@ -43,6 +43,9 @@
"preinfusion_off": {
"default": "mdi:water"
},
+ "scale_target": {
+ "default": "mdi:scale-balance"
+ },
"smart_standby_time": {
"default": "mdi:timer"
},
@@ -54,6 +57,13 @@
}
},
"select": {
+ "active_bbw": {
+ "default": "mdi:alpha-u",
+ "state": {
+ "a": "mdi:alpha-a",
+ "b": "mdi:alpha-b"
+ }
+ },
"smart_standby_mode": {
"default": "mdi:power",
"state": {
diff --git a/homeassistant/components/lamarzocco/manifest.json b/homeassistant/components/lamarzocco/manifest.json
index bfe0d34a9e4..afd367b0f6e 100644
--- a/homeassistant/components/lamarzocco/manifest.json
+++ b/homeassistant/components/lamarzocco/manifest.json
@@ -19,6 +19,9 @@
"config_flow": true,
"dependencies": ["bluetooth_adapters"],
"dhcp": [
+ {
+ "registered_devices": true
+ },
{
"hostname": "gs[0-9][0-9][0-9][0-9][0-9][0-9]"
},
@@ -32,6 +35,7 @@
"documentation": "https://www.home-assistant.io/integrations/lamarzocco",
"integration_type": "device",
"iot_class": "cloud_polling",
- "loggers": ["lmcloud"],
- "requirements": ["lmcloud==1.2.3"]
+ "loggers": ["pylamarzocco"],
+ "quality_scale": "platinum",
+ "requirements": ["pylamarzocco==1.4.6"]
}
diff --git a/homeassistant/components/lamarzocco/number.py b/homeassistant/components/lamarzocco/number.py
index df75147e7e1..44b582fbf1a 100644
--- a/homeassistant/components/lamarzocco/number.py
+++ b/homeassistant/components/lamarzocco/number.py
@@ -4,16 +4,16 @@ from collections.abc import Callable, Coroutine
from dataclasses import dataclass
from typing import Any
-from lmcloud.const import (
+from pylamarzocco.const import (
KEYS_PER_MODEL,
BoilerType,
MachineModel,
PhysicalKey,
PrebrewMode,
)
-from lmcloud.exceptions import RequestNotSuccessful
-from lmcloud.lm_machine import LaMarzoccoMachine
-from lmcloud.models import LaMarzoccoMachineConfig
+from pylamarzocco.devices.machine import LaMarzoccoMachine
+from pylamarzocco.exceptions import RequestNotSuccessful
+from pylamarzocco.models import LaMarzoccoMachineConfig
from homeassistant.components.number import (
NumberDeviceClass,
@@ -33,7 +33,9 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN
from .coordinator import LaMarzoccoConfigEntry, LaMarzoccoUpdateCoordinator
-from .entity import LaMarzoccoEntity, LaMarzoccoEntityDescription
+from .entity import LaMarzoccoEntity, LaMarzoccoEntityDescription, LaMarzoccScaleEntity
+
+PARALLEL_UPDATES = 1
@dataclass(frozen=True, kw_only=True)
@@ -54,7 +56,9 @@ class LaMarzoccoKeyNumberEntityDescription(
):
"""Description of an La Marzocco number entity with keys."""
- native_value_fn: Callable[[LaMarzoccoMachineConfig, PhysicalKey], float | int]
+ native_value_fn: Callable[
+ [LaMarzoccoMachineConfig, PhysicalKey], float | int | None
+ ]
set_value_fn: Callable[
[LaMarzoccoMachine, float | int, PhysicalKey], Coroutine[Any, Any, bool]
]
@@ -201,6 +205,27 @@ KEY_ENTITIES: tuple[LaMarzoccoKeyNumberEntityDescription, ...] = (
),
)
+SCALE_KEY_ENTITIES: tuple[LaMarzoccoKeyNumberEntityDescription, ...] = (
+ LaMarzoccoKeyNumberEntityDescription(
+ key="scale_target",
+ translation_key="scale_target",
+ native_step=PRECISION_WHOLE,
+ native_min_value=1,
+ native_max_value=100,
+ entity_category=EntityCategory.CONFIG,
+ set_value_fn=lambda machine, weight, key: machine.set_bbw_recipe_target(
+ key, int(weight)
+ ),
+ native_value_fn=lambda config, key: (
+ config.bbw_settings.doses[key] if config.bbw_settings else None
+ ),
+ supported_fn=(
+ lambda coordinator: coordinator.device.model == MachineModel.LINEA_MINI
+ and coordinator.device.config.scale is not None
+ ),
+ ),
+)
+
async def async_setup_entry(
hass: HomeAssistant,
@@ -208,7 +233,7 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up number entities."""
- coordinator = entry.runtime_data
+ coordinator = entry.runtime_data.config_coordinator
entities: list[NumberEntity] = [
LaMarzoccoNumberEntity(coordinator, description)
for description in ENTITIES
@@ -222,6 +247,27 @@ async def async_setup_entry(
LaMarzoccoKeyNumberEntity(coordinator, description, key)
for key in range(min(num_keys, 1), num_keys + 1)
)
+
+ for description in SCALE_KEY_ENTITIES:
+ if description.supported_fn(coordinator):
+ if bbw_settings := coordinator.device.config.bbw_settings:
+ entities.extend(
+ LaMarzoccoScaleTargetNumberEntity(
+ coordinator, description, int(key)
+ )
+ for key in bbw_settings.doses
+ )
+
+ def _async_add_new_scale() -> None:
+ if bbw_settings := coordinator.device.config.bbw_settings:
+ async_add_entities(
+ LaMarzoccoScaleTargetNumberEntity(coordinator, description, int(key))
+ for description in SCALE_KEY_ENTITIES
+ for key in bbw_settings.doses
+ )
+
+ coordinator.new_device_callback.append(_async_add_new_scale)
+
async_add_entities(entities)
@@ -279,7 +325,7 @@ class LaMarzoccoKeyNumberEntity(LaMarzoccoEntity, NumberEntity):
self.pyhsical_key = pyhsical_key
@property
- def native_value(self) -> float:
+ def native_value(self) -> float | None:
"""Return the current value."""
return self.entity_description.native_value_fn(
self.coordinator.device.config, PhysicalKey(self.pyhsical_key)
@@ -303,3 +349,11 @@ class LaMarzoccoKeyNumberEntity(LaMarzoccoEntity, NumberEntity):
},
) from exc
self.async_write_ha_state()
+
+
+class LaMarzoccoScaleTargetNumberEntity(
+ LaMarzoccoKeyNumberEntity, LaMarzoccScaleEntity
+):
+ """Entity representing a key number on the scale."""
+
+ entity_description: LaMarzoccoKeyNumberEntityDescription
diff --git a/homeassistant/components/lamarzocco/quality_scale.yaml b/homeassistant/components/lamarzocco/quality_scale.yaml
new file mode 100644
index 00000000000..b03f661c7b7
--- /dev/null
+++ b/homeassistant/components/lamarzocco/quality_scale.yaml
@@ -0,0 +1,87 @@
+rules:
+ # Bronze
+ action-setup:
+ status: exempt
+ comment: |
+ No custom actions are defined.
+ appropriate-polling: done
+ brands: done
+ common-modules: done
+ config-flow-test-coverage: done
+ config-flow: done
+ dependency-transparency: done
+ docs-actions:
+ status: exempt
+ comment: |
+ No custom actions are defined.
+ 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: done
+ test-before-setup: done
+ unique-config-entry: done
+
+ # Silver
+ action-exceptions:
+ status: exempt
+ comment: |
+ No custom actions are defined.
+ config-entry-unloading: done
+ docs-configuration-parameters: done
+ docs-installation-parameters: done
+ entity-unavailable: done
+ integration-owner: done
+ log-when-unavailable:
+ status: done
+ comment: |
+ Handled by coordinator.
+ parallel-updates: done
+ reauthentication-flow: done
+ test-coverage: done
+
+ # Gold
+ devices: done
+ diagnostics: done
+ discovery-update-info: done
+ discovery:
+ status: done
+ comment: |
+ DHCP & Bluetooth 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: done
+ comment: |
+ Device type integration, only possible for addon scale
+ 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: done
+ stale-devices:
+ status: done
+ comment: |
+ Device type integration, only possible for addon scale
+
+ # Platinum
+ async-dependency: done
+ inject-websession:
+ status: done
+ comment: |
+ Uses `httpx` session.
+ strict-typing: done
diff --git a/homeassistant/components/lamarzocco/select.py b/homeassistant/components/lamarzocco/select.py
index 1958fa6f210..7acb654f0d2 100644
--- a/homeassistant/components/lamarzocco/select.py
+++ b/homeassistant/components/lamarzocco/select.py
@@ -4,10 +4,16 @@ from collections.abc import Callable, Coroutine
from dataclasses import dataclass
from typing import Any
-from lmcloud.const import MachineModel, PrebrewMode, SmartStandbyMode, SteamLevel
-from lmcloud.exceptions import RequestNotSuccessful
-from lmcloud.lm_machine import LaMarzoccoMachine
-from lmcloud.models import LaMarzoccoMachineConfig
+from pylamarzocco.const import (
+ MachineModel,
+ PhysicalKey,
+ PrebrewMode,
+ SmartStandbyMode,
+ SteamLevel,
+)
+from pylamarzocco.devices.machine import LaMarzoccoMachine
+from pylamarzocco.exceptions import RequestNotSuccessful
+from pylamarzocco.models import LaMarzoccoMachineConfig
from homeassistant.components.select import SelectEntity, SelectEntityDescription
from homeassistant.const import EntityCategory
@@ -17,7 +23,9 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN
from .coordinator import LaMarzoccoConfigEntry
-from .entity import LaMarzoccoEntity, LaMarzoccoEntityDescription
+from .entity import LaMarzoccoEntity, LaMarzoccoEntityDescription, LaMarzoccScaleEntity
+
+PARALLEL_UPDATES = 1
STEAM_LEVEL_HA_TO_LM = {
"1": SteamLevel.LEVEL_1,
@@ -50,7 +58,7 @@ class LaMarzoccoSelectEntityDescription(
):
"""Description of a La Marzocco select entity."""
- current_option_fn: Callable[[LaMarzoccoMachineConfig], str]
+ current_option_fn: Callable[[LaMarzoccoMachineConfig], str | None]
select_option_fn: Callable[[LaMarzoccoMachine, str], Coroutine[Any, Any, bool]]
@@ -98,6 +106,22 @@ ENTITIES: tuple[LaMarzoccoSelectEntityDescription, ...] = (
),
)
+SCALE_ENTITIES: tuple[LaMarzoccoSelectEntityDescription, ...] = (
+ LaMarzoccoSelectEntityDescription(
+ key="active_bbw",
+ translation_key="active_bbw",
+ options=["a", "b"],
+ select_option_fn=lambda machine, option: machine.set_active_bbw_recipe(
+ PhysicalKey[option.upper()]
+ ),
+ current_option_fn=lambda config: (
+ config.bbw_settings.active_dose.name.lower()
+ if config.bbw_settings
+ else None
+ ),
+ ),
+)
+
async def async_setup_entry(
hass: HomeAssistant,
@@ -105,13 +129,32 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up select entities."""
- coordinator = entry.runtime_data
+ coordinator = entry.runtime_data.config_coordinator
- async_add_entities(
+ entities = [
LaMarzoccoSelectEntity(coordinator, description)
for description in ENTITIES
if description.supported_fn(coordinator)
- )
+ ]
+
+ if (
+ coordinator.device.model == MachineModel.LINEA_MINI
+ and coordinator.device.config.scale
+ ):
+ entities.extend(
+ LaMarzoccoScaleSelectEntity(coordinator, description)
+ for description in SCALE_ENTITIES
+ )
+
+ def _async_add_new_scale() -> None:
+ async_add_entities(
+ LaMarzoccoScaleSelectEntity(coordinator, description)
+ for description in SCALE_ENTITIES
+ )
+
+ coordinator.new_device_callback.append(_async_add_new_scale)
+
+ async_add_entities(entities)
class LaMarzoccoSelectEntity(LaMarzoccoEntity, SelectEntity):
@@ -120,7 +163,7 @@ class LaMarzoccoSelectEntity(LaMarzoccoEntity, SelectEntity):
entity_description: LaMarzoccoSelectEntityDescription
@property
- def current_option(self) -> str:
+ def current_option(self) -> str | None:
"""Return the current selected option."""
return str(
self.entity_description.current_option_fn(self.coordinator.device.config)
@@ -143,3 +186,9 @@ class LaMarzoccoSelectEntity(LaMarzoccoEntity, SelectEntity):
},
) from exc
self.async_write_ha_state()
+
+
+class LaMarzoccoScaleSelectEntity(LaMarzoccoSelectEntity, LaMarzoccScaleEntity):
+ """Select entity for La Marzocco scales."""
+
+ entity_description: LaMarzoccoSelectEntityDescription
diff --git a/homeassistant/components/lamarzocco/sensor.py b/homeassistant/components/lamarzocco/sensor.py
index ca8a118c1ee..2acca879d52 100644
--- a/homeassistant/components/lamarzocco/sensor.py
+++ b/homeassistant/components/lamarzocco/sensor.py
@@ -3,8 +3,8 @@
from collections.abc import Callable
from dataclasses import dataclass
-from lmcloud.const import BoilerType, MachineModel, PhysicalKey
-from lmcloud.lm_machine import LaMarzoccoMachine
+from pylamarzocco.const import BoilerType, MachineModel, PhysicalKey
+from pylamarzocco.devices.machine import LaMarzoccoMachine
from homeassistant.components.sensor import (
SensorDeviceClass,
@@ -12,12 +12,20 @@ from homeassistant.components.sensor import (
SensorEntityDescription,
SensorStateClass,
)
-from homeassistant.const import EntityCategory, UnitOfTemperature, UnitOfTime
+from homeassistant.const import (
+ PERCENTAGE,
+ EntityCategory,
+ UnitOfTemperature,
+ UnitOfTime,
+)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .coordinator import LaMarzoccoConfigEntry
-from .entity import LaMarzoccoEntity, LaMarzoccoEntityDescription
+from .entity import LaMarzoccoEntity, LaMarzoccoEntityDescription, LaMarzoccScaleEntity
+
+# Coordinator is used to centralize the data updates
+PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
@@ -30,24 +38,6 @@ class LaMarzoccoSensorEntityDescription(
ENTITIES: tuple[LaMarzoccoSensorEntityDescription, ...] = (
- LaMarzoccoSensorEntityDescription(
- key="drink_stats_coffee",
- 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),
- available_fn=lambda device: len(device.statistics.drink_stats) > 0,
- entity_category=EntityCategory.DIAGNOSTIC,
- ),
- LaMarzoccoSensorEntityDescription(
- key="drink_stats_flushing",
- translation_key="drink_stats_flushing",
- native_unit_of_measurement="drinks",
- state_class=SensorStateClass.TOTAL_INCREASING,
- value_fn=lambda device: device.statistics.total_flushes,
- available_fn=lambda device: len(device.statistics.drink_stats) > 0,
- entity_category=EntityCategory.DIAGNOSTIC,
- ),
LaMarzoccoSensorEntityDescription(
key="shot_timer",
translation_key="shot_timer",
@@ -85,6 +75,42 @@ ENTITIES: tuple[LaMarzoccoSensorEntityDescription, ...] = (
),
)
+STATISTIC_ENTITIES: tuple[LaMarzoccoSensorEntityDescription, ...] = (
+ LaMarzoccoSensorEntityDescription(
+ key="drink_stats_coffee",
+ 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),
+ available_fn=lambda device: len(device.statistics.drink_stats) > 0,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
+ LaMarzoccoSensorEntityDescription(
+ key="drink_stats_flushing",
+ translation_key="drink_stats_flushing",
+ native_unit_of_measurement="drinks",
+ state_class=SensorStateClass.TOTAL_INCREASING,
+ value_fn=lambda device: device.statistics.total_flushes,
+ available_fn=lambda device: len(device.statistics.drink_stats) > 0,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
+)
+
+SCALE_ENTITIES: tuple[LaMarzoccoSensorEntityDescription, ...] = (
+ LaMarzoccoSensorEntityDescription(
+ key="scale_battery",
+ native_unit_of_measurement=PERCENTAGE,
+ state_class=SensorStateClass.MEASUREMENT,
+ device_class=SensorDeviceClass.BATTERY,
+ value_fn=lambda device: (
+ device.config.scale.battery if device.config.scale else 0
+ ),
+ supported_fn=(
+ lambda coordinator: coordinator.device.model == MachineModel.LINEA_MINI
+ ),
+ ),
+)
+
async def async_setup_entry(
hass: HomeAssistant,
@@ -92,14 +118,40 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up sensor entities."""
- coordinator = entry.runtime_data
+ config_coordinator = entry.runtime_data.config_coordinator
- async_add_entities(
- LaMarzoccoSensorEntity(coordinator, description)
+ entities = [
+ LaMarzoccoSensorEntity(config_coordinator, description)
for description in ENTITIES
- if description.supported_fn(coordinator)
+ if description.supported_fn(config_coordinator)
+ ]
+
+ if (
+ config_coordinator.device.model == MachineModel.LINEA_MINI
+ and config_coordinator.device.config.scale
+ ):
+ entities.extend(
+ LaMarzoccoScaleSensorEntity(config_coordinator, description)
+ for description in SCALE_ENTITIES
+ )
+
+ statistics_coordinator = entry.runtime_data.statistics_coordinator
+ entities.extend(
+ LaMarzoccoSensorEntity(statistics_coordinator, description)
+ for description in STATISTIC_ENTITIES
+ if description.supported_fn(statistics_coordinator)
)
+ def _async_add_new_scale() -> None:
+ async_add_entities(
+ LaMarzoccoScaleSensorEntity(config_coordinator, description)
+ for description in SCALE_ENTITIES
+ )
+
+ config_coordinator.new_device_callback.append(_async_add_new_scale)
+
+ async_add_entities(entities)
+
class LaMarzoccoSensorEntity(LaMarzoccoEntity, SensorEntity):
"""Sensor representing espresso machine temperature data."""
@@ -110,3 +162,9 @@ class LaMarzoccoSensorEntity(LaMarzoccoEntity, SensorEntity):
def native_value(self) -> int | float:
"""State of the sensor."""
return self.entity_description.value_fn(self.coordinator.device)
+
+
+class LaMarzoccoScaleSensorEntity(LaMarzoccoSensorEntity, LaMarzoccScaleEntity):
+ """Sensor for a La Marzocco scale."""
+
+ entity_description: LaMarzoccoSensorEntityDescription
diff --git a/homeassistant/components/lamarzocco/strings.json b/homeassistant/components/lamarzocco/strings.json
index 959dda265a9..cc96e4615dc 100644
--- a/homeassistant/components/lamarzocco/strings.json
+++ b/homeassistant/components/lamarzocco/strings.json
@@ -1,6 +1,5 @@
{
"config": {
- "flow_title": "La Marzocco Espresso {host}",
"abort": {
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
@@ -26,7 +25,10 @@
"bluetooth_selection": {
"description": "Select your device from available Bluetooth devices.",
"data": {
- "mac": "Bluetooth device"
+ "mac": "[%key:common::config_flow::data::device%]"
+ },
+ "data_description": {
+ "mac": "Select the Bluetooth device that is your machine"
}
},
"machine_selection": {
@@ -36,7 +38,8 @@
"machine": "Machine"
},
"data_description": {
- "host": "Local IP address of the machine"
+ "host": "Local IP address of the machine",
+ "machine": "Select the machine you want to integrate"
}
},
"reauth_confirm": {
@@ -64,8 +67,10 @@
"step": {
"init": {
"data": {
- "title": "Update Configuration",
"use_bluetooth": "Use Bluetooth"
+ },
+ "data_description": {
+ "use_bluetooth": "Should the integration try to use Bluetooth to control the machine?"
}
}
}
@@ -117,6 +122,9 @@
"preinfusion_off_key": {
"name": "Preinfusion time Key {key}"
},
+ "scale_target_key": {
+ "name": "Brew by weight target {key}"
+ },
"smart_standby_time": {
"name": "Smart standby time"
},
@@ -128,6 +136,13 @@
}
},
"select": {
+ "active_bbw": {
+ "name": "Active brew by weight recipe",
+ "state": {
+ "a": "Recipe A",
+ "b": "Recipe B"
+ }
+ },
"prebrew_infusion_select": {
"name": "Prebrew/-infusion mode",
"state": {
@@ -196,6 +211,12 @@
}
},
"exceptions": {
+ "api_error": {
+ "message": "Error while communicating with the API"
+ },
+ "authentication_failed": {
+ "message": "Authentication failed"
+ },
"auto_on_off_error": {
"message": "Error while setting auto on/off to {state} for {id}"
},
diff --git a/homeassistant/components/lamarzocco/switch.py b/homeassistant/components/lamarzocco/switch.py
index a611424418f..54bd1ac2aed 100644
--- a/homeassistant/components/lamarzocco/switch.py
+++ b/homeassistant/components/lamarzocco/switch.py
@@ -4,10 +4,10 @@ from collections.abc import Callable, Coroutine
from dataclasses import dataclass
from typing import Any
-from lmcloud.const import BoilerType
-from lmcloud.exceptions import RequestNotSuccessful
-from lmcloud.lm_machine import LaMarzoccoMachine
-from lmcloud.models import LaMarzoccoMachineConfig
+from pylamarzocco.const import BoilerType
+from pylamarzocco.devices.machine import LaMarzoccoMachine
+from pylamarzocco.exceptions import RequestNotSuccessful
+from pylamarzocco.models import LaMarzoccoMachineConfig
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.const import EntityCategory
@@ -19,6 +19,8 @@ from .const import DOMAIN
from .coordinator import LaMarzoccoConfigEntry, LaMarzoccoUpdateCoordinator
from .entity import LaMarzoccoBaseEntity, LaMarzoccoEntity, LaMarzoccoEntityDescription
+PARALLEL_UPDATES = 1
+
@dataclass(frozen=True, kw_only=True)
class LaMarzoccoSwitchEntityDescription(
@@ -66,7 +68,7 @@ async def async_setup_entry(
) -> None:
"""Set up switch entities and services."""
- coordinator = entry.runtime_data
+ coordinator = entry.runtime_data.config_coordinator
entities: list[SwitchEntity] = []
entities.extend(
@@ -108,7 +110,7 @@ class LaMarzoccoSwitchEntity(LaMarzoccoEntity, SwitchEntity):
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="switch_off_error",
- translation_placeholders={"name": self.entity_description.key},
+ translation_placeholders={"key": self.entity_description.key},
) from exc
self.async_write_ha_state()
diff --git a/homeassistant/components/lamarzocco/update.py b/homeassistant/components/lamarzocco/update.py
index 61f436a7d7f..0833ee6e249 100644
--- a/homeassistant/components/lamarzocco/update.py
+++ b/homeassistant/components/lamarzocco/update.py
@@ -3,8 +3,8 @@
from dataclasses import dataclass
from typing import Any
-from lmcloud.const import FirmwareType
-from lmcloud.exceptions import RequestNotSuccessful
+from pylamarzocco.const import FirmwareType
+from pylamarzocco.exceptions import RequestNotSuccessful
from homeassistant.components.update import (
UpdateDeviceClass,
@@ -21,6 +21,8 @@ from .const import DOMAIN
from .coordinator import LaMarzoccoConfigEntry
from .entity import LaMarzoccoEntity, LaMarzoccoEntityDescription
+PARALLEL_UPDATES = 1
+
@dataclass(frozen=True, kw_only=True)
class LaMarzoccoUpdateEntityDescription(
@@ -57,7 +59,7 @@ async def async_setup_entry(
) -> None:
"""Create update entities."""
- coordinator = entry.runtime_data
+ coordinator = entry.runtime_data.firmware_coordinator
async_add_entities(
LaMarzoccoUpdateEntity(coordinator, description)
for description in ENTITIES
diff --git a/homeassistant/components/lametric/config_flow.py b/homeassistant/components/lametric/config_flow.py
index 36dcdf26ed6..05c5dea77d1 100644
--- a/homeassistant/components/lametric/config_flow.py
+++ b/homeassistant/components/lametric/config_flow.py
@@ -249,7 +249,10 @@ class LaMetricFlowHandler(AbstractOAuth2FlowHandler, domain=DOMAIN):
device = await lametric.device()
if self.source != SOURCE_REAUTH:
- await self.async_set_unique_id(device.serial_number)
+ await self.async_set_unique_id(
+ device.serial_number,
+ raise_on_progress=False,
+ )
self._abort_if_unique_id_configured(
updates={CONF_HOST: lametric.host, CONF_API_KEY: lametric.api_key}
)
diff --git a/homeassistant/components/lametric/diagnostics.py b/homeassistant/components/lametric/diagnostics.py
index 69c681e911a..c14ed998ace 100644
--- a/homeassistant/components/lametric/diagnostics.py
+++ b/homeassistant/components/lametric/diagnostics.py
@@ -26,5 +26,5 @@ async def async_get_config_entry_diagnostics(
"""Return diagnostics for a config entry."""
coordinator: LaMetricDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
# Round-trip via JSON to trigger serialization
- data = json.loads(coordinator.data.json())
+ data = json.loads(coordinator.data.to_json())
return async_redact_data(data, TO_REDACT)
diff --git a/homeassistant/components/lametric/manifest.json b/homeassistant/components/lametric/manifest.json
index 92ccd29c916..f66ffb0c6ae 100644
--- a/homeassistant/components/lametric/manifest.json
+++ b/homeassistant/components/lametric/manifest.json
@@ -13,8 +13,7 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["demetriek"],
- "quality_scale": "platinum",
- "requirements": ["demetriek==0.4.0"],
+ "requirements": ["demetriek==1.1.1"],
"ssdp": [
{
"deviceType": "urn:schemas-upnp-org:device:LaMetric:1"
diff --git a/homeassistant/components/lametric/notify.py b/homeassistant/components/lametric/notify.py
index 7362f0ca402..195924e2da5 100644
--- a/homeassistant/components/lametric/notify.py
+++ b/homeassistant/components/lametric/notify.py
@@ -5,12 +5,14 @@ from __future__ import annotations
from typing import Any
from demetriek import (
+ AlarmSound,
LaMetricDevice,
LaMetricError,
Model,
Notification,
NotificationIconType,
NotificationPriority,
+ NotificationSound,
Simple,
Sound,
)
@@ -18,8 +20,9 @@ from demetriek import (
from homeassistant.components.notify import ATTR_DATA, BaseNotificationService
from homeassistant.const import CONF_ICON
from homeassistant.core import HomeAssistant
-from homeassistant.exceptions import HomeAssistantError
+from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
+from homeassistant.util.enum import try_parse_enum
from .const import CONF_CYCLES, CONF_ICON_TYPE, CONF_PRIORITY, CONF_SOUND, DOMAIN
from .coordinator import LaMetricDataUpdateCoordinator
@@ -53,7 +56,12 @@ class LaMetricNotificationService(BaseNotificationService):
sound = None
if CONF_SOUND in data:
- sound = Sound(sound=data[CONF_SOUND], category=None)
+ snd: AlarmSound | NotificationSound | None
+ if (snd := try_parse_enum(AlarmSound, data[CONF_SOUND])) is None and (
+ snd := try_parse_enum(NotificationSound, data[CONF_SOUND])
+ ) is None:
+ raise ServiceValidationError("Unknown sound provided")
+ sound = Sound(sound=snd, category=None)
notification = Notification(
icon_type=NotificationIconType(data.get(CONF_ICON_TYPE, "none")),
diff --git a/homeassistant/components/lametric/number.py b/homeassistant/components/lametric/number.py
index cea9debb04b..a1d922c2d80 100644
--- a/homeassistant/components/lametric/number.py
+++ b/homeassistant/components/lametric/number.py
@@ -25,6 +25,7 @@ class LaMetricNumberEntityDescription(NumberEntityDescription):
"""Class describing LaMetric number entities."""
value_fn: Callable[[Device], int | None]
+ has_fn: Callable[[Device], bool] = lambda device: True
set_value_fn: Callable[[LaMetricDevice, float], Awaitable[Any]]
@@ -49,7 +50,8 @@ NUMBERS = [
native_step=1,
native_min_value=0,
native_max_value=100,
- value_fn=lambda device: device.audio.volume,
+ has_fn=lambda device: bool(device.audio and device.audio.available),
+ value_fn=lambda device: device.audio.volume if device.audio else 0,
set_value_fn=lambda api, volume: api.audio(volume=int(volume)),
),
]
diff --git a/homeassistant/components/lametric/quality_scale.yaml b/homeassistant/components/lametric/quality_scale.yaml
new file mode 100644
index 00000000000..a8982bb938b
--- /dev/null
+++ b/homeassistant/components/lametric/quality_scale.yaml
@@ -0,0 +1,75 @@
+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: todo
+ 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: todo
+ 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: done
+ test-coverage: done
+ # Gold
+ devices: done
+ diagnostics: done
+ discovery-update-info: done
+ discovery: done
+ docs-data-update: todo
+ docs-examples: todo
+ docs-known-limitations: todo
+ docs-supported-devices:
+ status: todo
+ comment: |
+ Device are documented, but some are missing. For example, the their pro
+ strip is supported as well.
+ docs-supported-functions: todo
+ docs-troubleshooting: todo
+ docs-use-cases: todo
+ dynamic-devices:
+ status: exempt
+ comment: |
+ This integration connects to a single device.
+ entity-category: done
+ entity-device-class: done
+ entity-disabled-by-default: done
+ entity-translations: todo
+ exception-translations: todo
+ icon-translations: done
+ reconfiguration-flow: todo
+ repair-issues:
+ status: exempt
+ comment: |
+ This integration does not raise any repairable issues.
+ stale-devices:
+ status: exempt
+ comment: |
+ This integration connects to a single device.
+
+ # Platinum
+ async-dependency: done
+ inject-websession: done
+ strict-typing: done
diff --git a/homeassistant/components/lametric/services.py b/homeassistant/components/lametric/services.py
index d5191e0a434..2d9cd8f222d 100644
--- a/homeassistant/components/lametric/services.py
+++ b/homeassistant/components/lametric/services.py
@@ -19,8 +19,9 @@ import voluptuous as vol
from homeassistant.const import CONF_DEVICE_ID, CONF_ICON
from homeassistant.core import HomeAssistant, ServiceCall, callback
-from homeassistant.exceptions import HomeAssistantError
+from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import config_validation as cv
+from homeassistant.util.enum import try_parse_enum
from .const import (
CONF_CYCLES,
@@ -118,7 +119,12 @@ async def async_send_notification(
"""Send a notification to an LaMetric device."""
sound = None
if CONF_SOUND in call.data:
- sound = Sound(sound=call.data[CONF_SOUND], category=None)
+ snd: AlarmSound | NotificationSound | None
+ if (snd := try_parse_enum(AlarmSound, call.data[CONF_SOUND])) is None and (
+ snd := try_parse_enum(NotificationSound, call.data[CONF_SOUND])
+ ) is None:
+ raise ServiceValidationError("Unknown sound provided")
+ sound = Sound(sound=snd, category=None)
notification = Notification(
icon_type=NotificationIconType(call.data[CONF_ICON_TYPE]),
diff --git a/homeassistant/components/lametric/strings.json b/homeassistant/components/lametric/strings.json
index 87bda01e305..0fd6f5a12dc 100644
--- a/homeassistant/components/lametric/strings.json
+++ b/homeassistant/components/lametric/strings.json
@@ -21,8 +21,11 @@
"api_key": "You can find this API key in [devices page in your LaMetric developer account](https://developer.lametric.com/user/devices)."
}
},
- "user_cloud_select_device": {
+ "cloud_select_device": {
"data": {
+ "device": "Device"
+ },
+ "data_description": {
"device": "Select the LaMetric device to add"
}
}
diff --git a/homeassistant/components/lametric/switch.py b/homeassistant/components/lametric/switch.py
index 9689bb7b802..3aabfaf17e1 100644
--- a/homeassistant/components/lametric/switch.py
+++ b/homeassistant/components/lametric/switch.py
@@ -25,6 +25,7 @@ class LaMetricSwitchEntityDescription(SwitchEntityDescription):
"""Class describing LaMetric switch entities."""
available_fn: Callable[[Device], bool] = lambda device: True
+ has_fn: Callable[[Device], bool] = lambda device: True
is_on_fn: Callable[[Device], bool]
set_fn: Callable[[LaMetricDevice, bool], Awaitable[Any]]
@@ -34,8 +35,11 @@ SWITCHES = [
key="bluetooth",
translation_key="bluetooth",
entity_category=EntityCategory.CONFIG,
- available_fn=lambda device: device.bluetooth.available,
- is_on_fn=lambda device: device.bluetooth.active,
+ available_fn=lambda device: bool(
+ device.bluetooth and device.bluetooth.available
+ ),
+ has_fn=lambda device: bool(device.bluetooth),
+ is_on_fn=lambda device: bool(device.bluetooth and device.bluetooth.active),
set_fn=lambda api, active: api.bluetooth(active=active),
),
]
@@ -54,6 +58,7 @@ async def async_setup_entry(
description=description,
)
for description in SWITCHES
+ if description.has_fn(coordinator.data)
)
diff --git a/homeassistant/components/lannouncer/manifest.json b/homeassistant/components/lannouncer/manifest.json
index c04d9e87655..9d0942bd14f 100644
--- a/homeassistant/components/lannouncer/manifest.json
+++ b/homeassistant/components/lannouncer/manifest.json
@@ -3,5 +3,6 @@
"name": "LANnouncer",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/lannouncer",
- "iot_class": "local_push"
+ "iot_class": "local_push",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/lcn/__init__.py b/homeassistant/components/lcn/__init__.py
index 5995e06efcc..58924413c56 100644
--- a/homeassistant/components/lcn/__init__.py
+++ b/homeassistant/components/lcn/__init__.py
@@ -6,9 +6,17 @@ from functools import partial
import logging
import pypck
-from pypck.connection import PchkConnectionManager
+from pypck.connection import (
+ PchkAuthenticationError,
+ PchkConnectionFailedError,
+ PchkConnectionManager,
+ PchkConnectionRefusedError,
+ PchkLcnNotConnectedError,
+ PchkLicenseError,
+)
+from pypck.lcn_defs import LcnEvent
-from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
+from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONF_DEVICE_ID,
CONF_DOMAIN,
@@ -20,7 +28,8 @@ from homeassistant.const import (
Platform,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers import device_registry as dr
+from homeassistant.exceptions import ConfigEntryNotReady
+from homeassistant.helpers import config_validation as cv, device_registry as dr
from homeassistant.helpers.typing import ConfigType
from .const import (
@@ -31,6 +40,7 @@ from .const import (
CONF_SK_NUM_TRIES,
CONF_TRANSITION,
CONNECTION,
+ DEVICE_CONNECTIONS,
DOMAIN,
PLATFORMS,
)
@@ -39,40 +49,29 @@ from .helpers import (
InputType,
async_update_config_entry,
generate_unique_id,
- import_lcn_config,
register_lcn_address_devices,
register_lcn_host_device,
)
-from .schemas import CONFIG_SCHEMA # noqa: F401
-from .services import SERVICES
+from .services import register_services
from .websocket import register_panel_and_ws_api
_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 LCN component."""
- if DOMAIN not in config:
- return True
+ hass.data.setdefault(DOMAIN, {})
- # initialize a config_flow for all LCN configurations read from
- # configuration.yaml
- config_entries_data = import_lcn_config(config[DOMAIN])
+ await register_services(hass)
+ await register_panel_and_ws_api(hass)
- for config_entry_data in config_entries_data:
- hass.async_create_task(
- hass.config_entries.flow.async_init(
- DOMAIN,
- context={"source": SOURCE_IMPORT},
- data=config_entry_data,
- )
- )
return True
async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
"""Set up a connection to PCHK host from a config entry."""
- hass.data.setdefault(DOMAIN, {})
if config_entry.entry_id in hass.data[DOMAIN]:
return False
@@ -91,30 +90,29 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
settings=settings,
connection_id=config_entry.entry_id,
)
+
try:
# establish connection to PCHK server
await lcn_connection.async_connect(timeout=15)
- except pypck.connection.PchkAuthenticationError:
- _LOGGER.warning('Authentication on PCHK "%s" failed', config_entry.title)
- return False
- except pypck.connection.PchkLicenseError:
- _LOGGER.warning(
- (
- 'Maximum number of connections on PCHK "%s" was '
- "reached. An additional license key is required"
- ),
- config_entry.title,
- )
- return False
- except TimeoutError:
- _LOGGER.warning('Connection to PCHK "%s" failed', config_entry.title)
- return False
+ except (
+ PchkAuthenticationError,
+ PchkLicenseError,
+ PchkConnectionRefusedError,
+ PchkConnectionFailedError,
+ PchkLcnNotConnectedError,
+ ) as ex:
+ await lcn_connection.async_close()
+ raise ConfigEntryNotReady(
+ f"Unable to connect to {config_entry.title}: {ex}"
+ ) from ex
_LOGGER.debug('LCN connected to "%s"', config_entry.title)
hass.data[DOMAIN][config_entry.entry_id] = {
CONNECTION: lcn_connection,
+ DEVICE_CONNECTIONS: {},
ADD_ENTITIES_CALLBACKS: {},
}
+
# Update config_entry with LCN device serials
await async_update_config_entry(hass, config_entry)
@@ -127,20 +125,14 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
# register for LCN bus messages
device_registry = dr.async_get(hass)
+ event_received = partial(async_host_event_received, hass, config_entry)
input_received = partial(
async_host_input_received, hass, config_entry, device_registry
)
+
+ lcn_connection.register_for_events(event_received)
lcn_connection.register_for_inputs(input_received)
- # register service calls
- for service_name, service in SERVICES:
- if not hass.services.has_service(DOMAIN, service_name):
- hass.services.async_register(
- DOMAIN, service_name, service(hass).async_call_service, service.schema
- )
-
- await register_panel_and_ws_api(hass)
-
return True
@@ -191,14 +183,34 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
host = hass.data[DOMAIN].pop(config_entry.entry_id)
await host[CONNECTION].async_close()
- # unregister service calls
- if unload_ok and not hass.data[DOMAIN]: # check if this is the last entry to unload
- for service_name, _ in SERVICES:
- hass.services.async_remove(DOMAIN, service_name)
-
return unload_ok
+def async_host_event_received(
+ hass: HomeAssistant, config_entry: ConfigEntry, event: pypck.lcn_defs.LcnEvent
+) -> None:
+ """Process received event from LCN."""
+ lcn_connection = hass.data[DOMAIN][config_entry.entry_id][CONNECTION]
+
+ async def reload_config_entry() -> None:
+ """Close connection and schedule config entry for reload."""
+ await lcn_connection.async_close()
+ hass.config_entries.async_schedule_reload(config_entry.entry_id)
+
+ if event in (
+ LcnEvent.CONNECTION_LOST,
+ LcnEvent.PING_TIMEOUT,
+ ):
+ _LOGGER.info('The connection to host "%s" has been lost', config_entry.title)
+ hass.async_create_task(reload_config_entry())
+ elif event == LcnEvent.BUS_DISCONNECTED:
+ _LOGGER.info(
+ 'The connection to the LCN bus via host "%s" has been disconnected',
+ config_entry.title,
+ )
+ hass.async_create_task(reload_config_entry())
+
+
def async_host_input_received(
hass: HomeAssistant,
config_entry: ConfigEntry,
@@ -218,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)
@@ -228,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 = {
@@ -250,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 1c7472bc4e3..360b732c02e 100644
--- a/homeassistant/components/lcn/climate.py
+++ b/homeassistant/components/lcn/climate.py
@@ -81,8 +81,6 @@ async def async_setup_entry(
class LcnClimate(LcnEntity, ClimateEntity):
"""Representation of a LCN climate device."""
- _enable_turn_on_off_backwards_compatibility = False
-
def __init__(self, config: ConfigType, config_entry: ConfigEntry) -> None:
"""Initialize of a LCN climate device."""
super().__init__(config, config_entry)
diff --git a/homeassistant/components/lcn/config_flow.py b/homeassistant/components/lcn/config_flow.py
index e78378a61b1..a1be32704f7 100644
--- a/homeassistant/components/lcn/config_flow.py
+++ b/homeassistant/components/lcn/config_flow.py
@@ -9,7 +9,6 @@ import pypck
import voluptuous as vol
from homeassistant import config_entries
-from homeassistant.config_entries import ConfigFlowResult
from homeassistant.const import (
CONF_BASE,
CONF_DEVICES,
@@ -20,14 +19,12 @@ from homeassistant.const import (
CONF_PORT,
CONF_USERNAME,
)
-from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant
+from homeassistant.core import HomeAssistant
import homeassistant.helpers.config_validation as cv
-from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.helpers.typing import ConfigType
from . import PchkConnectionManager
from .const import CONF_ACKNOWLEDGE, CONF_DIM_MODE, CONF_SK_NUM_TRIES, DIM_MODES, DOMAIN
-from .helpers import purge_device_registry, purge_entity_registry
_LOGGER = logging.getLogger(__name__)
@@ -99,7 +96,10 @@ async def validate_connection(data: ConfigType) -> str | None:
host_name,
)
error = "license_error"
- except (TimeoutError, ConnectionRefusedError):
+ except (
+ pypck.connection.PchkConnectionFailedError,
+ pypck.connection.PchkConnectionRefusedError,
+ ):
_LOGGER.warning('Connection to PCHK "%s" failed', host_name)
error = "connection_refused"
@@ -113,55 +113,6 @@ class LcnFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
VERSION = 2
MINOR_VERSION = 1
- async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult:
- """Import existing configuration from LCN."""
- # validate the imported connection parameters
- if error := await validate_connection(import_data):
- async_create_issue(
- self.hass,
- DOMAIN,
- error,
- is_fixable=False,
- issue_domain=DOMAIN,
- severity=IssueSeverity.ERROR,
- translation_key=error,
- translation_placeholders={
- "url": "/config/integrations/dashboard/add?domain=lcn"
- },
- )
- return self.async_abort(reason=error)
-
- async_create_issue(
- self.hass,
- HOMEASSISTANT_DOMAIN,
- f"deprecated_yaml_{DOMAIN}",
- breaks_in_ha_version="2024.12.0",
- is_fixable=False,
- is_persistent=False,
- issue_domain=DOMAIN,
- severity=IssueSeverity.WARNING,
- translation_key="deprecated_yaml",
- translation_placeholders={
- "domain": DOMAIN,
- "integration_title": "LCN",
- },
- )
-
- # check if we already have a host with the same address configured
- if entry := get_config_entry(self.hass, import_data):
- entry.source = config_entries.SOURCE_IMPORT
- # Cleanup entity and device registry, if we imported from configuration.yaml to
- # remove orphans when entities were removed from configuration
- purge_entity_registry(self.hass, entry.entry_id, import_data)
- purge_device_registry(self.hass, entry.entry_id, import_data)
-
- self.hass.config_entries.async_update_entry(entry, data=import_data)
- return self.async_abort(reason="existing_configuration_updated")
-
- return self.async_create_entry(
- title=f"{import_data[CONF_HOST]}", data=import_data
- )
-
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> config_entries.ConfigFlowResult:
diff --git a/homeassistant/components/lcn/const.py b/homeassistant/components/lcn/const.py
index 97aeeecd8b5..cee9da9be43 100644
--- a/homeassistant/components/lcn/const.py
+++ b/homeassistant/components/lcn/const.py
@@ -20,6 +20,7 @@ DEFAULT_NAME = "pchk"
ADD_ENTITIES_CALLBACKS = "add_entities_callbacks"
CONNECTION = "connection"
+DEVICE_CONNECTIONS = "device_connections"
CONF_HARDWARE_SERIAL = "hardware_serial"
CONF_SOFTWARE_SERIAL = "software_serial"
CONF_HARDWARE_TYPE = "hardware_type"
diff --git a/homeassistant/components/lcn/helpers.py b/homeassistant/components/lcn/helpers.py
index 7da047682ac..348305c775e 100644
--- a/homeassistant/components/lcn/helpers.py
+++ b/homeassistant/components/lcn/helpers.py
@@ -9,7 +9,6 @@ import re
from typing import cast
import pypck
-import voluptuous as vol
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
@@ -19,17 +18,12 @@ from homeassistant.const import (
CONF_DEVICES,
CONF_DOMAIN,
CONF_ENTITIES,
- CONF_HOST,
- CONF_IP_ADDRESS,
CONF_LIGHTS,
CONF_NAME,
- CONF_PASSWORD,
- CONF_PORT,
CONF_RESOURCE,
CONF_SENSORS,
CONF_SOURCE,
CONF_SWITCHES,
- CONF_USERNAME,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr, entity_registry as er
@@ -37,19 +31,14 @@ from homeassistant.helpers.typing import ConfigType
from .const import (
BINSENSOR_PORTS,
- CONF_ACKNOWLEDGE,
CONF_CLIMATES,
- CONF_CONNECTIONS,
- CONF_DIM_MODE,
- CONF_DOMAIN_DATA,
CONF_HARDWARE_SERIAL,
CONF_HARDWARE_TYPE,
CONF_OUTPUT,
CONF_SCENES,
- CONF_SK_NUM_TRIES,
CONF_SOFTWARE_SERIAL,
CONNECTION,
- DEFAULT_NAME,
+ DEVICE_CONNECTIONS,
DOMAIN,
LED_PORTS,
LOGICOP_PORTS,
@@ -146,110 +135,6 @@ def generate_unique_id(
return unique_id
-def import_lcn_config(lcn_config: ConfigType) -> list[ConfigType]:
- """Convert lcn settings from configuration.yaml to config_entries data.
-
- Create a list of config_entry data structures like:
-
- "data": {
- "host": "pchk",
- "ip_address": "192.168.2.41",
- "port": 4114,
- "username": "lcn",
- "password": "lcn,
- "sk_num_tries: 0,
- "dim_mode: "STEPS200",
- "acknowledge": False,
- "devices": [
- {
- "address": (0, 7, False)
- "name": "",
- "hardware_serial": -1,
- "software_serial": -1,
- "hardware_type": -1
- }, ...
- ],
- "entities": [
- {
- "address": (0, 7, False)
- "name": "Light_Output1",
- "resource": "output1",
- "domain": "light",
- "domain_data": {
- "output": "OUTPUT1",
- "dimmable": True,
- "transition": 5000.0
- }
- }, ...
- ]
- }
- """
- data = {}
- for connection in lcn_config[CONF_CONNECTIONS]:
- host = {
- CONF_HOST: connection[CONF_NAME],
- CONF_IP_ADDRESS: connection[CONF_HOST],
- CONF_PORT: connection[CONF_PORT],
- CONF_USERNAME: connection[CONF_USERNAME],
- CONF_PASSWORD: connection[CONF_PASSWORD],
- CONF_SK_NUM_TRIES: connection[CONF_SK_NUM_TRIES],
- CONF_DIM_MODE: connection[CONF_DIM_MODE],
- CONF_ACKNOWLEDGE: False,
- CONF_DEVICES: [],
- CONF_ENTITIES: [],
- }
- data[connection[CONF_NAME]] = host
-
- for confkey, domain_config in lcn_config.items():
- if confkey == CONF_CONNECTIONS:
- continue
- domain = DOMAIN_LOOKUP[confkey]
- # loop over entities in configuration.yaml
- for domain_data in domain_config:
- # remove name and address from domain_data
- entity_name = domain_data.pop(CONF_NAME)
- address, host_name = domain_data.pop(CONF_ADDRESS)
-
- if host_name is None:
- host_name = DEFAULT_NAME
-
- # check if we have a new device config
- for device_config in data[host_name][CONF_DEVICES]:
- if address == device_config[CONF_ADDRESS]:
- break
- else: # create new device_config
- device_config = {
- CONF_ADDRESS: address,
- CONF_NAME: "",
- CONF_HARDWARE_SERIAL: -1,
- CONF_SOFTWARE_SERIAL: -1,
- CONF_HARDWARE_TYPE: -1,
- }
-
- data[host_name][CONF_DEVICES].append(device_config)
-
- # insert entity config
- resource = get_resource(domain, domain_data).lower()
- for entity_config in data[host_name][CONF_ENTITIES]:
- if (
- address == entity_config[CONF_ADDRESS]
- and resource == entity_config[CONF_RESOURCE]
- and domain == entity_config[CONF_DOMAIN]
- ):
- break
- else: # create new entity_config
- entity_config = {
- CONF_ADDRESS: address,
- CONF_NAME: entity_name,
- CONF_RESOURCE: resource,
- CONF_DOMAIN: domain,
- CONF_DOMAIN_DATA: domain_data.copy(),
- }
- data[host_name][CONF_ENTITIES].append(entity_config)
-
- return list(data.values())
-
-
def purge_entity_registry(
hass: HomeAssistant, entry_id: str, imported_entry_data: ConfigType
) -> None:
@@ -353,7 +238,7 @@ def register_lcn_address_devices(
identifiers = {(DOMAIN, generate_unique_id(config_entry.entry_id, address))}
if device_config[CONF_ADDRESS][2]: # is group
- device_model = f"LCN group (g{address[0]:03d}{address[1]:03d})"
+ device_model = "LCN group"
sw_version = None
else: # is module
hardware_type = device_config[CONF_HARDWARE_TYPE]
@@ -361,10 +246,10 @@ def register_lcn_address_devices(
hardware_name = pypck.lcn_defs.HARDWARE_DESCRIPTIONS[hardware_type]
else:
hardware_name = pypck.lcn_defs.HARDWARE_DESCRIPTIONS[-1]
- device_model = f"{hardware_name} (m{address[0]:03d}{address[1]:03d})"
+ device_model = f"{hardware_name}"
sw_version = f"{device_config[CONF_SOFTWARE_SERIAL]:06X}"
- device_registry.async_get_or_create(
+ device_entry = device_registry.async_get_or_create(
config_entry_id=config_entry.entry_id,
identifiers=identifiers,
via_device=host_identifiers,
@@ -374,6 +259,10 @@ def register_lcn_address_devices(
model=device_model,
)
+ hass.data[DOMAIN][config_entry.entry_id][DEVICE_CONNECTIONS][
+ device_entry.id
+ ] = get_device_connection(hass, address, config_entry)
+
async def async_update_device_config(
device_connection: DeviceConnectionType, device_config: ConfigType
@@ -436,26 +325,6 @@ def get_device_config(
return None
-def has_unique_host_names(hosts: list[ConfigType]) -> list[ConfigType]:
- """Validate that all connection names are unique.
-
- Use 'pchk' as default connection_name (or add a numeric suffix if
- pchk' is already in use.
- """
- suffix = 0
- for host in hosts:
- if host.get(CONF_NAME) is None:
- if suffix == 0:
- host[CONF_NAME] = DEFAULT_NAME
- else:
- host[CONF_NAME] = f"{DEFAULT_NAME}{suffix:d}"
- suffix += 1
-
- schema = vol.Schema(vol.Unique())
- schema([host.get(CONF_NAME) for host in hosts])
- return hosts
-
-
def is_address(value: str) -> tuple[AddressType, str]:
"""Validate the given address string.
diff --git a/homeassistant/components/lcn/manifest.json b/homeassistant/components/lcn/manifest.json
index 6ce41a2d08d..f5eb1654588 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.7.24", "lcn-frontend==0.2.1"]
+ "requirements": ["pypck==0.8.1", "lcn-frontend==0.2.2"]
}
diff --git a/homeassistant/components/lcn/schemas.py b/homeassistant/components/lcn/schemas.py
index 3b4d2333970..c9c91b9843d 100644
--- a/homeassistant/components/lcn/schemas.py
+++ b/homeassistant/components/lcn/schemas.py
@@ -4,20 +4,9 @@ import voluptuous as vol
from homeassistant.components.climate import DEFAULT_MAX_TEMP, DEFAULT_MIN_TEMP
from homeassistant.const import (
- CONF_ADDRESS,
- CONF_BINARY_SENSORS,
- CONF_COVERS,
- CONF_HOST,
- CONF_LIGHTS,
- CONF_NAME,
- CONF_PASSWORD,
- CONF_PORT,
CONF_SCENE,
- CONF_SENSORS,
CONF_SOURCE,
- CONF_SWITCHES,
CONF_UNIT_OF_MEASUREMENT,
- CONF_USERNAME,
UnitOfTemperature,
)
import homeassistant.helpers.config_validation as cv
@@ -25,9 +14,6 @@ from homeassistant.helpers.typing import VolDictType
from .const import (
BINSENSOR_PORTS,
- CONF_CLIMATES,
- CONF_CONNECTIONS,
- CONF_DIM_MODE,
CONF_DIMMABLE,
CONF_LOCKABLE,
CONF_MAX_TEMP,
@@ -37,12 +23,8 @@ from .const import (
CONF_OUTPUTS,
CONF_REGISTER,
CONF_REVERSE_TIME,
- CONF_SCENES,
CONF_SETPOINT,
- CONF_SK_NUM_TRIES,
CONF_TRANSITION,
- DIM_MODES,
- DOMAIN,
KEYS,
LED_PORTS,
LOGICOP_PORTS,
@@ -56,7 +38,6 @@ from .const import (
VAR_UNITS,
VARIABLES,
)
-from .helpers import has_unique_host_names, is_address
ADDRESS_SCHEMA = vol.Coerce(tuple)
@@ -130,72 +111,3 @@ DOMAIN_DATA_SWITCH: VolDictType = {
vol.In(OUTPUT_PORTS + RELAY_PORTS + SETPOINTS + KEYS),
),
}
-
-
-#
-# Configuration
-#
-
-DOMAIN_DATA_BASE: VolDictType = {
- vol.Required(CONF_NAME): cv.string,
- vol.Required(CONF_ADDRESS): is_address,
-}
-
-BINARY_SENSORS_SCHEMA = vol.Schema({**DOMAIN_DATA_BASE, **DOMAIN_DATA_BINARY_SENSOR})
-
-CLIMATES_SCHEMA = vol.Schema({**DOMAIN_DATA_BASE, **DOMAIN_DATA_CLIMATE})
-
-COVERS_SCHEMA = vol.Schema({**DOMAIN_DATA_BASE, **DOMAIN_DATA_COVER})
-
-LIGHTS_SCHEMA = vol.Schema({**DOMAIN_DATA_BASE, **DOMAIN_DATA_LIGHT})
-
-SCENES_SCHEMA = vol.Schema({**DOMAIN_DATA_BASE, **DOMAIN_DATA_SCENE})
-
-SENSORS_SCHEMA = vol.Schema({**DOMAIN_DATA_BASE, **DOMAIN_DATA_SENSOR})
-
-SWITCHES_SCHEMA = vol.Schema({**DOMAIN_DATA_BASE, **DOMAIN_DATA_SWITCH})
-
-CONNECTION_SCHEMA = vol.Schema(
- {
- vol.Required(CONF_HOST): cv.string,
- vol.Required(CONF_PORT): cv.port,
- vol.Required(CONF_USERNAME): cv.string,
- vol.Required(CONF_PASSWORD): cv.string,
- vol.Optional(CONF_SK_NUM_TRIES, default=0): cv.positive_int,
- vol.Optional(CONF_DIM_MODE, default="steps50"): vol.All(
- vol.Upper, vol.In(DIM_MODES)
- ),
- vol.Optional(CONF_NAME): cv.string,
- }
-)
-
-CONFIG_SCHEMA = vol.Schema(
- vol.All(
- cv.deprecated(DOMAIN),
- {
- DOMAIN: vol.Schema(
- {
- vol.Required(CONF_CONNECTIONS): vol.All(
- cv.ensure_list, has_unique_host_names, [CONNECTION_SCHEMA]
- ),
- vol.Optional(CONF_BINARY_SENSORS): vol.All(
- cv.ensure_list, [BINARY_SENSORS_SCHEMA]
- ),
- vol.Optional(CONF_CLIMATES): vol.All(
- cv.ensure_list, [CLIMATES_SCHEMA]
- ),
- vol.Optional(CONF_COVERS): vol.All(cv.ensure_list, [COVERS_SCHEMA]),
- vol.Optional(CONF_LIGHTS): vol.All(cv.ensure_list, [LIGHTS_SCHEMA]),
- vol.Optional(CONF_SCENES): vol.All(cv.ensure_list, [SCENES_SCHEMA]),
- vol.Optional(CONF_SENSORS): vol.All(
- cv.ensure_list, [SENSORS_SCHEMA]
- ),
- vol.Optional(CONF_SWITCHES): vol.All(
- cv.ensure_list, [SWITCHES_SCHEMA]
- ),
- },
- )
- },
- ),
- extra=vol.ALLOW_EXTRA,
-)
diff --git a/homeassistant/components/lcn/services.py b/homeassistant/components/lcn/services.py
index 611a7353bcd..a6c42de0487 100644
--- a/homeassistant/components/lcn/services.py
+++ b/homeassistant/components/lcn/services.py
@@ -8,12 +8,21 @@ import voluptuous as vol
from homeassistant.const import (
CONF_ADDRESS,
CONF_BRIGHTNESS,
+ CONF_DEVICE_ID,
CONF_HOST,
CONF_STATE,
CONF_UNIT_OF_MEASUREMENT,
)
-from homeassistant.core import HomeAssistant, ServiceCall
+from homeassistant.core import (
+ HomeAssistant,
+ ServiceCall,
+ ServiceResponse,
+ SupportsResponse,
+)
+from homeassistant.exceptions import ServiceValidationError
+from homeassistant.helpers import device_registry as dr
import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from .const import (
CONF_KEYS,
@@ -30,6 +39,7 @@ from .const import (
CONF_TRANSITION,
CONF_VALUE,
CONF_VARIABLE,
+ DEVICE_CONNECTIONS,
DOMAIN,
LED_PORTS,
LED_STATUS,
@@ -53,7 +63,13 @@ from .helpers import (
class LcnServiceCall:
"""Parent class for all LCN service calls."""
- schema = vol.Schema({vol.Required(CONF_ADDRESS): is_address})
+ schema = vol.Schema(
+ {
+ vol.Optional(CONF_DEVICE_ID): cv.string,
+ vol.Optional(CONF_ADDRESS): is_address,
+ }
+ )
+ supports_response = SupportsResponse.NONE
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize service call."""
@@ -61,8 +77,37 @@ class LcnServiceCall:
def get_device_connection(self, service: ServiceCall) -> DeviceConnectionType:
"""Get address connection object."""
- address, host_name = service.data[CONF_ADDRESS]
+ if CONF_DEVICE_ID not in service.data and CONF_ADDRESS not in service.data:
+ raise ServiceValidationError(
+ translation_domain=DOMAIN,
+ translation_key="no_device_identifier",
+ )
+ if CONF_DEVICE_ID in service.data:
+ device_id = service.data[CONF_DEVICE_ID]
+ device_registry = dr.async_get(self.hass)
+ if not (device := device_registry.async_get(device_id)):
+ raise ServiceValidationError(
+ translation_domain=DOMAIN,
+ translation_key="invalid_device_id",
+ translation_placeholders={"device_id": device_id},
+ )
+
+ return self.hass.data[DOMAIN][device.primary_config_entry][
+ DEVICE_CONNECTIONS
+ ][device_id]
+
+ async_create_issue(
+ self.hass,
+ DOMAIN,
+ "deprecated_address_parameter",
+ breaks_in_ha_version="2025.6.0",
+ is_fixable=False,
+ severity=IssueSeverity.WARNING,
+ translation_key="deprecated_address_parameter",
+ )
+
+ address, host_name = service.data[CONF_ADDRESS]
for config_entry in self.hass.config_entries.async_entries(DOMAIN):
if config_entry.data[CONF_HOST] == host_name:
device_connection = get_device_connection(
@@ -73,7 +118,7 @@ class LcnServiceCall:
return device_connection
raise ValueError("Invalid host name.")
- async def async_call_service(self, service: ServiceCall) -> None:
+ async def async_call_service(self, service: ServiceCall) -> ServiceResponse:
"""Execute service call."""
raise NotImplementedError
@@ -429,3 +474,11 @@ SERVICES = (
(LcnService.DYN_TEXT, DynText),
(LcnService.PCK, Pck),
)
+
+
+async def register_services(hass: HomeAssistant) -> None:
+ """Register services for LCN."""
+ for service_name, service in SERVICES:
+ hass.services.async_register(
+ DOMAIN, service_name, service(hass).async_call_service, service.schema
+ )
diff --git a/homeassistant/components/lcn/services.yaml b/homeassistant/components/lcn/services.yaml
index d62a1e72d45..f58e79b9f40 100644
--- a/homeassistant/components/lcn/services.yaml
+++ b/homeassistant/components/lcn/services.yaml
@@ -2,8 +2,76 @@
output_abs:
fields:
+ device_id:
+ example: "91aa039a2fb6e0b9f9ec7eb219a6b7d2"
+ selector: &device_selector
+ device:
+ filter:
+ - integration: lcn
+ model: LCN group
+ - integration: lcn
+ model: UnknownModuleType
+ - integration: lcn
+ model: LCN-SW1.0
+ - integration: lcn
+ model: LCN-SW1.1
+ - integration: lcn
+ model: LCN-UP1.0
+ - integration: lcn
+ model: LCN-UP2
+ - integration: lcn
+ model: LCN-SW2
+ - integration: lcn
+ model: LCN-UP-Profi1-Plus
+ - integration: lcn
+ model: LCN-DI12
+ - integration: lcn
+ model: LCN-HU
+ - integration: lcn
+ model: LCN-SH
+ - integration: lcn
+ model: LCN-UP2
+ - integration: lcn
+ model: LCN-UPP
+ - integration: lcn
+ model: LCN-SK
+ - integration: lcn
+ model: LCN-LD
+ - integration: lcn
+ model: LCN-SH-Plus
+ - integration: lcn
+ model: LCN-UPS
+ - integration: lcn
+ model: LCN_UPS24V
+ - integration: lcn
+ model: LCN-GTM
+ - integration: lcn
+ model: LCN-SHS
+ - integration: lcn
+ model: LCN-ESD
+ - integration: lcn
+ model: LCN-EB2
+ - integration: lcn
+ model: LCN-MRS
+ - integration: lcn
+ model: LCN-EB11
+ - integration: lcn
+ model: LCN-UMR
+ - integration: lcn
+ model: LCN-UPU
+ - integration: lcn
+ model: LCN-UMR24V
+ - integration: lcn
+ model: LCN-SHD
+ - integration: lcn
+ model: LCN-SHU
+ - integration: lcn
+ model: LCN-SR6
+ - integration: lcn
+ model: LCN-UMF
+ - integration: lcn
+ model: LCN-WBH
address:
- required: true
example: "myhome.s0.m7"
selector:
text:
@@ -34,8 +102,10 @@ output_abs:
output_rel:
fields:
+ device_id:
+ example: "91aa039a2fb6e0b9f9ec7eb219a6b7d2"
+ selector: *device_selector
address:
- required: true
example: "myhome.s0.m7"
selector:
text:
@@ -58,8 +128,10 @@ output_rel:
output_toggle:
fields:
+ device_id:
+ example: "91aa039a2fb6e0b9f9ec7eb219a6b7d2"
+ selector: *device_selector
address:
- required: true
example: "myhome.s0.m7"
selector:
text:
@@ -83,8 +155,10 @@ output_toggle:
relays:
fields:
+ device_id:
+ example: "91aa039a2fb6e0b9f9ec7eb219a6b7d2"
+ selector: *device_selector
address:
- required: true
example: "myhome.s0.m7"
selector:
text:
@@ -96,8 +170,10 @@ relays:
led:
fields:
+ device_id:
+ example: "91aa039a2fb6e0b9f9ec7eb219a6b7d2"
+ selector: *device_selector
address:
- required: true
example: "myhome.s0.m7"
selector:
text:
@@ -130,8 +206,10 @@ led:
var_abs:
fields:
+ device_id:
+ example: "91aa039a2fb6e0b9f9ec7eb219a6b7d2"
+ selector: *device_selector
address:
- required: true
example: "myhome.s0.m7"
selector:
text:
@@ -197,8 +275,10 @@ var_abs:
var_reset:
fields:
+ device_id:
+ example: "91aa039a2fb6e0b9f9ec7eb219a6b7d2"
+ selector: *device_selector
address:
- required: true
example: "myhome.s0.m7"
selector:
text:
@@ -230,8 +310,10 @@ var_reset:
var_rel:
fields:
+ device_id:
+ example: "91aa039a2fb6e0b9f9ec7eb219a6b7d2"
+ selector: *device_selector
address:
- required: true
example: "myhome.s0.m7"
selector:
text:
@@ -321,8 +403,10 @@ var_rel:
lock_regulator:
fields:
+ device_id:
+ example: "91aa039a2fb6e0b9f9ec7eb219a6b7d2"
+ selector: *device_selector
address:
- required: true
example: "myhome.s0.m7"
selector:
text:
@@ -355,8 +439,10 @@ lock_regulator:
send_keys:
fields:
+ device_id:
+ example: "91aa039a2fb6e0b9f9ec7eb219a6b7d2"
+ selector: *device_selector
address:
- required: true
example: "myhome.s0.m7"
selector:
text:
@@ -402,8 +488,10 @@ send_keys:
lock_keys:
fields:
+ device_id:
+ example: "91aa039a2fb6e0b9f9ec7eb219a6b7d2"
+ selector: *device_selector
address:
- required: true
example: "myhome.s0.m7"
selector:
text:
@@ -445,8 +533,10 @@ lock_keys:
dyn_text:
fields:
+ device_id:
+ example: "91aa039a2fb6e0b9f9ec7eb219a6b7d2"
+ selector: *device_selector
address:
- required: true
example: "myhome.s0.m7"
selector:
text:
@@ -464,8 +554,10 @@ dyn_text:
pck:
fields:
+ device_id:
+ example: "91aa039a2fb6e0b9f9ec7eb219a6b7d2"
+ selector: *device_selector
address:
- required: true
example: "myhome.s0.m7"
selector:
text:
diff --git a/homeassistant/components/lcn/strings.json b/homeassistant/components/lcn/strings.json
index ae0b1b01f9a..47696719b73 100644
--- a/homeassistant/components/lcn/strings.json
+++ b/homeassistant/components/lcn/strings.json
@@ -63,18 +63,6 @@
}
},
"issues": {
- "authentication_error": {
- "title": "Authentication failed.",
- "description": "Configuring LCN using YAML is being removed but there was an error importing your YAML configuration.\n\nEnsure username and password are correct.\n\nConsider removing the LCN YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually."
- },
- "license_error": {
- "title": "Maximum number of connections was reached.",
- "description": "Configuring LCN using YAML is being removed but there was an error importing your YAML configuration.\n\nEnsure sufficient PCHK licenses are registered and restart Home Assistant.\n\nConsider removing the LCN YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually."
- },
- "connection_refused": {
- "title": "Unable to connect to PCHK.",
- "description": "Configuring LCN using YAML is being removed but there was an error importing your YAML configuration.\n\nEnsure the connection (IP and port) to the LCN bus coupler is correct.\n\nConsider removing the LCN YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually."
- },
"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."
@@ -82,6 +70,10 @@
"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."
+ },
+ "deprecated_address_parameter": {
+ "title": "Deprecated 'address' parameter",
+ "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": {
@@ -89,6 +81,10 @@
"name": "Output absolute brightness",
"description": "Sets absolute brightness of output port in percent.",
"fields": {
+ "device_id": {
+ "name": "[%key:common::config_flow::data::device%]",
+ "description": "The device_id of the LCN module or group."
+ },
"address": {
"name": "Address",
"description": "Module address."
@@ -111,6 +107,10 @@
"name": "Output relative brightness",
"description": "Sets relative brightness of output port in percent.",
"fields": {
+ "device_id": {
+ "name": "[%key:common::config_flow::data::device%]",
+ "description": "[%key:component::lcn::services::output_abs::fields::device_id::description%]"
+ },
"address": {
"name": "Address",
"description": "[%key:component::lcn::services::output_abs::fields::address::description%]"
@@ -129,6 +129,10 @@
"name": "Toggle output",
"description": "Toggles output port.",
"fields": {
+ "device_id": {
+ "name": "[%key:common::config_flow::data::device%]",
+ "description": "[%key:component::lcn::services::output_abs::fields::device_id::description%]"
+ },
"address": {
"name": "Address",
"description": "[%key:component::lcn::services::output_abs::fields::address::description%]"
@@ -147,6 +151,10 @@
"name": "Relays",
"description": "Sets the relays status.",
"fields": {
+ "device_id": {
+ "name": "[%key:common::config_flow::data::device%]",
+ "description": "[%key:component::lcn::services::output_abs::fields::device_id::description%]"
+ },
"address": {
"name": "Address",
"description": "[%key:component::lcn::services::output_abs::fields::address::description%]"
@@ -159,19 +167,23 @@
},
"led": {
"name": "LED",
- "description": "Sets the led state.",
+ "description": "Sets the LED state.",
"fields": {
+ "device_id": {
+ "name": "[%key:common::config_flow::data::device%]",
+ "description": "[%key:component::lcn::services::output_abs::fields::device_id::description%]"
+ },
"address": {
"name": "Address",
"description": "[%key:component::lcn::services::output_abs::fields::address::description%]"
},
"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."
}
}
},
@@ -179,6 +191,10 @@
"name": "Set absolute variable",
"description": "Sets absolute value of a variable or setpoint.",
"fields": {
+ "device_id": {
+ "name": "[%key:common::config_flow::data::device%]",
+ "description": "[%key:component::lcn::services::output_abs::fields::device_id::description%]"
+ },
"address": {
"name": "Address",
"description": "[%key:component::lcn::services::output_abs::fields::address::description%]"
@@ -201,6 +217,10 @@
"name": "Reset variable",
"description": "Resets value of variable or setpoint.",
"fields": {
+ "device_id": {
+ "name": "[%key:common::config_flow::data::device%]",
+ "description": "[%key:component::lcn::services::output_abs::fields::device_id::description%]"
+ },
"address": {
"name": "Address",
"description": "[%key:component::lcn::services::output_abs::fields::address::description%]"
@@ -215,6 +235,10 @@
"name": "Shift variable",
"description": "Shift value of a variable, setpoint or threshold.",
"fields": {
+ "device_id": {
+ "name": "[%key:common::config_flow::data::device%]",
+ "description": "[%key:component::lcn::services::output_abs::fields::device_id::description%]"
+ },
"address": {
"name": "Address",
"description": "[%key:component::lcn::services::output_abs::fields::address::description%]"
@@ -241,6 +265,10 @@
"name": "Lock regulator",
"description": "Locks a regulator setpoint.",
"fields": {
+ "device_id": {
+ "name": "[%key:common::config_flow::data::device%]",
+ "description": "[%key:component::lcn::services::output_abs::fields::device_id::description%]"
+ },
"address": {
"name": "Address",
"description": "[%key:component::lcn::services::output_abs::fields::address::description%]"
@@ -259,6 +287,10 @@
"name": "Send keys",
"description": "Sends keys (which executes bound commands).",
"fields": {
+ "device_id": {
+ "name": "[%key:common::config_flow::data::device%]",
+ "description": "[%key:component::lcn::services::output_abs::fields::device_id::description%]"
+ },
"address": {
"name": "Address",
"description": "[%key:component::lcn::services::output_abs::fields::address::description%]"
@@ -285,6 +317,10 @@
"name": "Lock keys",
"description": "Locks keys.",
"fields": {
+ "device_id": {
+ "name": "[%key:common::config_flow::data::device%]",
+ "description": "[%key:component::lcn::services::output_abs::fields::device_id::description%]"
+ },
"address": {
"name": "Address",
"description": "[%key:component::lcn::services::output_abs::fields::address::description%]"
@@ -311,6 +347,10 @@
"name": "Dynamic text",
"description": "Sends dynamic text to LCN-GTxD displays.",
"fields": {
+ "device_id": {
+ "name": "[%key:common::config_flow::data::device%]",
+ "description": "[%key:component::lcn::services::output_abs::fields::device_id::description%]"
+ },
"address": {
"name": "Address",
"description": "[%key:component::lcn::services::output_abs::fields::address::description%]"
@@ -329,6 +369,10 @@
"name": "PCK",
"description": "Sends arbitrary PCK command.",
"fields": {
+ "device_id": {
+ "name": "[%key:common::config_flow::data::device%]",
+ "description": "[%key:component::lcn::services::output_abs::fields::device_id::description%]"
+ },
"address": {
"name": "Address",
"description": "[%key:component::lcn::services::output_abs::fields::address::description%]"
@@ -338,6 +382,39 @@
"description": "PCK command (without address header)."
}
}
+ },
+ "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."
+ },
+ "segment_id": {
+ "name": "Segment ID",
+ "description": "Target segment ID."
+ },
+ "type": {
+ "name": "Type",
+ "description": "Target type."
+ },
+ "host": {
+ "name": "Host name",
+ "description": "Host name as given in the integration panel."
+ }
+ }
+ }
+ },
+ "exceptions": {
+ "no_device_identifier": {
+ "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."
}
}
}
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 1d12e355a0d..5aefad4d429 100644
--- a/homeassistant/components/led_ble/manifest.json
+++ b/homeassistant/components/led_ble/manifest.json
@@ -28,6 +28,9 @@
},
{
"local_name": "MELK-*"
+ },
+ {
+ "local_name": "LD-0003"
}
],
"codeowners": ["@bdraco"],
@@ -35,5 +38,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.0.2"]
+ "requirements": ["bluetooth-data-tools==1.20.0", "led-ble==1.1.1"]
}
diff --git a/homeassistant/components/lg_netcast/strings.json b/homeassistant/components/lg_netcast/strings.json
index 209c3837261..0377d4bf318 100644
--- a/homeassistant/components/lg_netcast/strings.json
+++ b/homeassistant/components/lg_netcast/strings.json
@@ -25,7 +25,8 @@
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
- "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
+ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
+ "invalid_host": "[%key:common::config_flow::error::invalid_host%]"
}
},
"device_automation": {
diff --git a/homeassistant/components/lg_thinq/__init__.py b/homeassistant/components/lg_thinq/__init__.py
index a8d3fe175ef..657524f0ef5 100644
--- a/homeassistant/components/lg_thinq/__init__.py
+++ b/homeassistant/components/lg_thinq/__init__.py
@@ -95,6 +95,7 @@ async def async_setup_coordinators(
raise ConfigEntryNotReady(exc.message) from exc
if not bridge_list:
+ _LOGGER.warning("No devices registered with the correct profile")
return
# Setup coordinator per device.
diff --git a/homeassistant/components/lg_thinq/climate.py b/homeassistant/components/lg_thinq/climate.py
index 9ead57ab7b0..5cf9ccbd442 100644
--- a/homeassistant/components/lg_thinq/climate.py
+++ b/homeassistant/components/lg_thinq/climate.py
@@ -12,7 +12,6 @@ from thinqconnect.integration import ExtendedProperty
from homeassistant.components.climate import (
ATTR_TARGET_TEMP_HIGH,
ATTR_TARGET_TEMP_LOW,
- FAN_OFF,
ClimateEntity,
ClimateEntityDescription,
ClimateEntityFeature,
@@ -37,7 +36,7 @@ class ThinQClimateEntityDescription(ClimateEntityDescription):
step: float | None = None
-DEVIE_TYPE_CLIMATE_MAP: dict[DeviceType, tuple[ThinQClimateEntityDescription, ...]] = {
+DEVICE_TYPE_CLIMATE_MAP: dict[DeviceType, tuple[ThinQClimateEntityDescription, ...]] = {
DeviceType.AIR_CONDITIONER: (
ThinQClimateEntityDescription(
key=ExtendedProperty.CLIMATE_AIR_CONDITIONER,
@@ -86,7 +85,7 @@ async def async_setup_entry(
entities: list[ThinQClimateEntity] = []
for coordinator in entry.runtime_data.coordinators.values():
if (
- descriptions := DEVIE_TYPE_CLIMATE_MAP.get(
+ descriptions := DEVICE_TYPE_CLIMATE_MAP.get(
coordinator.api.device.device_type
)
) is not None:
@@ -149,10 +148,9 @@ class ThinQClimateEntity(ThinQEntity, ClimateEntity):
super()._update_status()
# Update fan, hvac and preset mode.
+ if self.supported_features & ClimateEntityFeature.FAN_MODE:
+ self._attr_fan_mode = self.data.fan_mode
if self.data.is_on:
- if self.supported_features & ClimateEntityFeature.FAN_MODE:
- self._attr_fan_mode = self.data.fan_mode
-
hvac_mode = self._requested_hvac_mode or self.data.hvac_mode
if hvac_mode in STR_TO_HVAC:
self._attr_hvac_mode = STR_TO_HVAC.get(hvac_mode)
@@ -160,9 +158,6 @@ class ThinQClimateEntity(ThinQEntity, ClimateEntity):
elif hvac_mode in THINQ_PRESET_MODE:
self._attr_preset_mode = hvac_mode
else:
- if self.supported_features & ClimateEntityFeature.FAN_MODE:
- self._attr_fan_mode = FAN_OFF
-
self._attr_hvac_mode = HVACMode.OFF
self._attr_preset_mode = None
@@ -170,6 +165,7 @@ class ThinQClimateEntity(ThinQEntity, ClimateEntity):
self._attr_current_humidity = self.data.humidity
self._attr_current_temperature = self.data.current_temp
+ # Update min, max and step.
if (max_temp := self.entity_description.max_temp) is not None or (
max_temp := self.data.max
) is not None:
@@ -184,26 +180,18 @@ class ThinQClimateEntity(ThinQEntity, ClimateEntity):
self._attr_target_temperature_step = step
# Update target temperatures.
- if (
- self.supported_features & ClimateEntityFeature.TARGET_TEMPERATURE_RANGE
- and self.hvac_mode == HVACMode.AUTO
- ):
- self._attr_target_temperature = None
- self._attr_target_temperature_high = self.data.target_temp_high
- self._attr_target_temperature_low = self.data.target_temp_low
- else:
- self._attr_target_temperature = self.data.target_temp
- self._attr_target_temperature_high = None
- self._attr_target_temperature_low = None
+ self._attr_target_temperature = self.data.target_temp
+ self._attr_target_temperature_high = self.data.target_temp_high
+ self._attr_target_temperature_low = self.data.target_temp_low
_LOGGER.debug(
- "[%s:%s] update status: %s/%s -> %s/%s, hvac:%s, unit:%s, step:%s",
+ "[%s:%s] update status: c:%s, t:%s, l:%s, h:%s, hvac:%s, unit:%s, step:%s",
self.coordinator.device_name,
self.property_id,
- self.data.current_temp,
- self.data.target_temp,
self.current_temperature,
self.target_temperature,
+ self.target_temperature_low,
+ self.target_temperature_high,
self.hvac_mode,
self.temperature_unit,
self.target_temperature_step,
diff --git a/homeassistant/components/lg_thinq/config_flow.py b/homeassistant/components/lg_thinq/config_flow.py
index cdb41916688..3bbcf3cd226 100644
--- a/homeassistant/components/lg_thinq/config_flow.py
+++ b/homeassistant/components/lg_thinq/config_flow.py
@@ -6,7 +6,7 @@ import logging
from typing import Any
import uuid
-from thinqconnect import ThinQApi, ThinQAPIException
+from thinqconnect import ThinQApi, ThinQAPIErrorCodes, ThinQAPIException
from thinqconnect.country import Country
import voluptuous as vol
@@ -26,6 +26,13 @@ from .const import (
)
SUPPORTED_COUNTRIES = [country.value for country in Country]
+THINQ_ERRORS = {
+ ThinQAPIErrorCodes.INVALID_TOKEN: "invalid_token",
+ ThinQAPIErrorCodes.NOT_ACCEPTABLE_TERMS: "not_acceptable_terms",
+ ThinQAPIErrorCodes.NOT_ALLOWED_API_AGAIN: "not_allowed_api_again",
+ ThinQAPIErrorCodes.NOT_SUPPORTED_COUNTRY: "not_supported_country",
+ ThinQAPIErrorCodes.EXCEEDED_API_CALLS: "exceeded_api_calls",
+}
_LOGGER = logging.getLogger(__name__)
@@ -83,8 +90,9 @@ class ThinQFlowHandler(ConfigFlow, domain=DOMAIN):
try:
return await self._validate_and_create_entry(access_token, country_code)
- except ThinQAPIException:
- errors["base"] = "token_unauthorized"
+ except ThinQAPIException as exc:
+ errors["base"] = THINQ_ERRORS.get(exc.code, "token_unauthorized")
+ _LOGGER.error("Failed to validate access_token %s", exc)
return self.async_show_form(
step_id="user",
diff --git a/homeassistant/components/lg_thinq/coordinator.py b/homeassistant/components/lg_thinq/coordinator.py
index 0ba859b1228..9f317dc21d9 100644
--- a/homeassistant/components/lg_thinq/coordinator.py
+++ b/homeassistant/components/lg_thinq/coordinator.py
@@ -77,5 +77,9 @@ async def async_setup_device_coordinator(
coordinator = DeviceDataUpdateCoordinator(hass, ha_bridge)
await coordinator.async_refresh()
- _LOGGER.debug("Setup device's coordinator: %s", coordinator.device_name)
+ _LOGGER.debug(
+ "Setup device's coordinator: %s, model:%s",
+ coordinator.device_name,
+ coordinator.api.device.model_name,
+ )
return coordinator
diff --git a/homeassistant/components/lg_thinq/entity.py b/homeassistant/components/lg_thinq/entity.py
index f31b535dcaf..7856506559b 100644
--- a/homeassistant/components/lg_thinq/entity.py
+++ b/homeassistant/components/lg_thinq/entity.py
@@ -51,7 +51,7 @@ class ThinQEntity(CoordinatorEntity[DeviceDataUpdateCoordinator]):
self._attr_device_info = dr.DeviceInfo(
identifiers={(DOMAIN, coordinator.unique_id)},
manufacturer=COMPANY,
- model=coordinator.api.device.model_name,
+ model=f"{coordinator.api.device.model_name} ({self.coordinator.api.device.device_type})",
name=coordinator.device_name,
)
self._attr_unique_id = f"{coordinator.unique_id}_{self.property_id}"
diff --git a/homeassistant/components/lg_thinq/fan.py b/homeassistant/components/lg_thinq/fan.py
index 187cc74b3eb..edcadf2598a 100644
--- a/homeassistant/components/lg_thinq/fan.py
+++ b/homeassistant/components/lg_thinq/fan.py
@@ -72,8 +72,11 @@ class ThinQFanEntity(ThinQEntity, FanEntity):
super().__init__(coordinator, entity_description, property_id)
self._ordered_named_fan_speeds = []
- self._attr_supported_features |= FanEntityFeature.SET_SPEED
-
+ self._attr_supported_features = (
+ FanEntityFeature.SET_SPEED
+ | FanEntityFeature.TURN_ON
+ | FanEntityFeature.TURN_OFF
+ )
if (fan_modes := self.data.fan_modes) is not None:
self._attr_speed_count = len(fan_modes)
if self.speed_count == 4:
@@ -98,7 +101,7 @@ class ThinQFanEntity(ThinQEntity, FanEntity):
self._attr_percentage = 0
_LOGGER.debug(
- "[%s:%s] update status: %s -> %s (percntage=%s)",
+ "[%s:%s] update status: %s -> %s (percentage=%s)",
self.coordinator.device_name,
self.property_id,
self.data.is_on,
@@ -120,7 +123,7 @@ class ThinQFanEntity(ThinQEntity, FanEntity):
return
_LOGGER.debug(
- "[%s:%s] async_set_percentage. percntage=%s, value=%s",
+ "[%s:%s] async_set_percentage. percentage=%s, value=%s",
self.coordinator.device_name,
self.property_id,
percentage,
diff --git a/homeassistant/components/lg_thinq/manifest.json b/homeassistant/components/lg_thinq/manifest.json
index 665a5a9e179..6dd60909c66 100644
--- a/homeassistant/components/lg_thinq/manifest.json
+++ b/homeassistant/components/lg_thinq/manifest.json
@@ -3,9 +3,8 @@
"name": "LG ThinQ",
"codeowners": ["@LG-ThinQ-Integration"],
"config_flow": true,
- "dependencies": [],
- "documentation": "https://www.home-assistant.io/integrations/lg_thinq/",
+ "documentation": "https://www.home-assistant.io/integrations/lg_thinq",
"iot_class": "cloud_push",
"loggers": ["thinqconnect"],
- "requirements": ["thinqconnect==1.0.0"]
+ "requirements": ["thinqconnect==1.0.2"]
}
diff --git a/homeassistant/components/lg_thinq/mqtt.py b/homeassistant/components/lg_thinq/mqtt.py
index 30d1302e458..8759869aad3 100644
--- a/homeassistant/components/lg_thinq/mqtt.py
+++ b/homeassistant/components/lg_thinq/mqtt.py
@@ -167,7 +167,6 @@ class ThinQMQTT:
async def async_handle_device_event(self, message: dict) -> None:
"""Handle received mqtt message."""
- _LOGGER.debug("async_handle_device_event: message=%s", message)
unique_id = (
f"{message["deviceId"]}_{list(message["report"].keys())[0]}"
if message["deviceType"] == DeviceType.WASHTOWER
@@ -178,6 +177,12 @@ class ThinQMQTT:
_LOGGER.error("Failed to handle device event: No device")
return
+ _LOGGER.debug(
+ "async_handle_device_event: %s, model:%s, message=%s",
+ coordinator.device_name,
+ coordinator.api.device.model_name,
+ message,
+ )
push_type = message.get("pushType")
if push_type == DEVICE_STATUS_MESSAGE:
diff --git a/homeassistant/components/lg_thinq/strings.json b/homeassistant/components/lg_thinq/strings.json
index 277e3db3df0..a776dde2054 100644
--- a/homeassistant/components/lg_thinq/strings.json
+++ b/homeassistant/components/lg_thinq/strings.json
@@ -5,6 +5,12 @@
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]"
},
"error": {
+ "invalid_token": "The token is not valid.",
+ "not_acceptable_terms": "The service terms are not accepted.",
+ "not_allowed_api_again": "The user does NOT have permission on the API call.",
+ "not_supported_country": "The country is not supported.",
+ "exceeded_api_calls": "The number of API calls has been exceeded.",
+ "exceeded_user_api_calls": "The number of User API calls has been exceeded.",
"token_unauthorized": "The token is invalid or unauthorized."
},
"step": {
diff --git a/homeassistant/components/lg_thinq/vacuum.py b/homeassistant/components/lg_thinq/vacuum.py
index 138b9ba55bf..6cbb731869c 100644
--- a/homeassistant/components/lg_thinq/vacuum.py
+++ b/homeassistant/components/lg_thinq/vacuum.py
@@ -9,15 +9,11 @@ from thinqconnect import DeviceType
from thinqconnect.integration import ExtendedProperty
from homeassistant.components.vacuum import (
- STATE_CLEANING,
- STATE_DOCKED,
- STATE_ERROR,
- STATE_RETURNING,
StateVacuumEntity,
StateVacuumEntityDescription,
+ VacuumActivity,
VacuumEntityFeature,
)
-from homeassistant.const import STATE_IDLE, STATE_PAUSED
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
@@ -46,21 +42,21 @@ class State(StrEnum):
ROBOT_STATUS_TO_HA = {
- "charging": STATE_DOCKED,
- "diagnosis": STATE_IDLE,
- "homing": STATE_RETURNING,
- "initializing": STATE_IDLE,
- "macrosector": STATE_IDLE,
- "monitoring_detecting": STATE_IDLE,
- "monitoring_moving": STATE_IDLE,
- "monitoring_positioning": STATE_IDLE,
- "pause": STATE_PAUSED,
- "reservation": STATE_IDLE,
- "setdate": STATE_IDLE,
- "sleep": STATE_IDLE,
- "standby": STATE_IDLE,
- "working": STATE_CLEANING,
- "error": STATE_ERROR,
+ "charging": VacuumActivity.DOCKED,
+ "diagnosis": VacuumActivity.IDLE,
+ "homing": VacuumActivity.RETURNING,
+ "initializing": VacuumActivity.IDLE,
+ "macrosector": VacuumActivity.IDLE,
+ "monitoring_detecting": VacuumActivity.IDLE,
+ "monitoring_moving": VacuumActivity.IDLE,
+ "monitoring_positioning": VacuumActivity.IDLE,
+ "pause": VacuumActivity.PAUSED,
+ "reservation": VacuumActivity.IDLE,
+ "setdate": VacuumActivity.IDLE,
+ "sleep": VacuumActivity.IDLE,
+ "standby": VacuumActivity.IDLE,
+ "working": VacuumActivity.CLEANING,
+ "error": VacuumActivity.ERROR,
}
ROBOT_BATT_TO_HA = {
"moveless": 5,
@@ -114,7 +110,7 @@ class ThinQStateVacuumEntity(ThinQEntity, StateVacuumEntity):
super()._update_status()
# Update state.
- self._attr_state = ROBOT_STATUS_TO_HA[self.data.current_state]
+ self._attr_activity = ROBOT_STATUS_TO_HA[self.data.current_state]
# Update battery.
if (level := self.data.battery) is not None:
@@ -135,7 +131,7 @@ class ThinQStateVacuumEntity(ThinQEntity, StateVacuumEntity):
"""Start the device."""
if self.data.current_state == State.SLEEP:
value = State.WAKE_UP
- elif self._attr_state == STATE_PAUSED:
+ elif self._attr_activity == VacuumActivity.PAUSED:
value = State.RESUME
else:
value = State.START
diff --git a/homeassistant/components/lifx/const.py b/homeassistant/components/lifx/const.py
index 9b213cc9f6d..667afe1125d 100644
--- a/homeassistant/components/lifx/const.py
+++ b/homeassistant/components/lifx/const.py
@@ -64,3 +64,6 @@ DATA_LIFX_MANAGER = "lifx_manager"
LIFX_CEILING_PRODUCT_IDS = {176, 177}
_LOGGER = logging.getLogger(__package__)
+
+# _ATTR_COLOR_TEMP deprecated - to be removed in 2026.1
+_ATTR_COLOR_TEMP = "color_temp"
diff --git a/homeassistant/components/lifx/manager.py b/homeassistant/components/lifx/manager.py
index 759d08707cd..27e62717e96 100644
--- a/homeassistant/components/lifx/manager.py
+++ b/homeassistant/components/lifx/manager.py
@@ -15,7 +15,6 @@ from homeassistant.components.light import (
ATTR_BRIGHTNESS,
ATTR_BRIGHTNESS_PCT,
ATTR_COLOR_NAME,
- ATTR_COLOR_TEMP,
ATTR_COLOR_TEMP_KELVIN,
ATTR_HS_COLOR,
ATTR_RGB_COLOR,
@@ -30,7 +29,7 @@ from homeassistant.core import HomeAssistant, ServiceCall, callback
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.service import async_extract_referenced_entity_ids
-from .const import ATTR_THEME, DATA_LIFX_MANAGER, DOMAIN
+from .const import _ATTR_COLOR_TEMP, ATTR_THEME, DATA_LIFX_MANAGER, DOMAIN
from .coordinator import LIFXUpdateCoordinator, Light
from .util import convert_8_to_16, find_hsbk
@@ -126,7 +125,8 @@ LIFX_EFFECT_PULSE_SCHEMA = cv.make_entity_service_schema(
vol.Exclusive(ATTR_COLOR_TEMP_KELVIN, COLOR_GROUP): vol.All(
vol.Coerce(int), vol.Range(min=1500, max=9000)
),
- vol.Exclusive(ATTR_COLOR_TEMP, COLOR_GROUP): cv.positive_int,
+ # _ATTR_COLOR_TEMP deprecated - to be removed in 2026.1
+ vol.Exclusive(_ATTR_COLOR_TEMP, COLOR_GROUP): cv.positive_int,
ATTR_PERIOD: vol.All(vol.Coerce(float), vol.Range(min=0.05)),
ATTR_CYCLES: vol.All(vol.Coerce(float), vol.Range(min=1)),
ATTR_MODE: vol.In(PULSE_MODES),
diff --git a/homeassistant/components/lifx/manifest.json b/homeassistant/components/lifx/manifest.json
index c7d8a27a1c7..9940ee15dca 100644
--- a/homeassistant/components/lifx/manifest.json
+++ b/homeassistant/components/lifx/manifest.json
@@ -23,6 +23,7 @@
"LIFX Ceiling",
"LIFX Clean",
"LIFX Color",
+ "LIFX Colour",
"LIFX DLCOL",
"LIFX Dlight",
"LIFX DLWW",
@@ -35,12 +36,14 @@
"LIFX Neon",
"LIFX Nightvision",
"LIFX PAR38",
+ "LIFX Permanent Outdoor",
"LIFX Pls",
"LIFX Plus",
"LIFX Round",
"LIFX Square",
"LIFX String",
"LIFX Tile",
+ "LIFX Tube",
"LIFX White",
"LIFX Z"
]
@@ -48,8 +51,8 @@
"iot_class": "local_polling",
"loggers": ["aiolifx", "aiolifx_effects", "bitstring"],
"requirements": [
- "aiolifx==1.1.1",
+ "aiolifx==1.1.2",
"aiolifx-effects==0.3.2",
- "aiolifx-themes==0.5.5"
+ "aiolifx-themes==0.6.0"
]
}
diff --git a/homeassistant/components/lifx/util.py b/homeassistant/components/lifx/util.py
index 9782fe4adba..ffffe7a4856 100644
--- a/homeassistant/components/lifx/util.py
+++ b/homeassistant/components/lifx/util.py
@@ -16,10 +16,8 @@ from homeassistant.components.light import (
ATTR_BRIGHTNESS,
ATTR_BRIGHTNESS_PCT,
ATTR_COLOR_NAME,
- ATTR_COLOR_TEMP,
ATTR_COLOR_TEMP_KELVIN,
ATTR_HS_COLOR,
- ATTR_KELVIN,
ATTR_RGB_COLOR,
ATTR_XY_COLOR,
)
@@ -29,6 +27,7 @@ from homeassistant.helpers import device_registry as dr
import homeassistant.util.color as color_util
from .const import (
+ _ATTR_COLOR_TEMP,
_LOGGER,
DEFAULT_ATTEMPTS,
DOMAIN,
@@ -114,17 +113,14 @@ def find_hsbk(hass: HomeAssistant, **kwargs: Any) -> list[float | int | None] |
saturation = int(saturation / 100 * 65535)
kelvin = 3500
- if ATTR_KELVIN in kwargs:
+ if _ATTR_COLOR_TEMP in kwargs:
+ # added in 2025.1, can be removed in 2026.1
_LOGGER.warning(
- "The 'kelvin' parameter is deprecated. Please use 'color_temp_kelvin' for"
+ "The 'color_temp' parameter is deprecated. Please use 'color_temp_kelvin' for"
" all service calls"
)
- kelvin = kwargs.pop(ATTR_KELVIN)
- saturation = 0
-
- if ATTR_COLOR_TEMP in kwargs:
kelvin = color_util.color_temperature_mired_to_kelvin(
- kwargs.pop(ATTR_COLOR_TEMP)
+ kwargs.pop(_ATTR_COLOR_TEMP)
)
saturation = 0
diff --git a/homeassistant/components/lifx_cloud/manifest.json b/homeassistant/components/lifx_cloud/manifest.json
index 7799de85b8d..61e5d66c821 100644
--- a/homeassistant/components/lifx_cloud/manifest.json
+++ b/homeassistant/components/lifx_cloud/manifest.json
@@ -3,5 +3,6 @@
"name": "LIFX Cloud",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/lifx_cloud",
- "iot_class": "cloud_push"
+ "iot_class": "cloud_push",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/light/__init__.py b/homeassistant/components/light/__init__.py
index 37ee6fe88fd..76fbea70322 100644
--- a/homeassistant/components/light/__init__.py
+++ b/homeassistant/components/light/__init__.py
@@ -5,11 +5,10 @@ from __future__ import annotations
from collections.abc import Iterable
import csv
import dataclasses
-from datetime import timedelta
-from enum import IntFlag, StrEnum
+from functools import partial
import logging
import os
-from typing import Any, Self, cast, final
+from typing import Any, Final, Self, cast, final
from propcache import cached_property
import voluptuous as vol
@@ -24,97 +23,84 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv, entity_registry as er
+from homeassistant.helpers.deprecation import (
+ DeprecatedConstant,
+ DeprecatedConstantEnum,
+ all_with_deprecated_constants,
+ check_if_deprecated_constant,
+ dir_with_deprecated_constants,
+)
from homeassistant.helpers.entity import ToggleEntity, ToggleEntityDescription
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.hass_dict import HassKey
-DOMAIN = "light"
-DATA_COMPONENT: HassKey[EntityComponent[LightEntity]] = HassKey(DOMAIN)
+from .const import ( # noqa: F401
+ COLOR_MODES_BRIGHTNESS,
+ COLOR_MODES_COLOR,
+ DATA_COMPONENT,
+ DATA_PROFILES,
+ DEFAULT_MAX_KELVIN,
+ DEFAULT_MIN_KELVIN,
+ DOMAIN,
+ SCAN_INTERVAL,
+ VALID_COLOR_MODES,
+ ColorMode,
+ LightEntityFeature,
+)
+
ENTITY_ID_FORMAT = DOMAIN + ".{}"
PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA
PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE
-SCAN_INTERVAL = timedelta(seconds=30)
-
-DATA_PROFILES: HassKey[Profiles] = HassKey(f"{DOMAIN}_profiles")
-
-
-class LightEntityFeature(IntFlag):
- """Supported features of the light entity."""
-
- EFFECT = 4
- FLASH = 8
- TRANSITION = 32
# These SUPPORT_* constants are deprecated as of Home Assistant 2022.5.
# Please use the LightEntityFeature enum instead.
-SUPPORT_BRIGHTNESS = 1 # Deprecated, replaced by color modes
-SUPPORT_COLOR_TEMP = 2 # Deprecated, replaced by color modes
-SUPPORT_EFFECT = 4
-SUPPORT_FLASH = 8
-SUPPORT_COLOR = 16 # Deprecated, replaced by color modes
-SUPPORT_TRANSITION = 32
+_DEPRECATED_SUPPORT_BRIGHTNESS: Final = DeprecatedConstant(
+ 1, "supported_color_modes", "2026.1"
+) # Deprecated, replaced by color modes
+_DEPRECATED_SUPPORT_COLOR_TEMP: Final = DeprecatedConstant(
+ 2, "supported_color_modes", "2026.1"
+) # Deprecated, replaced by color modes
+_DEPRECATED_SUPPORT_EFFECT: Final = DeprecatedConstantEnum(
+ LightEntityFeature.EFFECT, "2026.1"
+)
+_DEPRECATED_SUPPORT_FLASH: Final = DeprecatedConstantEnum(
+ LightEntityFeature.FLASH, "2026.1"
+)
+_DEPRECATED_SUPPORT_COLOR: Final = DeprecatedConstant(
+ 16, "supported_color_modes", "2026.1"
+) # Deprecated, replaced by color modes
+_DEPRECATED_SUPPORT_TRANSITION: Final = DeprecatedConstantEnum(
+ LightEntityFeature.TRANSITION, "2026.1"
+)
# Color mode of the light
ATTR_COLOR_MODE = "color_mode"
# List of color modes supported by the light
ATTR_SUPPORTED_COLOR_MODES = "supported_color_modes"
-
-class ColorMode(StrEnum):
- """Possible light color modes."""
-
- UNKNOWN = "unknown"
- """Ambiguous color mode"""
- ONOFF = "onoff"
- """Must be the only supported mode"""
- BRIGHTNESS = "brightness"
- """Must be the only supported mode"""
- COLOR_TEMP = "color_temp"
- HS = "hs"
- XY = "xy"
- RGB = "rgb"
- RGBW = "rgbw"
- RGBWW = "rgbww"
- WHITE = "white"
- """Must *NOT* be the only supported mode"""
-
-
# These COLOR_MODE_* constants are deprecated as of Home Assistant 2022.5.
# Please use the LightEntityFeature enum instead.
-COLOR_MODE_UNKNOWN = "unknown"
-COLOR_MODE_ONOFF = "onoff"
-COLOR_MODE_BRIGHTNESS = "brightness"
-COLOR_MODE_COLOR_TEMP = "color_temp"
-COLOR_MODE_HS = "hs"
-COLOR_MODE_XY = "xy"
-COLOR_MODE_RGB = "rgb"
-COLOR_MODE_RGBW = "rgbw"
-COLOR_MODE_RGBWW = "rgbww"
-COLOR_MODE_WHITE = "white"
+_DEPRECATED_COLOR_MODE_UNKNOWN: Final = DeprecatedConstantEnum(
+ ColorMode.UNKNOWN, "2026.1"
+)
+_DEPRECATED_COLOR_MODE_ONOFF: Final = DeprecatedConstantEnum(ColorMode.ONOFF, "2026.1")
+_DEPRECATED_COLOR_MODE_BRIGHTNESS: Final = DeprecatedConstantEnum(
+ ColorMode.BRIGHTNESS, "2026.1"
+)
+_DEPRECATED_COLOR_MODE_COLOR_TEMP: Final = DeprecatedConstantEnum(
+ ColorMode.COLOR_TEMP, "2026.1"
+)
+_DEPRECATED_COLOR_MODE_HS: Final = DeprecatedConstantEnum(ColorMode.HS, "2026.1")
+_DEPRECATED_COLOR_MODE_XY: Final = DeprecatedConstantEnum(ColorMode.XY, "2026.1")
+_DEPRECATED_COLOR_MODE_RGB: Final = DeprecatedConstantEnum(ColorMode.RGB, "2026.1")
+_DEPRECATED_COLOR_MODE_RGBW: Final = DeprecatedConstantEnum(ColorMode.RGBW, "2026.1")
+_DEPRECATED_COLOR_MODE_RGBWW: Final = DeprecatedConstantEnum(ColorMode.RGBWW, "2026.1")
+_DEPRECATED_COLOR_MODE_WHITE: Final = DeprecatedConstantEnum(ColorMode.WHITE, "2026.1")
-VALID_COLOR_MODES = {
- ColorMode.ONOFF,
- ColorMode.BRIGHTNESS,
- ColorMode.COLOR_TEMP,
- ColorMode.HS,
- ColorMode.XY,
- ColorMode.RGB,
- ColorMode.RGBW,
- ColorMode.RGBWW,
- ColorMode.WHITE,
-}
-COLOR_MODES_BRIGHTNESS = VALID_COLOR_MODES - {ColorMode.ONOFF}
-COLOR_MODES_COLOR = {
- ColorMode.HS,
- ColorMode.RGB,
- ColorMode.RGBW,
- ColorMode.RGBWW,
- ColorMode.XY,
-}
# mypy: disallow-any-generics
@@ -200,16 +186,26 @@ ATTR_RGBW_COLOR = "rgbw_color"
ATTR_RGBWW_COLOR = "rgbww_color"
ATTR_XY_COLOR = "xy_color"
ATTR_HS_COLOR = "hs_color"
-ATTR_COLOR_TEMP = "color_temp" # Deprecated in HA Core 2022.11
-ATTR_KELVIN = "kelvin" # Deprecated in HA Core 2022.11
-ATTR_MIN_MIREDS = "min_mireds" # Deprecated in HA Core 2022.11
-ATTR_MAX_MIREDS = "max_mireds" # Deprecated in HA Core 2022.11
ATTR_COLOR_TEMP_KELVIN = "color_temp_kelvin"
ATTR_MIN_COLOR_TEMP_KELVIN = "min_color_temp_kelvin"
ATTR_MAX_COLOR_TEMP_KELVIN = "max_color_temp_kelvin"
ATTR_COLOR_NAME = "color_name"
ATTR_WHITE = "white"
+# Deprecated in HA Core 2022.11
+_DEPRECATED_ATTR_COLOR_TEMP: Final = DeprecatedConstant(
+ "color_temp", "kelvin equivalent (ATTR_COLOR_TEMP_KELVIN)", "2026.1"
+)
+_DEPRECATED_ATTR_KELVIN: Final = DeprecatedConstant(
+ "kelvin", "ATTR_COLOR_TEMP_KELVIN", "2026.1"
+)
+_DEPRECATED_ATTR_MIN_MIREDS: Final = DeprecatedConstant(
+ "min_mireds", "kelvin equivalent (ATTR_MAX_COLOR_TEMP_KELVIN)", "2026.1"
+)
+_DEPRECATED_ATTR_MAX_MIREDS: Final = DeprecatedConstant(
+ "max_mireds", "kelvin equivalent (ATTR_MIN_COLOR_TEMP_KELVIN)", "2026.1"
+)
+
# Brightness of the light, 0..255 or percentage
ATTR_BRIGHTNESS = "brightness"
ATTR_BRIGHTNESS_PCT = "brightness_pct"
@@ -254,11 +250,11 @@ LIGHT_TURN_ON_SCHEMA: VolDictType = {
vol.Exclusive(ATTR_BRIGHTNESS_STEP, ATTR_BRIGHTNESS): VALID_BRIGHTNESS_STEP,
vol.Exclusive(ATTR_BRIGHTNESS_STEP_PCT, ATTR_BRIGHTNESS): VALID_BRIGHTNESS_STEP_PCT,
vol.Exclusive(ATTR_COLOR_NAME, COLOR_GROUP): cv.string,
- vol.Exclusive(ATTR_COLOR_TEMP, COLOR_GROUP): vol.All(
+ vol.Exclusive(_DEPRECATED_ATTR_COLOR_TEMP.value, COLOR_GROUP): vol.All(
vol.Coerce(int), vol.Range(min=1)
),
vol.Exclusive(ATTR_COLOR_TEMP_KELVIN, COLOR_GROUP): cv.positive_int,
- vol.Exclusive(ATTR_KELVIN, COLOR_GROUP): cv.positive_int,
+ vol.Exclusive(_DEPRECATED_ATTR_KELVIN.value, COLOR_GROUP): cv.positive_int,
vol.Exclusive(ATTR_HS_COLOR, COLOR_GROUP): vol.All(
vol.Coerce(tuple),
vol.ExactSequence(
@@ -321,19 +317,29 @@ def preprocess_turn_on_alternatives(
_LOGGER.warning("Got unknown color %s, falling back to white", color_name)
params[ATTR_RGB_COLOR] = (255, 255, 255)
- if (mired := params.pop(ATTR_COLOR_TEMP, None)) is not None:
+ if (mired := params.pop(_DEPRECATED_ATTR_COLOR_TEMP.value, None)) is not None:
+ _LOGGER.warning(
+ "Got `color_temp` argument in `turn_on` service, which is deprecated "
+ "and will break in Home Assistant 2026.1, please use "
+ "`color_temp_kelvin` argument"
+ )
kelvin = color_util.color_temperature_mired_to_kelvin(mired)
- params[ATTR_COLOR_TEMP] = int(mired)
+ params[_DEPRECATED_ATTR_COLOR_TEMP.value] = int(mired)
params[ATTR_COLOR_TEMP_KELVIN] = int(kelvin)
- if (kelvin := params.pop(ATTR_KELVIN, None)) is not None:
+ if (kelvin := params.pop(_DEPRECATED_ATTR_KELVIN.value, None)) is not None:
+ _LOGGER.warning(
+ "Got `kelvin` argument in `turn_on` service, which is deprecated "
+ "and will break in Home Assistant 2026.1, please use "
+ "`color_temp_kelvin` argument"
+ )
mired = color_util.color_temperature_kelvin_to_mired(kelvin)
- params[ATTR_COLOR_TEMP] = int(mired)
+ params[_DEPRECATED_ATTR_COLOR_TEMP.value] = int(mired)
params[ATTR_COLOR_TEMP_KELVIN] = int(kelvin)
if (kelvin := params.pop(ATTR_COLOR_TEMP_KELVIN, None)) is not None:
mired = color_util.color_temperature_kelvin_to_mired(kelvin)
- params[ATTR_COLOR_TEMP] = int(mired)
+ params[_DEPRECATED_ATTR_COLOR_TEMP.value] = int(mired)
params[ATTR_COLOR_TEMP_KELVIN] = int(kelvin)
brightness_pct = params.pop(ATTR_BRIGHTNESS_PCT, None)
@@ -375,7 +381,7 @@ def filter_turn_on_params(light: LightEntity, params: dict[str, Any]) -> dict[st
if not brightness_supported(supported_color_modes):
params.pop(ATTR_BRIGHTNESS, None)
if ColorMode.COLOR_TEMP not in supported_color_modes:
- params.pop(ATTR_COLOR_TEMP, None)
+ params.pop(_DEPRECATED_ATTR_COLOR_TEMP.value, None)
params.pop(ATTR_COLOR_TEMP_KELVIN, None)
if ColorMode.HS not in supported_color_modes:
params.pop(ATTR_HS_COLOR, None)
@@ -457,7 +463,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa:
and ColorMode.COLOR_TEMP not in supported_color_modes
and ColorMode.RGBWW in supported_color_modes
):
- params.pop(ATTR_COLOR_TEMP)
+ params.pop(_DEPRECATED_ATTR_COLOR_TEMP.value)
color_temp = params.pop(ATTR_COLOR_TEMP_KELVIN)
brightness = params.get(ATTR_BRIGHTNESS, light.brightness)
params[ATTR_RGBWW_COLOR] = color_util.color_temperature_to_rgbww(
@@ -467,7 +473,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa:
light.max_color_temp_kelvin,
)
elif ColorMode.COLOR_TEMP not in legacy_supported_color_modes:
- params.pop(ATTR_COLOR_TEMP)
+ params.pop(_DEPRECATED_ATTR_COLOR_TEMP.value)
color_temp = params.pop(ATTR_COLOR_TEMP_KELVIN)
if color_supported(legacy_supported_color_modes):
params[ATTR_HS_COLOR] = color_util.color_temperature_to_hs(
@@ -514,8 +520,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa:
params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature(
*xy_color
)
- params[ATTR_COLOR_TEMP] = color_util.color_temperature_kelvin_to_mired(
- params[ATTR_COLOR_TEMP_KELVIN]
+ params[_DEPRECATED_ATTR_COLOR_TEMP.value] = (
+ color_util.color_temperature_kelvin_to_mired(
+ params[ATTR_COLOR_TEMP_KELVIN]
+ )
)
elif ATTR_RGB_COLOR in params and ColorMode.RGB not in supported_color_modes:
rgb_color = params.pop(ATTR_RGB_COLOR)
@@ -537,8 +545,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa:
params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature(
*xy_color
)
- params[ATTR_COLOR_TEMP] = color_util.color_temperature_kelvin_to_mired(
- params[ATTR_COLOR_TEMP_KELVIN]
+ params[_DEPRECATED_ATTR_COLOR_TEMP.value] = (
+ color_util.color_temperature_kelvin_to_mired(
+ params[ATTR_COLOR_TEMP_KELVIN]
+ )
)
elif ATTR_XY_COLOR in params and ColorMode.XY not in supported_color_modes:
xy_color = params.pop(ATTR_XY_COLOR)
@@ -558,8 +568,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa:
params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature(
*xy_color
)
- params[ATTR_COLOR_TEMP] = color_util.color_temperature_kelvin_to_mired(
- params[ATTR_COLOR_TEMP_KELVIN]
+ params[_DEPRECATED_ATTR_COLOR_TEMP.value] = (
+ color_util.color_temperature_kelvin_to_mired(
+ params[ATTR_COLOR_TEMP_KELVIN]
+ )
)
elif ATTR_RGBW_COLOR in params and ColorMode.RGBW not in supported_color_modes:
rgbw_color = params.pop(ATTR_RGBW_COLOR)
@@ -579,8 +591,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa:
params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature(
*xy_color
)
- params[ATTR_COLOR_TEMP] = color_util.color_temperature_kelvin_to_mired(
- params[ATTR_COLOR_TEMP_KELVIN]
+ params[_DEPRECATED_ATTR_COLOR_TEMP.value] = (
+ color_util.color_temperature_kelvin_to_mired(
+ params[ATTR_COLOR_TEMP_KELVIN]
+ )
)
elif (
ATTR_RGBWW_COLOR in params and ColorMode.RGBWW not in supported_color_modes
@@ -603,8 +617,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa:
params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature(
*xy_color
)
- params[ATTR_COLOR_TEMP] = color_util.color_temperature_kelvin_to_mired(
- params[ATTR_COLOR_TEMP_KELVIN]
+ params[_DEPRECATED_ATTR_COLOR_TEMP.value] = (
+ color_util.color_temperature_kelvin_to_mired(
+ params[ATTR_COLOR_TEMP_KELVIN]
+ )
)
# If white is set to True, set it to the light's brightness
@@ -812,7 +828,7 @@ class Profiles:
color_attributes = (
ATTR_COLOR_NAME,
- ATTR_COLOR_TEMP,
+ _DEPRECATED_ATTR_COLOR_TEMP.value,
ATTR_HS_COLOR,
ATTR_RGB_COLOR,
ATTR_RGBW_COLOR,
@@ -860,13 +876,13 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
{
ATTR_SUPPORTED_COLOR_MODES,
ATTR_EFFECT_LIST,
- ATTR_MIN_MIREDS,
- ATTR_MAX_MIREDS,
+ _DEPRECATED_ATTR_MIN_MIREDS.value,
+ _DEPRECATED_ATTR_MAX_MIREDS.value,
ATTR_MIN_COLOR_TEMP_KELVIN,
ATTR_MAX_COLOR_TEMP_KELVIN,
ATTR_BRIGHTNESS,
ATTR_COLOR_MODE,
- ATTR_COLOR_TEMP,
+ _DEPRECATED_ATTR_COLOR_TEMP.value,
ATTR_COLOR_TEMP_KELVIN,
ATTR_EFFECT,
ATTR_HS_COLOR,
@@ -880,17 +896,15 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
entity_description: LightEntityDescription
_attr_brightness: int | None = None
_attr_color_mode: ColorMode | str | None = None
- _attr_color_temp: int | None = None
_attr_color_temp_kelvin: int | None = None
_attr_effect_list: list[str] | None = None
_attr_effect: str | None = None
_attr_hs_color: tuple[float, float] | None = None
- # Default to the Philips Hue value that HA has always assumed
- # https://developers.meethue.com/documentation/core-concepts
+ # We cannot set defaults without causing breaking changes until mireds
+ # are fully removed. Until then, developers can explicitly
+ # use DEFAULT_MIN_KELVIN and DEFAULT_MAX_KELVIN
_attr_max_color_temp_kelvin: int | None = None
_attr_min_color_temp_kelvin: int | None = None
- _attr_max_mireds: int = 500 # 2000 K
- _attr_min_mireds: int = 153 # 6500 K
_attr_rgb_color: tuple[int, int, int] | None = None
_attr_rgbw_color: tuple[int, int, int, int] | None = None
_attr_rgbww_color: tuple[int, int, int, int, int] | None = None
@@ -898,6 +912,11 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
_attr_supported_features: LightEntityFeature = LightEntityFeature(0)
_attr_xy_color: tuple[float, float] | None = None
+ # Deprecated, see https://github.com/home-assistant/core/pull/79591
+ _attr_color_temp: Final[int | None] = None
+ _attr_max_mireds: Final[int] = 500 # = 2000 K
+ _attr_min_mireds: Final[int] = 153 # = 6535.94 K (~ 6500 K)
+
__color_mode_reported = False
@cached_property
@@ -973,32 +992,70 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
"""Return the rgbww color value [int, int, int, int, int]."""
return self._attr_rgbww_color
+ @final
@cached_property
def color_temp(self) -> int | None:
- """Return the CT color value in mireds."""
+ """Return the CT color value in mireds.
+
+ Deprecated, see https://github.com/home-assistant/core/pull/79591
+ """
return self._attr_color_temp
@property
def color_temp_kelvin(self) -> int | None:
"""Return the CT color value in Kelvin."""
if self._attr_color_temp_kelvin is None and (color_temp := self.color_temp):
+ report_usage(
+ "is using mireds for current light color temperature, when "
+ "it should be adjusted to use the kelvin attribute "
+ "`_attr_color_temp_kelvin` or override the kelvin property "
+ "`color_temp_kelvin` (see "
+ "https://github.com/home-assistant/core/pull/79591)",
+ breaks_in_ha_version="2026.1",
+ core_behavior=ReportBehavior.LOG,
+ integration_domain=self.platform.platform_name
+ if self.platform
+ else None,
+ exclude_integrations={DOMAIN},
+ )
return color_util.color_temperature_mired_to_kelvin(color_temp)
return self._attr_color_temp_kelvin
+ @final
@cached_property
def min_mireds(self) -> int:
- """Return the coldest color_temp that this light supports."""
+ """Return the coldest color_temp that this light supports.
+
+ Deprecated, see https://github.com/home-assistant/core/pull/79591
+ """
return self._attr_min_mireds
+ @final
@cached_property
def max_mireds(self) -> int:
- """Return the warmest color_temp that this light supports."""
+ """Return the warmest color_temp that this light supports.
+
+ Deprecated, see https://github.com/home-assistant/core/pull/79591
+ """
return self._attr_max_mireds
@property
def min_color_temp_kelvin(self) -> int:
"""Return the warmest color_temp_kelvin that this light supports."""
if self._attr_min_color_temp_kelvin is None:
+ report_usage(
+ "is using mireds for warmest light color temperature, when "
+ "it should be adjusted to use the kelvin attribute "
+ "`_attr_min_color_temp_kelvin` or override the kelvin property "
+ "`min_color_temp_kelvin`, possibly with default DEFAULT_MIN_KELVIN "
+ "(see https://github.com/home-assistant/core/pull/79591)",
+ breaks_in_ha_version="2026.1",
+ core_behavior=ReportBehavior.LOG,
+ integration_domain=self.platform.platform_name
+ if self.platform
+ else None,
+ exclude_integrations={DOMAIN},
+ )
return color_util.color_temperature_mired_to_kelvin(self.max_mireds)
return self._attr_min_color_temp_kelvin
@@ -1006,6 +1063,19 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
def max_color_temp_kelvin(self) -> int:
"""Return the coldest color_temp_kelvin that this light supports."""
if self._attr_max_color_temp_kelvin is None:
+ report_usage(
+ "is using mireds for coldest light color temperature, when "
+ "it should be adjusted to use the kelvin attribute "
+ "`_attr_max_color_temp_kelvin` or override the kelvin property "
+ "`max_color_temp_kelvin`, possibly with default DEFAULT_MAX_KELVIN "
+ "(see https://github.com/home-assistant/core/pull/79591)",
+ breaks_in_ha_version="2026.1",
+ core_behavior=ReportBehavior.LOG,
+ integration_domain=self.platform.platform_name
+ if self.platform
+ else None,
+ exclude_integrations={DOMAIN},
+ )
return color_util.color_temperature_mired_to_kelvin(self.min_mireds)
return self._attr_max_color_temp_kelvin
@@ -1032,16 +1102,16 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
data[ATTR_MIN_COLOR_TEMP_KELVIN] = min_color_temp_kelvin
data[ATTR_MAX_COLOR_TEMP_KELVIN] = max_color_temp_kelvin
if not max_color_temp_kelvin:
- data[ATTR_MIN_MIREDS] = None
+ data[_DEPRECATED_ATTR_MIN_MIREDS.value] = None
else:
- data[ATTR_MIN_MIREDS] = color_util.color_temperature_kelvin_to_mired(
- max_color_temp_kelvin
+ data[_DEPRECATED_ATTR_MIN_MIREDS.value] = (
+ color_util.color_temperature_kelvin_to_mired(max_color_temp_kelvin)
)
if not min_color_temp_kelvin:
- data[ATTR_MAX_MIREDS] = None
+ data[_DEPRECATED_ATTR_MAX_MIREDS.value] = None
else:
- data[ATTR_MAX_MIREDS] = color_util.color_temperature_kelvin_to_mired(
- min_color_temp_kelvin
+ data[_DEPRECATED_ATTR_MAX_MIREDS.value] = (
+ color_util.color_temperature_kelvin_to_mired(min_color_temp_kelvin)
)
if LightEntityFeature.EFFECT in supported_features:
data[ATTR_EFFECT_LIST] = self.effect_list
@@ -1209,7 +1279,7 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
data[ATTR_BRIGHTNESS] = self.brightness
else:
data[ATTR_BRIGHTNESS] = None
- elif supported_features_value & SUPPORT_BRIGHTNESS:
+ elif supported_features_value & _DEPRECATED_SUPPORT_BRIGHTNESS.value:
# Backwards compatibility for ambiguous / incomplete states
# Warning is printed by supported_features_compat, remove in 2025.1
if _is_on:
@@ -1222,29 +1292,29 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
color_temp_kelvin = self.color_temp_kelvin
data[ATTR_COLOR_TEMP_KELVIN] = color_temp_kelvin
if color_temp_kelvin:
- data[ATTR_COLOR_TEMP] = (
+ data[_DEPRECATED_ATTR_COLOR_TEMP.value] = (
color_util.color_temperature_kelvin_to_mired(color_temp_kelvin)
)
else:
- data[ATTR_COLOR_TEMP] = None
+ data[_DEPRECATED_ATTR_COLOR_TEMP.value] = None
else:
data[ATTR_COLOR_TEMP_KELVIN] = None
- data[ATTR_COLOR_TEMP] = None
- elif supported_features_value & SUPPORT_COLOR_TEMP:
+ data[_DEPRECATED_ATTR_COLOR_TEMP.value] = None
+ elif supported_features_value & _DEPRECATED_SUPPORT_COLOR_TEMP.value:
# Backwards compatibility
# Warning is printed by supported_features_compat, remove in 2025.1
if _is_on:
color_temp_kelvin = self.color_temp_kelvin
data[ATTR_COLOR_TEMP_KELVIN] = color_temp_kelvin
if color_temp_kelvin:
- data[ATTR_COLOR_TEMP] = (
+ data[_DEPRECATED_ATTR_COLOR_TEMP.value] = (
color_util.color_temperature_kelvin_to_mired(color_temp_kelvin)
)
else:
- data[ATTR_COLOR_TEMP] = None
+ data[_DEPRECATED_ATTR_COLOR_TEMP.value] = None
else:
data[ATTR_COLOR_TEMP_KELVIN] = None
- data[ATTR_COLOR_TEMP] = None
+ data[_DEPRECATED_ATTR_COLOR_TEMP.value] = None
if color_supported(legacy_supported_color_modes) or color_temp_supported(
legacy_supported_color_modes
@@ -1286,11 +1356,14 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
supported_features_value = supported_features.value
supported_color_modes: set[ColorMode] = set()
- if supported_features_value & SUPPORT_COLOR_TEMP:
+ if supported_features_value & _DEPRECATED_SUPPORT_COLOR_TEMP.value:
supported_color_modes.add(ColorMode.COLOR_TEMP)
- if supported_features_value & SUPPORT_COLOR:
+ if supported_features_value & _DEPRECATED_SUPPORT_COLOR.value:
supported_color_modes.add(ColorMode.HS)
- if not supported_color_modes and supported_features_value & SUPPORT_BRIGHTNESS:
+ if (
+ not supported_color_modes
+ and supported_features_value & _DEPRECATED_SUPPORT_BRIGHTNESS.value
+ ):
supported_color_modes = {ColorMode.BRIGHTNESS}
if not supported_color_modes:
@@ -1345,3 +1418,11 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
return True
# philips_js has known issues, we don't need users to open issues
return self.platform.platform_name not in {"philips_js"}
+
+
+# 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/light/const.py b/homeassistant/components/light/const.py
new file mode 100644
index 00000000000..d27750a950d
--- /dev/null
+++ b/homeassistant/components/light/const.py
@@ -0,0 +1,73 @@
+"""Provides constants for lights."""
+
+from __future__ import annotations
+
+from datetime import timedelta
+from enum import IntFlag, StrEnum
+from typing import TYPE_CHECKING
+
+from homeassistant.util.hass_dict import HassKey
+
+if TYPE_CHECKING:
+ from homeassistant.helpers.entity_component import EntityComponent
+
+ from . import LightEntity, Profiles
+
+DOMAIN = "light"
+DATA_COMPONENT: HassKey[EntityComponent[LightEntity]] = HassKey(DOMAIN)
+SCAN_INTERVAL = timedelta(seconds=30)
+
+DATA_PROFILES: HassKey[Profiles] = HassKey(f"{DOMAIN}_profiles")
+
+
+class LightEntityFeature(IntFlag):
+ """Supported features of the light entity."""
+
+ EFFECT = 4
+ FLASH = 8
+ TRANSITION = 32
+
+
+class ColorMode(StrEnum):
+ """Possible light color modes."""
+
+ UNKNOWN = "unknown"
+ """Ambiguous color mode"""
+ ONOFF = "onoff"
+ """Must be the only supported mode"""
+ BRIGHTNESS = "brightness"
+ """Must be the only supported mode"""
+ COLOR_TEMP = "color_temp"
+ HS = "hs"
+ XY = "xy"
+ RGB = "rgb"
+ RGBW = "rgbw"
+ RGBWW = "rgbww"
+ WHITE = "white"
+ """Must *NOT* be the only supported mode"""
+
+
+VALID_COLOR_MODES = {
+ ColorMode.ONOFF,
+ ColorMode.BRIGHTNESS,
+ ColorMode.COLOR_TEMP,
+ ColorMode.HS,
+ ColorMode.XY,
+ ColorMode.RGB,
+ ColorMode.RGBW,
+ ColorMode.RGBWW,
+ ColorMode.WHITE,
+}
+COLOR_MODES_BRIGHTNESS = VALID_COLOR_MODES - {ColorMode.ONOFF}
+COLOR_MODES_COLOR = {
+ ColorMode.HS,
+ ColorMode.RGB,
+ ColorMode.RGBW,
+ ColorMode.RGBWW,
+ ColorMode.XY,
+}
+
+# Default to the Philips Hue value that HA has always assumed
+# https://developers.meethue.com/documentation/core-concepts
+DEFAULT_MIN_KELVIN = 2000 # 500 mireds
+DEFAULT_MAX_KELVIN = 6535 # 153 mireds
diff --git a/homeassistant/components/light/device_action.py b/homeassistant/components/light/device_action.py
index 45e9731c5b8..56bf7485e68 100644
--- a/homeassistant/components/light/device_action.py
+++ b/homeassistant/components/light/device_action.py
@@ -27,14 +27,13 @@ from . import (
ATTR_BRIGHTNESS_PCT,
ATTR_BRIGHTNESS_STEP_PCT,
ATTR_FLASH,
- DOMAIN,
FLASH_SHORT,
VALID_BRIGHTNESS_PCT,
VALID_FLASH,
- LightEntityFeature,
brightness_supported,
get_supported_color_modes,
)
+from .const import DOMAIN, LightEntityFeature
# mypy: disallow-any-generics
diff --git a/homeassistant/components/light/device_condition.py b/homeassistant/components/light/device_condition.py
index f9bb7c30bd7..6dc702f8551 100644
--- a/homeassistant/components/light/device_condition.py
+++ b/homeassistant/components/light/device_condition.py
@@ -10,7 +10,7 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.condition import ConditionCheckerType
from homeassistant.helpers.typing import ConfigType
-from . import DOMAIN
+from .const import DOMAIN
# mypy: disallow-any-generics
diff --git a/homeassistant/components/light/device_trigger.py b/homeassistant/components/light/device_trigger.py
index 033ea75357e..1f6bfdbe6e9 100644
--- a/homeassistant/components/light/device_trigger.py
+++ b/homeassistant/components/light/device_trigger.py
@@ -10,7 +10,7 @@ from homeassistant.core import CALLBACK_TYPE, HomeAssistant
from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo
from homeassistant.helpers.typing import ConfigType
-from . import DOMAIN
+from .const import DOMAIN
TRIGGER_SCHEMA = vol.All(
toggle_entity.TRIGGER_SCHEMA,
diff --git a/homeassistant/components/light/intent.py b/homeassistant/components/light/intent.py
index 458dbbde770..e496255029a 100644
--- a/homeassistant/components/light/intent.py
+++ b/homeassistant/components/light/intent.py
@@ -11,7 +11,8 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv, intent
import homeassistant.util.color as color_util
-from . import ATTR_BRIGHTNESS_PCT, ATTR_COLOR_TEMP_KELVIN, ATTR_RGB_COLOR, DOMAIN
+from . import ATTR_BRIGHTNESS_PCT, ATTR_COLOR_TEMP_KELVIN, ATTR_RGB_COLOR
+from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
diff --git a/homeassistant/components/light/reproduce_state.py b/homeassistant/components/light/reproduce_state.py
index 4024f2f84ba..4e994ab791d 100644
--- a/homeassistant/components/light/reproduce_state.py
+++ b/homeassistant/components/light/reproduce_state.py
@@ -15,11 +15,13 @@ from homeassistant.const import (
STATE_ON,
)
from homeassistant.core import Context, HomeAssistant, State
+from homeassistant.util import color as color_util
from . import (
+ _DEPRECATED_ATTR_COLOR_TEMP,
ATTR_BRIGHTNESS,
ATTR_COLOR_MODE,
- ATTR_COLOR_TEMP,
+ ATTR_COLOR_TEMP_KELVIN,
ATTR_EFFECT,
ATTR_HS_COLOR,
ATTR_RGB_COLOR,
@@ -28,9 +30,8 @@ from . import (
ATTR_TRANSITION,
ATTR_WHITE,
ATTR_XY_COLOR,
- DOMAIN,
- ColorMode,
)
+from .const import DOMAIN, ColorMode
_LOGGER = logging.getLogger(__name__)
@@ -40,7 +41,8 @@ ATTR_GROUP = [ATTR_BRIGHTNESS, ATTR_EFFECT]
COLOR_GROUP = [
ATTR_HS_COLOR,
- ATTR_COLOR_TEMP,
+ _DEPRECATED_ATTR_COLOR_TEMP.value,
+ ATTR_COLOR_TEMP_KELVIN,
ATTR_RGB_COLOR,
ATTR_RGBW_COLOR,
ATTR_RGBWW_COLOR,
@@ -56,7 +58,7 @@ class ColorModeAttr(NamedTuple):
COLOR_MODE_TO_ATTRIBUTE = {
- ColorMode.COLOR_TEMP: ColorModeAttr(ATTR_COLOR_TEMP, ATTR_COLOR_TEMP),
+ ColorMode.COLOR_TEMP: ColorModeAttr(ATTR_COLOR_TEMP_KELVIN, ATTR_COLOR_TEMP_KELVIN),
ColorMode.HS: ColorModeAttr(ATTR_HS_COLOR, ATTR_HS_COLOR),
ColorMode.RGB: ColorModeAttr(ATTR_RGB_COLOR, ATTR_RGB_COLOR),
ColorMode.RGBW: ColorModeAttr(ATTR_RGBW_COLOR, ATTR_RGBW_COLOR),
@@ -125,13 +127,30 @@ async def _async_reproduce_state(
color_mode = state.attributes[ATTR_COLOR_MODE]
if cm_attr := COLOR_MODE_TO_ATTRIBUTE.get(color_mode):
if (cm_attr_state := state.attributes.get(cm_attr.state_attr)) is None:
+ if (
+ color_mode != ColorMode.COLOR_TEMP
+ or (
+ mireds := state.attributes.get(
+ _DEPRECATED_ATTR_COLOR_TEMP.value
+ )
+ )
+ is None
+ ):
+ _LOGGER.warning(
+ "Color mode %s specified but attribute %s missing for: %s",
+ color_mode,
+ cm_attr.state_attr,
+ state.entity_id,
+ )
+ return
_LOGGER.warning(
- "Color mode %s specified but attribute %s missing for: %s",
+ "Color mode %s specified but attribute %s missing for: %s, "
+ "using color_temp (mireds) as fallback",
color_mode,
cm_attr.state_attr,
state.entity_id,
)
- return
+ cm_attr_state = color_util.color_temperature_mired_to_kelvin(mireds)
service_data[cm_attr.parameter] = cm_attr_state
else:
# Fall back to Choosing the first color that is specified
diff --git a/homeassistant/components/light/significant_change.py b/homeassistant/components/light/significant_change.py
index 1877c925622..773b7a6b898 100644
--- a/homeassistant/components/light/significant_change.py
+++ b/homeassistant/components/light/significant_change.py
@@ -7,7 +7,7 @@ from typing import Any
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.significant_change import check_absolute_change
-from . import ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_EFFECT, ATTR_HS_COLOR
+from . import ATTR_BRIGHTNESS, ATTR_COLOR_TEMP_KELVIN, ATTR_EFFECT, ATTR_HS_COLOR
@callback
@@ -44,10 +44,10 @@ def async_check_significant_change(
return True
if check_absolute_change(
- # Default range 153..500
- old_attrs.get(ATTR_COLOR_TEMP),
- new_attrs.get(ATTR_COLOR_TEMP),
- 5,
+ # Default range 2000..6500
+ old_attrs.get(ATTR_COLOR_TEMP_KELVIN),
+ new_attrs.get(ATTR_COLOR_TEMP_KELVIN),
+ 50,
):
return True
diff --git a/homeassistant/components/lightwave/climate.py b/homeassistant/components/lightwave/climate.py
index 1016e8ce80d..942fb4a1fbc 100644
--- a/homeassistant/components/lightwave/climate.py
+++ b/homeassistant/components/lightwave/climate.py
@@ -55,7 +55,6 @@ class LightwaveTrv(ClimateEntity):
)
_attr_target_temperature_step = 0.5
_attr_temperature_unit = UnitOfTemperature.CELSIUS
- _enable_turn_on_off_backwards_compatibility = False
def __init__(self, name, device_id, lwlink, serial):
"""Initialize LightwaveTrv entity."""
diff --git a/homeassistant/components/lightwave/manifest.json b/homeassistant/components/lightwave/manifest.json
index d242195a71c..75b39b18c26 100644
--- a/homeassistant/components/lightwave/manifest.json
+++ b/homeassistant/components/lightwave/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/lightwave",
"iot_class": "assumed_state",
"loggers": ["lightwave"],
+ "quality_scale": "legacy",
"requirements": ["lightwave==0.24"]
}
diff --git a/homeassistant/components/limitlessled/light.py b/homeassistant/components/limitlessled/light.py
index c6b3301081d..4b2b75be9d7 100644
--- a/homeassistant/components/limitlessled/light.py
+++ b/homeassistant/components/limitlessled/light.py
@@ -19,7 +19,7 @@ import voluptuous as vol
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
- ATTR_COLOR_TEMP,
+ ATTR_COLOR_TEMP_KELVIN,
ATTR_EFFECT,
ATTR_FLASH,
ATTR_HS_COLOR,
@@ -38,7 +38,7 @@ import homeassistant.helpers.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
-from homeassistant.util.color import color_hs_to_RGB, color_temperature_mired_to_kelvin
+from homeassistant.util.color import color_hs_to_RGB
_LOGGER = logging.getLogger(__name__)
@@ -217,8 +217,8 @@ class LimitlessLEDGroup(LightEntity, RestoreEntity):
"""Representation of a LimitessLED group."""
_attr_assumed_state = True
- _attr_max_mireds = 370
- _attr_min_mireds = 154
+ _attr_min_color_temp_kelvin = 2700 # 370 Mireds
+ _attr_max_color_temp_kelvin = 6500 # 154 Mireds
_attr_should_poll = False
def __init__(self, group: Group, config: dict[str, Any]) -> None:
@@ -261,7 +261,9 @@ class LimitlessLEDGroup(LightEntity, RestoreEntity):
if last_state := await self.async_get_last_state():
self._attr_is_on = last_state.state == STATE_ON
self._attr_brightness = last_state.attributes.get("brightness")
- self._attr_color_temp = last_state.attributes.get("color_temp")
+ self._attr_color_temp_kelvin = last_state.attributes.get(
+ "color_temp_kelvin"
+ )
self._attr_hs_color = last_state.attributes.get("hs_color")
@property
@@ -325,12 +327,12 @@ class LimitlessLEDGroup(LightEntity, RestoreEntity):
else:
args["color"] = self.limitlessled_color()
- if ATTR_COLOR_TEMP in kwargs:
+ if ATTR_COLOR_TEMP_KELVIN in kwargs:
assert self.supported_color_modes
if ColorMode.HS in self.supported_color_modes:
pipeline.white()
self._attr_hs_color = WHITE
- self._attr_color_temp = kwargs[ATTR_COLOR_TEMP]
+ self._attr_color_temp_kelvin = kwargs[ATTR_COLOR_TEMP_KELVIN]
args["temperature"] = self.limitlessled_temperature()
if args:
@@ -354,12 +356,9 @@ class LimitlessLEDGroup(LightEntity, RestoreEntity):
def limitlessled_temperature(self) -> float:
"""Convert Home Assistant color temperature units to percentage."""
- max_kelvin = color_temperature_mired_to_kelvin(self.min_mireds)
- min_kelvin = color_temperature_mired_to_kelvin(self.max_mireds)
- width = max_kelvin - min_kelvin
- assert self.color_temp is not None
- kelvin = color_temperature_mired_to_kelvin(self.color_temp)
- temperature = (kelvin - min_kelvin) / width
+ width = self.max_color_temp_kelvin - self.min_color_temp_kelvin
+ assert self.color_temp_kelvin is not None
+ temperature = (self.color_temp_kelvin - self.min_color_temp_kelvin) / width
return max(0, min(1, temperature))
def limitlessled_brightness(self) -> float:
diff --git a/homeassistant/components/limitlessled/manifest.json b/homeassistant/components/limitlessled/manifest.json
index 3495ac2c981..c2a921c6e24 100644
--- a/homeassistant/components/limitlessled/manifest.json
+++ b/homeassistant/components/limitlessled/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/limitlessled",
"iot_class": "assumed_state",
"loggers": ["limitlessled"],
+ "quality_scale": "legacy",
"requirements": ["limitlessled==1.1.3"]
}
diff --git a/homeassistant/components/linkplay/button.py b/homeassistant/components/linkplay/button.py
new file mode 100644
index 00000000000..1c93ebcdc3e
--- /dev/null
+++ b/homeassistant/components/linkplay/button.py
@@ -0,0 +1,82 @@
+"""Support for LinkPlay buttons."""
+
+from __future__ import annotations
+
+from collections.abc import Callable, Coroutine
+from dataclasses import dataclass
+import logging
+from typing import Any
+
+from linkplay.bridge import LinkPlayBridge
+
+from homeassistant.components.button import (
+ ButtonDeviceClass,
+ ButtonEntity,
+ ButtonEntityDescription,
+)
+from homeassistant.const import EntityCategory
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+
+from . import LinkPlayConfigEntry
+from .entity import LinkPlayBaseEntity, exception_wrap
+
+_LOGGER = logging.getLogger(__name__)
+
+
+@dataclass(frozen=True, kw_only=True)
+class LinkPlayButtonEntityDescription(ButtonEntityDescription):
+ """Class describing LinkPlay button entities."""
+
+ remote_function: Callable[[LinkPlayBridge], Coroutine[Any, Any, None]]
+
+
+BUTTON_TYPES: tuple[LinkPlayButtonEntityDescription, ...] = (
+ LinkPlayButtonEntityDescription(
+ key="timesync",
+ translation_key="timesync",
+ remote_function=lambda linkplay_bridge: linkplay_bridge.device.timesync(),
+ entity_category=EntityCategory.CONFIG,
+ ),
+ LinkPlayButtonEntityDescription(
+ key="restart",
+ device_class=ButtonDeviceClass.RESTART,
+ remote_function=lambda linkplay_bridge: linkplay_bridge.device.reboot(),
+ entity_category=EntityCategory.CONFIG,
+ ),
+)
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ config_entry: LinkPlayConfigEntry,
+ async_add_entities: AddEntitiesCallback,
+) -> None:
+ """Set up the LinkPlay buttons from config entry."""
+
+ # add entities
+ async_add_entities(
+ LinkPlayButton(config_entry.runtime_data.bridge, description)
+ for description in BUTTON_TYPES
+ )
+
+
+class LinkPlayButton(LinkPlayBaseEntity, ButtonEntity):
+ """Representation of LinkPlay button."""
+
+ entity_description: LinkPlayButtonEntityDescription
+
+ def __init__(
+ self,
+ bridge: LinkPlayBridge,
+ description: LinkPlayButtonEntityDescription,
+ ) -> None:
+ """Initialize LinkPlay button."""
+ super().__init__(bridge)
+ self.entity_description = description
+ self._attr_unique_id = f"{bridge.device.uuid}-{description.key}"
+
+ @exception_wrap
+ async def async_press(self) -> None:
+ """Press the button."""
+ await self.entity_description.remote_function(self._bridge)
diff --git a/homeassistant/components/linkplay/const.py b/homeassistant/components/linkplay/const.py
index a776365e38f..e10450cf255 100644
--- a/homeassistant/components/linkplay/const.py
+++ b/homeassistant/components/linkplay/const.py
@@ -8,5 +8,5 @@ from homeassistant.util.hass_dict import HassKey
DOMAIN = "linkplay"
CONTROLLER = "controller"
CONTROLLER_KEY: HassKey[LinkPlayController] = HassKey(CONTROLLER)
-PLATFORMS = [Platform.MEDIA_PLAYER]
+PLATFORMS = [Platform.BUTTON, Platform.MEDIA_PLAYER]
DATA_SESSION = "session"
diff --git a/homeassistant/components/linkplay/diagnostics.py b/homeassistant/components/linkplay/diagnostics.py
new file mode 100644
index 00000000000..cfc1346aff4
--- /dev/null
+++ b/homeassistant/components/linkplay/diagnostics.py
@@ -0,0 +1,17 @@
+"""Diagnostics support for Linkplay."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from homeassistant.core import HomeAssistant
+
+from . import LinkPlayConfigEntry
+
+
+async def async_get_config_entry_diagnostics(
+ hass: HomeAssistant, entry: LinkPlayConfigEntry
+) -> dict[str, Any]:
+ """Return diagnostics for a config entry."""
+ data = entry.runtime_data
+ return {"device_info": data.bridge.to_dict()}
diff --git a/homeassistant/components/linkplay/entity.py b/homeassistant/components/linkplay/entity.py
new file mode 100644
index 00000000000..00e2f39b233
--- /dev/null
+++ b/homeassistant/components/linkplay/entity.py
@@ -0,0 +1,57 @@
+"""BaseEntity to support multiple LinkPlay platforms."""
+
+from collections.abc import Callable, Coroutine
+from typing import Any, Concatenate
+
+from linkplay.bridge import LinkPlayBridge
+
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers import device_registry as dr
+from homeassistant.helpers.entity import Entity
+
+from . import DOMAIN, LinkPlayRequestException
+from .utils import MANUFACTURER_GENERIC, get_info_from_project
+
+
+def exception_wrap[_LinkPlayEntityT: LinkPlayBaseEntity, **_P, _R](
+ func: Callable[Concatenate[_LinkPlayEntityT, _P], Coroutine[Any, Any, _R]],
+) -> Callable[Concatenate[_LinkPlayEntityT, _P], Coroutine[Any, Any, _R]]:
+ """Define a wrapper to catch exceptions and raise HomeAssistant errors."""
+
+ async def _wrap(self: _LinkPlayEntityT, *args: _P.args, **kwargs: _P.kwargs) -> _R:
+ try:
+ return await func(self, *args, **kwargs)
+ except LinkPlayRequestException as err:
+ raise HomeAssistantError(
+ f"Exception occurred when communicating with API {func}: {err}"
+ ) from err
+
+ return _wrap
+
+
+class LinkPlayBaseEntity(Entity):
+ """Representation of a LinkPlay base entity."""
+
+ _attr_has_entity_name = True
+
+ def __init__(self, bridge: LinkPlayBridge) -> None:
+ """Initialize the LinkPlay media player."""
+
+ self._bridge = bridge
+
+ manufacturer, model = get_info_from_project(bridge.device.properties["project"])
+ model_id = None
+ if model != MANUFACTURER_GENERIC:
+ model_id = bridge.device.properties["project"]
+
+ self._attr_device_info = dr.DeviceInfo(
+ configuration_url=bridge.endpoint,
+ connections={(dr.CONNECTION_NETWORK_MAC, bridge.device.properties["MAC"])},
+ hw_version=bridge.device.properties["hardware"],
+ identifiers={(DOMAIN, bridge.device.uuid)},
+ manufacturer=manufacturer,
+ model=model,
+ model_id=model_id,
+ name=bridge.device.name,
+ sw_version=bridge.device.properties["firmware"],
+ )
diff --git a/homeassistant/components/linkplay/icons.json b/homeassistant/components/linkplay/icons.json
index ee76344dc39..c0fe86d9ac7 100644
--- a/homeassistant/components/linkplay/icons.json
+++ b/homeassistant/components/linkplay/icons.json
@@ -1,4 +1,11 @@
{
+ "entity": {
+ "button": {
+ "timesync": {
+ "default": "mdi:clock"
+ }
+ }
+ },
"services": {
"play_preset": {
"service": "mdi:play-box-outline"
diff --git a/homeassistant/components/linkplay/manifest.json b/homeassistant/components/linkplay/manifest.json
index 9ddb6abf093..cc124ceb611 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.0.18"],
+ "requirements": ["python-linkplay==0.1.1"],
"zeroconf": ["_linkplay._tcp.local."]
}
diff --git a/homeassistant/components/linkplay/media_player.py b/homeassistant/components/linkplay/media_player.py
index 36834610c04..456fbf23289 100644
--- a/homeassistant/components/linkplay/media_player.py
+++ b/homeassistant/components/linkplay/media_player.py
@@ -2,14 +2,13 @@
from __future__ import annotations
-from collections.abc import Callable, Coroutine
import logging
-from typing import Any, Concatenate
+from typing import Any
from linkplay.bridge import LinkPlayBridge
from linkplay.consts import EqualizerMode, LoopMode, PlayingMode, PlayingStatus
from linkplay.controller import LinkPlayController, LinkPlayMultiroom
-from linkplay.exceptions import LinkPlayException, LinkPlayRequestException
+from linkplay.exceptions import LinkPlayRequestException
import voluptuous as vol
from homeassistant.components import media_source
@@ -28,7 +27,6 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import (
config_validation as cv,
- device_registry as dr,
entity_platform,
entity_registry as er,
)
@@ -37,7 +35,7 @@ from homeassistant.util.dt import utcnow
from . import LinkPlayConfigEntry, LinkPlayData
from .const import CONTROLLER_KEY, DOMAIN
-from .utils import MANUFACTURER_GENERIC, get_info_from_project
+from .entity import LinkPlayBaseEntity, exception_wrap
_LOGGER = logging.getLogger(__name__)
STATE_MAP: dict[PlayingStatus, MediaPlayerState] = {
@@ -69,6 +67,8 @@ SOURCE_MAP: dict[PlayingMode, str] = {
PlayingMode.FM: "FM Radio",
PlayingMode.RCA: "RCA",
PlayingMode.UDISK: "USB",
+ PlayingMode.SPOTIFY: "Spotify",
+ PlayingMode.TIDAL: "Tidal",
PlayingMode.FOLLOWER: "Follower",
}
@@ -143,67 +143,32 @@ async def async_setup_entry(
async_add_entities([LinkPlayMediaPlayerEntity(entry.runtime_data.bridge)])
-def exception_wrap[_LinkPlayEntityT: LinkPlayMediaPlayerEntity, **_P, _R](
- func: Callable[Concatenate[_LinkPlayEntityT, _P], Coroutine[Any, Any, _R]],
-) -> Callable[Concatenate[_LinkPlayEntityT, _P], Coroutine[Any, Any, _R]]:
- """Define a wrapper to catch exceptions and raise HomeAssistant errors."""
-
- async def _wrap(self: _LinkPlayEntityT, *args: _P.args, **kwargs: _P.kwargs) -> _R:
- try:
- return await func(self, *args, **kwargs)
- except LinkPlayRequestException as err:
- raise HomeAssistantError(
- f"Exception occurred when communicating with API {func}: {err}"
- ) from err
-
- return _wrap
-
-
-class LinkPlayMediaPlayerEntity(MediaPlayerEntity):
+class LinkPlayMediaPlayerEntity(LinkPlayBaseEntity, MediaPlayerEntity):
"""Representation of a LinkPlay media player."""
_attr_sound_mode_list = list(EQUALIZER_MAP.values())
_attr_device_class = MediaPlayerDeviceClass.RECEIVER
_attr_media_content_type = MediaType.MUSIC
- _attr_has_entity_name = True
_attr_name = None
def __init__(self, bridge: LinkPlayBridge) -> None:
"""Initialize the LinkPlay media player."""
- self._bridge = bridge
+ super().__init__(bridge)
self._attr_unique_id = bridge.device.uuid
self._attr_source_list = [
SOURCE_MAP[playing_mode] for playing_mode in bridge.device.playmode_support
]
- manufacturer, model = get_info_from_project(bridge.device.properties["project"])
- model_id = None
- if model != MANUFACTURER_GENERIC:
- model_id = bridge.device.properties["project"]
-
- self._attr_device_info = dr.DeviceInfo(
- configuration_url=bridge.endpoint,
- connections={(dr.CONNECTION_NETWORK_MAC, bridge.device.properties["MAC"])},
- hw_version=bridge.device.properties["hardware"],
- identifiers={(DOMAIN, bridge.device.uuid)},
- manufacturer=manufacturer,
- model=model,
- model_id=model_id,
- name=bridge.device.name,
- sw_version=bridge.device.properties["firmware"],
- )
-
@exception_wrap
async def async_update(self) -> None:
"""Update the state of the media player."""
try:
await self._bridge.player.update_status()
self._update_properties()
- except LinkPlayException:
+ except LinkPlayRequestException:
self._attr_available = False
- raise
@exception_wrap
async def async_select_source(self, source: str) -> None:
@@ -292,7 +257,15 @@ class LinkPlayMediaPlayerEntity(MediaPlayerEntity):
@exception_wrap
async def async_play_preset(self, preset_number: int) -> None:
"""Play preset number."""
- await self._bridge.player.play_preset(preset_number)
+ try:
+ await self._bridge.player.play_preset(preset_number)
+ except ValueError as err:
+ raise HomeAssistantError(err) from err
+
+ @exception_wrap
+ async def async_media_seek(self, position: float) -> None:
+ """Seek to a position."""
+ await self._bridge.player.seek(round(position))
@exception_wrap
async def async_join_players(self, group_members: list[str]) -> None:
@@ -379,9 +352,9 @@ class LinkPlayMediaPlayerEntity(MediaPlayerEntity):
)
self._attr_source = SOURCE_MAP.get(self._bridge.player.play_mode, "other")
- self._attr_media_position = self._bridge.player.current_position / 1000
+ self._attr_media_position = self._bridge.player.current_position_in_seconds
self._attr_media_position_updated_at = utcnow()
- self._attr_media_duration = self._bridge.player.total_length / 1000
+ self._attr_media_duration = self._bridge.player.total_length_in_seconds
self._attr_media_artist = self._bridge.player.artist
self._attr_media_title = self._bridge.player.title
self._attr_media_album_name = self._bridge.player.album
diff --git a/homeassistant/components/linkplay/services.yaml b/homeassistant/components/linkplay/services.yaml
index 20bc47be7a7..0d7335a28c8 100644
--- a/homeassistant/components/linkplay/services.yaml
+++ b/homeassistant/components/linkplay/services.yaml
@@ -11,5 +11,4 @@ play_preset:
selector:
number:
min: 1
- max: 10
mode: box
diff --git a/homeassistant/components/linkplay/strings.json b/homeassistant/components/linkplay/strings.json
index f3495b293e0..31b4649e131 100644
--- a/homeassistant/components/linkplay/strings.json
+++ b/homeassistant/components/linkplay/strings.json
@@ -35,6 +35,13 @@
}
}
},
+ "entity": {
+ "button": {
+ "timesync": {
+ "name": "Sync time"
+ }
+ }
+ },
"exceptions": {
"invalid_grouping_entity": {
"message": "Entity with id {entity_id} can't be added to the LinkPlay multiroom. Is the entity a LinkPlay mediaplayer?"
diff --git a/homeassistant/components/linkplay/utils.py b/homeassistant/components/linkplay/utils.py
index 36a492f8464..00bb691362b 100644
--- a/homeassistant/components/linkplay/utils.py
+++ b/homeassistant/components/linkplay/utils.py
@@ -13,45 +13,68 @@ from .const import DATA_SESSION, DOMAIN
MANUFACTURER_ARTSOUND: Final[str] = "ArtSound"
MANUFACTURER_ARYLIC: Final[str] = "Arylic"
MANUFACTURER_IEAST: Final[str] = "iEAST"
+MANUFACTURER_WIIM: Final[str] = "WiiM"
+MANUFACTURER_GGMM: Final[str] = "GGMM"
+MANUFACTURER_MEDION: Final[str] = "Medion"
MANUFACTURER_GENERIC: Final[str] = "Generic"
MODELS_ARTSOUND_SMART_ZONE4: Final[str] = "Smart Zone 4 AMP"
MODELS_ARTSOUND_SMART_HYDE: Final[str] = "Smart Hyde"
MODELS_ARYLIC_S50: Final[str] = "S50+"
MODELS_ARYLIC_S50_PRO: Final[str] = "S50 Pro"
MODELS_ARYLIC_A30: Final[str] = "A30"
+MODELS_ARYLIC_A50: Final[str] = "A50"
MODELS_ARYLIC_A50S: Final[str] = "A50+"
+MODELS_ARYLIC_UP2STREAM_AMP: Final[str] = "Up2Stream Amp 2.0"
MODELS_ARYLIC_UP2STREAM_AMP_V3: Final[str] = "Up2Stream Amp v3"
MODELS_ARYLIC_UP2STREAM_AMP_V4: Final[str] = "Up2Stream Amp v4"
+MODELS_ARYLIC_UP2STREAM_PRO: Final[str] = "Up2Stream Pro v1"
MODELS_ARYLIC_UP2STREAM_PRO_V3: Final[str] = "Up2Stream Pro v3"
+MODELS_ARYLIC_UP2STREAM_PLATE_AMP: Final[str] = "Up2Stream Plate Amp"
MODELS_IEAST_AUDIOCAST_M5: Final[str] = "AudioCast M5"
+MODELS_WIIM_AMP: Final[str] = "WiiM Amp"
+MODELS_WIIM_MINI: Final[str] = "WiiM Mini"
+MODELS_GGMM_GGMM_E2: Final[str] = "GGMM E2"
+MODELS_MEDION_MD_43970: Final[str] = "Life P66970 (MD 43970)"
MODELS_GENERIC: Final[str] = "Generic"
+PROJECTID_LOOKUP: Final[dict[str, tuple[str, str]]] = {
+ "SMART_ZONE4_AMP": (MANUFACTURER_ARTSOUND, MODELS_ARTSOUND_SMART_ZONE4),
+ "SMART_HYDE": (MANUFACTURER_ARTSOUND, MODELS_ARTSOUND_SMART_HYDE),
+ "ARYLIC_S50": (MANUFACTURER_ARYLIC, MODELS_ARYLIC_S50),
+ "RP0016_S50PRO_S": (MANUFACTURER_ARYLIC, MODELS_ARYLIC_S50_PRO),
+ "RP0011_WB60_S": (MANUFACTURER_ARYLIC, MODELS_ARYLIC_A30),
+ "X-50": (MANUFACTURER_ARYLIC, MODELS_ARYLIC_A50),
+ "ARYLIC_A50S": (MANUFACTURER_ARYLIC, MODELS_ARYLIC_A50S),
+ "RP0011_WB60": (MANUFACTURER_ARYLIC, MODELS_ARYLIC_UP2STREAM_AMP),
+ "UP2STREAM_AMP_V3": (MANUFACTURER_ARYLIC, MODELS_ARYLIC_UP2STREAM_AMP_V3),
+ "UP2STREAM_AMP_V4": (MANUFACTURER_ARYLIC, MODELS_ARYLIC_UP2STREAM_AMP_V4),
+ "UP2STREAM_PRO_V3": (MANUFACTURER_ARYLIC, MODELS_ARYLIC_UP2STREAM_PRO_V3),
+ "ARYLIC_V20": (MANUFACTURER_ARYLIC, MODELS_ARYLIC_UP2STREAM_PLATE_AMP),
+ "UP2STREAM_MINI_V3": (MANUFACTURER_ARYLIC, MODELS_GENERIC),
+ "UP2STREAM_AMP_2P1": (MANUFACTURER_ARYLIC, MODELS_GENERIC),
+ "RP0014_A50C_S": (MANUFACTURER_ARYLIC, MODELS_GENERIC),
+ "ARYLIC_A30": (MANUFACTURER_ARYLIC, MODELS_GENERIC),
+ "ARYLIC_SUBWOOFER": (MANUFACTURER_ARYLIC, MODELS_GENERIC),
+ "ARYLIC_S50A": (MANUFACTURER_ARYLIC, MODELS_GENERIC),
+ "RP0010_D5_S": (MANUFACTURER_ARYLIC, MODELS_GENERIC),
+ "RP0001": (MANUFACTURER_ARYLIC, MODELS_GENERIC),
+ "RP0013_WA31S": (MANUFACTURER_ARYLIC, MODELS_GENERIC),
+ "RP0010_D5": (MANUFACTURER_ARYLIC, MODELS_GENERIC),
+ "RP0013_WA31S_S": (MANUFACTURER_ARYLIC, MODELS_GENERIC),
+ "RP0014_A50D_S": (MANUFACTURER_ARYLIC, MODELS_GENERIC),
+ "ARYLIC_A50TE": (MANUFACTURER_ARYLIC, MODELS_GENERIC),
+ "ARYLIC_A50N": (MANUFACTURER_ARYLIC, MODELS_GENERIC),
+ "iEAST-02": (MANUFACTURER_IEAST, MODELS_IEAST_AUDIOCAST_M5),
+ "WiiM_Amp_4layer": (MANUFACTURER_WIIM, MODELS_WIIM_AMP),
+ "Muzo_Mini": (MANUFACTURER_WIIM, MODELS_WIIM_MINI),
+ "GGMM_E2A": (MANUFACTURER_GGMM, MODELS_GGMM_GGMM_E2),
+ "A16": (MANUFACTURER_MEDION, MODELS_MEDION_MD_43970),
+}
+
def get_info_from_project(project: str) -> tuple[str, str]:
"""Get manufacturer and model info based on given project."""
- match project:
- case "SMART_ZONE4_AMP":
- return MANUFACTURER_ARTSOUND, MODELS_ARTSOUND_SMART_ZONE4
- case "SMART_HYDE":
- return MANUFACTURER_ARTSOUND, MODELS_ARTSOUND_SMART_HYDE
- case "ARYLIC_S50":
- return MANUFACTURER_ARYLIC, MODELS_ARYLIC_S50
- case "RP0016_S50PRO_S":
- return MANUFACTURER_ARYLIC, MODELS_ARYLIC_S50_PRO
- case "RP0011_WB60_S":
- return MANUFACTURER_ARYLIC, MODELS_ARYLIC_A30
- case "ARYLIC_A50S":
- return MANUFACTURER_ARYLIC, MODELS_ARYLIC_A50S
- case "UP2STREAM_AMP_V3":
- return MANUFACTURER_ARYLIC, MODELS_ARYLIC_UP2STREAM_AMP_V3
- case "UP2STREAM_AMP_V4":
- return MANUFACTURER_ARYLIC, MODELS_ARYLIC_UP2STREAM_AMP_V4
- case "UP2STREAM_PRO_V3":
- return MANUFACTURER_ARYLIC, MODELS_ARYLIC_UP2STREAM_PRO_V3
- case "iEAST-02":
- return MANUFACTURER_IEAST, MODELS_IEAST_AUDIOCAST_M5
- case _:
- return MANUFACTURER_GENERIC, MODELS_GENERIC
+ return PROJECTID_LOOKUP.get(project, (MANUFACTURER_GENERIC, MODELS_GENERIC))
async def async_get_client_session(hass: HomeAssistant) -> ClientSession:
diff --git a/homeassistant/components/linksys_smart/manifest.json b/homeassistant/components/linksys_smart/manifest.json
index 6200da5866d..4f099f81277 100644
--- a/homeassistant/components/linksys_smart/manifest.json
+++ b/homeassistant/components/linksys_smart/manifest.json
@@ -3,5 +3,6 @@
"name": "Linksys Smart Wi-Fi",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/linksys_smart",
- "iot_class": "local_polling"
+ "iot_class": "local_polling",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/linode/manifest.json b/homeassistant/components/linode/manifest.json
index bedd6c2d172..975747de86d 100644
--- a/homeassistant/components/linode/manifest.json
+++ b/homeassistant/components/linode/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/linode",
"iot_class": "cloud_polling",
"loggers": ["linode"],
+ "quality_scale": "legacy",
"requirements": ["linode-api==4.1.9b1"]
}
diff --git a/homeassistant/components/linux_battery/manifest.json b/homeassistant/components/linux_battery/manifest.json
index 12b49c18aee..39bd331e3a4 100644
--- a/homeassistant/components/linux_battery/manifest.json
+++ b/homeassistant/components/linux_battery/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/linux_battery",
"iot_class": "local_polling",
"loggers": ["batinfo"],
+ "quality_scale": "legacy",
"requirements": ["batinfo==0.4.2"]
}
diff --git a/homeassistant/components/lirc/manifest.json b/homeassistant/components/lirc/manifest.json
index 3cc5d453721..64dbee06390 100644
--- a/homeassistant/components/lirc/manifest.json
+++ b/homeassistant/components/lirc/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/lirc",
"iot_class": "local_push",
"loggers": ["lirc"],
+ "quality_scale": "legacy",
"requirements": ["python-lirc==1.2.3"]
}
diff --git a/homeassistant/components/litejet/manifest.json b/homeassistant/components/litejet/manifest.json
index 1df907029a9..cd2e5fda11a 100644
--- a/homeassistant/components/litejet/manifest.json
+++ b/homeassistant/components/litejet/manifest.json
@@ -7,7 +7,6 @@
"integration_type": "hub",
"iot_class": "local_push",
"loggers": ["pylitejet"],
- "quality_scale": "platinum",
"requirements": ["pylitejet==0.6.3"],
"single_config_entry": true
}
diff --git a/homeassistant/components/litterrobot/vacuum.py b/homeassistant/components/litterrobot/vacuum.py
index f5553bf5d49..bd00c328233 100644
--- a/homeassistant/components/litterrobot/vacuum.py
+++ b/homeassistant/components/litterrobot/vacuum.py
@@ -10,12 +10,9 @@ from pylitterbot.enums import LitterBoxStatus
import voluptuous as vol
from homeassistant.components.vacuum import (
- STATE_CLEANING,
- STATE_DOCKED,
- STATE_ERROR,
- STATE_PAUSED,
StateVacuumEntity,
StateVacuumEntityDescription,
+ VacuumActivity,
VacuumEntityFeature,
)
from homeassistant.core import HomeAssistant
@@ -29,16 +26,16 @@ from .entity import LitterRobotEntity
SERVICE_SET_SLEEP_MODE = "set_sleep_mode"
LITTER_BOX_STATUS_STATE_MAP = {
- LitterBoxStatus.CLEAN_CYCLE: STATE_CLEANING,
- LitterBoxStatus.EMPTY_CYCLE: STATE_CLEANING,
- LitterBoxStatus.CLEAN_CYCLE_COMPLETE: STATE_DOCKED,
- LitterBoxStatus.CAT_DETECTED: STATE_DOCKED,
- LitterBoxStatus.CAT_SENSOR_TIMING: STATE_DOCKED,
- LitterBoxStatus.DRAWER_FULL_1: STATE_DOCKED,
- LitterBoxStatus.DRAWER_FULL_2: STATE_DOCKED,
- LitterBoxStatus.READY: STATE_DOCKED,
- LitterBoxStatus.CAT_SENSOR_INTERRUPTED: STATE_PAUSED,
- LitterBoxStatus.OFF: STATE_DOCKED,
+ LitterBoxStatus.CLEAN_CYCLE: VacuumActivity.CLEANING,
+ LitterBoxStatus.EMPTY_CYCLE: VacuumActivity.CLEANING,
+ LitterBoxStatus.CLEAN_CYCLE_COMPLETE: VacuumActivity.DOCKED,
+ LitterBoxStatus.CAT_DETECTED: VacuumActivity.DOCKED,
+ LitterBoxStatus.CAT_SENSOR_TIMING: VacuumActivity.DOCKED,
+ LitterBoxStatus.DRAWER_FULL_1: VacuumActivity.DOCKED,
+ LitterBoxStatus.DRAWER_FULL_2: VacuumActivity.DOCKED,
+ LitterBoxStatus.READY: VacuumActivity.DOCKED,
+ LitterBoxStatus.CAT_SENSOR_INTERRUPTED: VacuumActivity.PAUSED,
+ LitterBoxStatus.OFF: VacuumActivity.DOCKED,
}
LITTER_BOX_ENTITY = StateVacuumEntityDescription(
@@ -78,9 +75,9 @@ class LitterRobotCleaner(LitterRobotEntity[LitterRobot], StateVacuumEntity):
)
@property
- def state(self) -> str:
+ def activity(self) -> VacuumActivity:
"""Return the state of the cleaner."""
- return LITTER_BOX_STATUS_STATE_MAP.get(self.robot.status, STATE_ERROR)
+ return LITTER_BOX_STATUS_STATE_MAP.get(self.robot.status, VacuumActivity.ERROR)
@property
def status(self) -> str:
diff --git a/homeassistant/components/livisi/__init__.py b/homeassistant/components/livisi/__init__.py
index 26e36e68efa..fc9e381a1c3 100644
--- a/homeassistant/components/livisi/__init__.py
+++ b/homeassistant/components/livisi/__init__.py
@@ -5,7 +5,7 @@ from __future__ import annotations
from typing import Final
from aiohttp import ClientConnectorError
-from aiolivisi import AioLivisi
+from livisi.aiolivisi import AioLivisi
from homeassistant import core
from homeassistant.config_entries import ConfigEntry
diff --git a/homeassistant/components/livisi/climate.py b/homeassistant/components/livisi/climate.py
index 56fe63d351f..3ecdcb486c0 100644
--- a/homeassistant/components/livisi/climate.py
+++ b/homeassistant/components/livisi/climate.py
@@ -4,7 +4,7 @@ from __future__ import annotations
from typing import Any
-from aiolivisi.const import CAPABILITY_CONFIG
+from livisi.const import CAPABILITY_CONFIG
from homeassistant.components.climate import (
ClimateEntity,
@@ -68,7 +68,6 @@ class LivisiClimate(LivisiEntity, ClimateEntity):
_attr_hvac_mode = HVACMode.HEAT
_attr_temperature_unit = UnitOfTemperature.CELSIUS
_attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE
- _enable_turn_on_off_backwards_compatibility = False
def __init__(
self,
diff --git a/homeassistant/components/livisi/config_flow.py b/homeassistant/components/livisi/config_flow.py
index 7317aec0abc..ce14c0e44e9 100644
--- a/homeassistant/components/livisi/config_flow.py
+++ b/homeassistant/components/livisi/config_flow.py
@@ -6,7 +6,8 @@ from contextlib import suppress
from typing import Any
from aiohttp import ClientConnectorError
-from aiolivisi import AioLivisi, errors as livisi_errors
+from livisi import errors as livisi_errors
+from livisi.aiolivisi import AioLivisi
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
diff --git a/homeassistant/components/livisi/coordinator.py b/homeassistant/components/livisi/coordinator.py
index 7cb5757310f..b8b282c2829 100644
--- a/homeassistant/components/livisi/coordinator.py
+++ b/homeassistant/components/livisi/coordinator.py
@@ -6,8 +6,9 @@ from datetime import timedelta
from typing import Any
from aiohttp import ClientConnectorError
-from aiolivisi import AioLivisi, LivisiEvent, Websocket
-from aiolivisi.errors import TokenExpiredException
+from livisi import LivisiEvent, Websocket
+from livisi.aiolivisi import AioLivisi
+from livisi.errors import TokenExpiredException
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_PASSWORD
diff --git a/homeassistant/components/livisi/entity.py b/homeassistant/components/livisi/entity.py
index 3160b8f288a..af588b0e360 100644
--- a/homeassistant/components/livisi/entity.py
+++ b/homeassistant/components/livisi/entity.py
@@ -5,7 +5,7 @@ from __future__ import annotations
from collections.abc import Mapping
from typing import Any
-from aiolivisi.const import CAPABILITY_MAP
+from livisi.const import CAPABILITY_MAP
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import callback
diff --git a/homeassistant/components/livisi/manifest.json b/homeassistant/components/livisi/manifest.json
index e6f46324ed8..1077cacf2c4 100644
--- a/homeassistant/components/livisi/manifest.json
+++ b/homeassistant/components/livisi/manifest.json
@@ -5,5 +5,5 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/livisi",
"iot_class": "local_polling",
- "requirements": ["aiolivisi==0.0.19"]
+ "requirements": ["livisi==0.0.24"]
}
diff --git a/homeassistant/components/llamalab_automate/manifest.json b/homeassistant/components/llamalab_automate/manifest.json
index 861b919f24b..4343d617e93 100644
--- a/homeassistant/components/llamalab_automate/manifest.json
+++ b/homeassistant/components/llamalab_automate/manifest.json
@@ -3,5 +3,6 @@
"name": "LlamaLab Automate",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/llamalab_automate",
- "iot_class": "cloud_push"
+ "iot_class": "cloud_push",
+ "quality_scale": "legacy"
}
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_file/strings.json b/homeassistant/components/local_file/strings.json
index abf31a6f94e..393cc5f2e46 100644
--- a/homeassistant/components/local_file/strings.json
+++ b/homeassistant/components/local_file/strings.json
@@ -39,8 +39,8 @@
},
"services": {
"update_file_path": {
- "name": "Updates file path",
- "description": "Use this action to change the file displayed by the camera.",
+ "name": "Update file path",
+ "description": "Changes the file displayed by the camera.",
"fields": {
"file_path": {
"name": "File path",
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/lock/__init__.py b/homeassistant/components/lock/__init__.py
index fad87145e00..39d5d3c350d 100644
--- a/homeassistant/components/lock/__init__.py
+++ b/homeassistant/components/lock/__init__.py
@@ -31,7 +31,6 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ServiceValidationError
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.deprecation import (
- DeprecatedConstantEnum,
all_with_deprecated_constants,
check_if_deprecated_constant,
dir_with_deprecated_constants,
@@ -67,10 +66,6 @@ class LockEntityFeature(IntFlag):
OPEN = 1
-# The SUPPORT_OPEN constant is deprecated as of Home Assistant 2022.5.
-# Please use the LockEntityFeature enum instead.
-_DEPRECATED_SUPPORT_OPEN = DeprecatedConstantEnum(LockEntityFeature.OPEN, "2025.1")
-
PROP_TO_ATTR = {"changed_by": ATTR_CHANGED_BY, "code_format": ATTR_CODE_FORMAT}
# mypy: disallow-any-generics
@@ -290,12 +285,7 @@ class LockEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
@cached_property
def supported_features(self) -> LockEntityFeature:
"""Return the list of supported features."""
- features = self._attr_supported_features
- if type(features) is int: # noqa: E721
- new_features = LockEntityFeature(features)
- self._report_deprecated_supported_features_values(new_features)
- return new_features
- return features
+ return self._attr_supported_features
async def async_internal_added_to_hass(self) -> None:
"""Call when the sensor entity is added to hass."""
diff --git a/homeassistant/components/logentries/manifest.json b/homeassistant/components/logentries/manifest.json
index ecf2d8a227c..e63e83aff00 100644
--- a/homeassistant/components/logentries/manifest.json
+++ b/homeassistant/components/logentries/manifest.json
@@ -3,5 +3,6 @@
"name": "Logentries",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/logentries",
- "iot_class": "cloud_push"
+ "iot_class": "cloud_push",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/london_air/manifest.json b/homeassistant/components/london_air/manifest.json
index 60eed8d83bd..653a951ae56 100644
--- a/homeassistant/components/london_air/manifest.json
+++ b/homeassistant/components/london_air/manifest.json
@@ -3,5 +3,6 @@
"name": "London Air",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/london_air",
- "iot_class": "cloud_polling"
+ "iot_class": "cloud_polling",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/london_underground/const.py b/homeassistant/components/london_underground/const.py
index 532f4333ba9..447ed4461f3 100644
--- a/homeassistant/components/london_underground/const.py
+++ b/homeassistant/components/london_underground/const.py
@@ -24,4 +24,10 @@ TUBE_LINES = [
"Piccadilly",
"Victoria",
"Waterloo & City",
+ "Liberty",
+ "Lioness",
+ "Mildmay",
+ "Suffragette",
+ "Weaver",
+ "Windrush",
]
diff --git a/homeassistant/components/london_underground/manifest.json b/homeassistant/components/london_underground/manifest.json
index eafc63c6ae7..94b993097c0 100644
--- a/homeassistant/components/london_underground/manifest.json
+++ b/homeassistant/components/london_underground/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/london_underground",
"iot_class": "cloud_polling",
"loggers": ["london_tube_status"],
+ "quality_scale": "legacy",
"requirements": ["london-tube-status==0.5"]
}
diff --git a/homeassistant/components/lookin/climate.py b/homeassistant/components/lookin/climate.py
index fadeb6d16fa..051a18c9a32 100644
--- a/homeassistant/components/lookin/climate.py
+++ b/homeassistant/components/lookin/climate.py
@@ -107,7 +107,6 @@ class ConditionerEntity(LookinCoordinatorEntity, ClimateEntity):
_attr_min_temp = MIN_TEMP
_attr_max_temp = MAX_TEMP
_attr_target_temperature_step = PRECISION_WHOLE
- _enable_turn_on_off_backwards_compatibility = False
def __init__(
self,
diff --git a/homeassistant/components/lookin/config_flow.py b/homeassistant/components/lookin/config_flow.py
index e2d2c3f2625..aaf98a06fa8 100644
--- a/homeassistant/components/lookin/config_flow.py
+++ b/homeassistant/components/lookin/config_flow.py
@@ -97,7 +97,10 @@ class LookinFlowHandler(ConfigFlow, domain=DOMAIN):
if user_input is None:
return self.async_show_form(
step_id="discovery_confirm",
- description_placeholders={"name": self._name, "host": self._host},
+ description_placeholders={
+ "name": self._name or "LOOKin",
+ "host": self._host,
+ },
)
return self.async_create_entry(
diff --git a/homeassistant/components/luci/manifest.json b/homeassistant/components/luci/manifest.json
index 597aad30648..a8df2c63df4 100644
--- a/homeassistant/components/luci/manifest.json
+++ b/homeassistant/components/luci/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/luci",
"iot_class": "local_polling",
"loggers": ["openwrt_luci_rpc"],
+ "quality_scale": "legacy",
"requirements": ["openwrt-luci-rpc==1.1.17"]
}
diff --git a/homeassistant/components/luftdaten/manifest.json b/homeassistant/components/luftdaten/manifest.json
index 96927bdd4a8..bafffe4d6ae 100644
--- a/homeassistant/components/luftdaten/manifest.json
+++ b/homeassistant/components/luftdaten/manifest.json
@@ -7,6 +7,5 @@
"integration_type": "device",
"iot_class": "cloud_polling",
"loggers": ["luftdaten"],
- "quality_scale": "gold",
"requirements": ["luftdaten==0.7.4"]
}
diff --git a/homeassistant/components/lutron/fan.py b/homeassistant/components/lutron/fan.py
index dc881b393de..7db8b12c8d0 100644
--- a/homeassistant/components/lutron/fan.py
+++ b/homeassistant/components/lutron/fan.py
@@ -51,7 +51,6 @@ class LutronFan(LutronDevice, FanEntity):
)
_lutron_device: Output
_prev_percentage: int | None = None
- _enable_turn_on_off_backwards_compatibility = False
def set_percentage(self, percentage: int) -> None:
"""Set the speed of the fan, as a percentage."""
diff --git a/homeassistant/components/lutron_caseta/fan.py b/homeassistant/components/lutron_caseta/fan.py
index e2bf7f15098..69167929e14 100644
--- a/homeassistant/components/lutron_caseta/fan.py
+++ b/homeassistant/components/lutron_caseta/fan.py
@@ -50,7 +50,6 @@ class LutronCasetaFan(LutronCasetaUpdatableEntity, FanEntity):
| FanEntityFeature.TURN_ON
)
_attr_speed_count = len(ORDERED_NAMED_FAN_SPEEDS)
- _enable_turn_on_off_backwards_compatibility = False
@property
def percentage(self) -> int | None:
diff --git a/homeassistant/components/lutron_caseta/manifest.json b/homeassistant/components/lutron_caseta/manifest.json
index e96778f0a31..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.21.1"],
+ "requirements": ["pylutron-caseta==0.23.0"],
"zeroconf": [
{
"type": "_lutron._tcp.local.",
diff --git a/homeassistant/components/lw12wifi/manifest.json b/homeassistant/components/lw12wifi/manifest.json
index d8b2290b234..683498f2056 100644
--- a/homeassistant/components/lw12wifi/manifest.json
+++ b/homeassistant/components/lw12wifi/manifest.json
@@ -4,5 +4,6 @@
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/lw12wifi",
"iot_class": "local_polling",
+ "quality_scale": "legacy",
"requirements": ["lw12==0.9.2"]
}
diff --git a/homeassistant/components/lyric/climate.py b/homeassistant/components/lyric/climate.py
index bf8e17527e8..87b5d566bb8 100644
--- a/homeassistant/components/lyric/climate.py
+++ b/homeassistant/components/lyric/climate.py
@@ -174,7 +174,6 @@ class LyricClimate(LyricDeviceEntity, ClimateEntity):
PRESET_TEMPORARY_HOLD,
PRESET_VACATION_HOLD,
]
- _enable_turn_on_off_backwards_compatibility = False
def __init__(
self,
diff --git a/homeassistant/components/lyric/manifest.json b/homeassistant/components/lyric/manifest.json
index 8bed909ace2..cca69969f70 100644
--- a/homeassistant/components/lyric/manifest.json
+++ b/homeassistant/components/lyric/manifest.json
@@ -21,6 +21,5 @@
"documentation": "https://www.home-assistant.io/integrations/lyric",
"iot_class": "cloud_polling",
"loggers": ["aiolyric"],
- "quality_scale": "silver",
"requirements": ["aiolyric==2.0.1"]
}
diff --git a/homeassistant/components/madvr/strings.json b/homeassistant/components/madvr/strings.json
index 06851efa2c8..1a4f0f79aae 100644
--- a/homeassistant/components/madvr/strings.json
+++ b/homeassistant/components/madvr/strings.json
@@ -28,12 +28,12 @@
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
- "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
+ "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
+ "set_up_new_device": "A new device was detected. Please set it up as a new entity instead of reconfiguring."
},
"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.",
- "set_up_new_device": "A new device was detected. Please set it up as a new entity instead of reconfiguring."
+ "no_mac": "A MAC address was not found. It required to identify the device. Please ensure your device is connectable."
}
},
"entity": {
diff --git a/homeassistant/components/manual_mqtt/manifest.json b/homeassistant/components/manual_mqtt/manifest.json
index d4adcaf3bc9..bf2fccb62ae 100644
--- a/homeassistant/components/manual_mqtt/manifest.json
+++ b/homeassistant/components/manual_mqtt/manifest.json
@@ -4,5 +4,6 @@
"codeowners": [],
"dependencies": ["mqtt"],
"documentation": "https://www.home-assistant.io/integrations/manual_mqtt",
- "iot_class": "local_push"
+ "iot_class": "local_push",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/marytts/manifest.json b/homeassistant/components/marytts/manifest.json
index bbf23327547..814d3c64925 100644
--- a/homeassistant/components/marytts/manifest.json
+++ b/homeassistant/components/marytts/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/marytts",
"iot_class": "local_push",
"loggers": ["speak2mary"],
+ "quality_scale": "legacy",
"requirements": ["speak2mary==1.4.0"]
}
diff --git a/homeassistant/components/mastodon/__init__.py b/homeassistant/components/mastodon/__init__.py
index e8d23434248..f7f974ffbb0 100644
--- a/homeassistant/components/mastodon/__init__.py
+++ b/homeassistant/components/mastodon/__init__.py
@@ -81,7 +81,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: MastodonConfigEntry) ->
)
-async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_migrate_entry(hass: HomeAssistant, entry: MastodonConfigEntry) -> bool:
"""Migrate old config."""
if entry.version == 1 and entry.minor_version == 1:
@@ -113,7 +113,7 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
return True
-def setup_mastodon(entry: ConfigEntry) -> tuple[Mastodon, dict, dict]:
+def setup_mastodon(entry: MastodonConfigEntry) -> tuple[Mastodon, dict, dict]:
"""Get mastodon details."""
client = create_mastodon_client(
entry.data[CONF_BASE_URL],
diff --git a/homeassistant/components/mastodon/config_flow.py b/homeassistant/components/mastodon/config_flow.py
index 7c0985570f7..1b93cbecd98 100644
--- a/homeassistant/components/mastodon/config_flow.py
+++ b/homeassistant/components/mastodon/config_flow.py
@@ -8,13 +8,8 @@ from mastodon.Mastodon import MastodonNetworkError, MastodonUnauthorizedError
import voluptuous as vol
from yarl import URL
-from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult
-from homeassistant.const import (
- CONF_ACCESS_TOKEN,
- CONF_CLIENT_ID,
- CONF_CLIENT_SECRET,
- CONF_NAME,
-)
+from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
+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(
@@ -53,7 +48,6 @@ class MastodonConfigFlow(ConfigFlow, domain=DOMAIN):
VERSION = 1
MINOR_VERSION = 2
- config_entry: ConfigEntry
def check_connection(
self,
@@ -131,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..bdfdbbf6e99 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")
diff --git a/homeassistant/components/mastodon/quality_scale.yaml b/homeassistant/components/mastodon/quality_scale.yaml
new file mode 100644
index 00000000000..86702095e95
--- /dev/null
+++ b/homeassistant/components/mastodon/quality_scale.yaml
@@ -0,0 +1,99 @@
+rules:
+ # Bronze
+ action-setup: done
+ appropriate-polling: done
+ brands: done
+ common-modules: done
+ config-flow-test-coverage: done
+ config-flow: done
+ dependency-transparency:
+ status: todo
+ comment: |
+ Mastodon.py does not have CI build/publish.
+ 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 do not explicitly subscribe to events.
+ entity-unique-id: done
+ has-entity-name: done
+ runtime-data: done
+ test-before-configure: done
+ test-before-setup: done
+ unique-config-entry: done
+
+ # Silver
+ action-exceptions:
+ status: todo
+ comment: |
+ Legacy Notify needs rewriting once Notify architecture stabilizes.
+ config-entry-unloading: done
+ docs-configuration-parameters:
+ status: exempt
+ comment: |
+ There are no configuration options.
+ docs-installation-parameters: done
+ entity-unavailable: done
+ integration-owner: done
+ log-when-unavailable: done
+ parallel-updates:
+ status: todo
+ comment: |
+ Does not set parallel-updates on notify platform.
+ reauthentication-flow:
+ status: todo
+ comment: |
+ Waiting to move to oAuth.
+ test-coverage:
+ status: todo
+ comment: |
+ Legacy Notify needs rewriting once Notify architecture stabilizes.
+
+ # Gold
+ devices: done
+ diagnostics: done
+ discovery-update-info:
+ status: exempt
+ comment: |
+ Web service does not support discovery.
+ discovery:
+ status: exempt
+ comment: |
+ Web service does not support discovery.
+ docs-data-update: done
+ docs-examples: done
+ docs-known-limitations: todo
+ docs-supported-devices: done
+ docs-supported-functions: done
+ docs-troubleshooting: todo
+ docs-use-cases: todo
+ dynamic-devices:
+ status: exempt
+ comment: |
+ This integration connects to a single web service.
+ entity-category: done
+ entity-device-class: done
+ entity-disabled-by-default: done
+ entity-translations: done
+ exception-translations: todo
+ icon-translations: done
+ reconfiguration-flow:
+ status: todo
+ comment: |
+ Waiting to move to OAuth.
+ repair-issues: done
+ stale-devices:
+ status: exempt
+ comment: |
+ Web service does not go stale.
+
+ # Platinum
+ async-dependency: todo
+ inject-websession: todo
+ strict-typing:
+ status: todo
+ comment: |
+ Requirement 'Mastodon.py==1.8.1' appears untyped
diff --git a/homeassistant/components/mastodon/sensor.py b/homeassistant/components/mastodon/sensor.py
index 12acfc04743..1bb59ad7c05 100644
--- a/homeassistant/components/mastodon/sensor.py
+++ b/homeassistant/components/mastodon/sensor.py
@@ -23,6 +23,9 @@ from .const import (
)
from .entity import MastodonEntity
+# Coordinator is used to centralize the data updates
+PARALLEL_UPDATES = 0
+
@dataclass(frozen=True, kw_only=True)
class MastodonSensorEntityDescription(SensorEntityDescription):
@@ -35,21 +38,18 @@ ENTITY_DESCRIPTIONS = (
MastodonSensorEntityDescription(
key="followers",
translation_key="followers",
- native_unit_of_measurement="accounts",
state_class=SensorStateClass.TOTAL,
value_fn=lambda data: data.get(ACCOUNT_FOLLOWERS_COUNT),
),
MastodonSensorEntityDescription(
key="following",
translation_key="following",
- native_unit_of_measurement="accounts",
state_class=SensorStateClass.TOTAL,
value_fn=lambda data: data.get(ACCOUNT_FOLLOWING_COUNT),
),
MastodonSensorEntityDescription(
key="posts",
translation_key="posts",
- native_unit_of_measurement="posts",
state_class=SensorStateClass.TOTAL,
value_fn=lambda data: data.get(ACCOUNT_STATUSES_COUNT),
),
diff --git a/homeassistant/components/mastodon/strings.json b/homeassistant/components/mastodon/strings.json
index fd4dd890b37..9df94ecf204 100644
--- a/homeassistant/components/mastodon/strings.json
+++ b/homeassistant/components/mastodon/strings.json
@@ -9,7 +9,10 @@
"access_token": "[%key:common::config_flow::data::access_token%]"
},
"data_description": {
- "base_url": "The URL of your Mastodon instance e.g. https://mastodon.social."
+ "base_url": "The URL of your Mastodon instance e.g. https://mastodon.social.",
+ "client_id": "The client key for the application created within your Mastodon account.",
+ "client_secret": "The client secret for the application created within your Mastodon account.",
+ "access_token": "The access token for the application created within your Mastodon account."
}
}
},
@@ -22,30 +25,19 @@
"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": {
- "name": "Followers"
+ "name": "Followers",
+ "unit_of_measurement": "accounts"
},
"following": {
- "name": "Following"
+ "name": "Following",
+ "unit_of_measurement": "[%key:component::mastodon::entity::sensor::followers::unit_of_measurement%]"
},
"posts": {
- "name": "Posts"
+ "name": "Posts",
+ "unit_of_measurement": "posts"
}
}
}
diff --git a/homeassistant/components/matrix/manifest.json b/homeassistant/components/matrix/manifest.json
index 520bd0550cc..b173a2c850b 100644
--- a/homeassistant/components/matrix/manifest.json
+++ b/homeassistant/components/matrix/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/matrix",
"iot_class": "cloud_push",
"loggers": ["matrix_client"],
- "requirements": ["matrix-nio==0.25.2", "Pillow==10.4.0"]
+ "quality_scale": "legacy",
+ "requirements": ["matrix-nio==0.25.2", "Pillow==11.1.0"]
}
diff --git a/homeassistant/components/matter/adapter.py b/homeassistant/components/matter/adapter.py
index 475e4a44538..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
@@ -45,6 +45,7 @@ class MatterAdapter:
self.hass = hass
self.config_entry = config_entry
self.platform_handlers: dict[Platform, AddEntitiesCallback] = {}
+ self.discovered_entities: set[str] = set()
def register_platform_handler(
self, platform: Platform, add_entities: AddEntitiesCallback
@@ -54,23 +55,19 @@ class MatterAdapter:
async def setup_nodes(self) -> None:
"""Set up all existing nodes and subscribe to new nodes."""
- initialized_nodes: set[int] = set()
for node in self.matter_client.get_nodes():
- initialized_nodes.add(node.node_id)
self._setup_node(node)
def node_added_callback(event: EventType, node: MatterNode) -> None:
"""Handle node added event."""
- initialized_nodes.add(node.node_id)
self._setup_node(node)
def node_updated_callback(event: EventType, node: MatterNode) -> None:
"""Handle node updated event."""
- if node.node_id in initialized_nodes:
- return
if not node.available:
return
- initialized_nodes.add(node.node_id)
+ # We always run the discovery logic again,
+ # because the firmware version could have been changed or features added.
self._setup_node(node)
def endpoint_added_callback(event: EventType, data: dict[str, int]) -> None:
@@ -165,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,
)
@@ -237,11 +234,20 @@ class MatterAdapter:
self._create_device_registry(endpoint)
# run platform discovery from device type instances
for entity_info in async_discover_entities(endpoint):
+ discovery_key = (
+ f"{entity_info.platform}_{endpoint.node.node_id}_{endpoint.endpoint_id}_"
+ f"{entity_info.primary_attribute.cluster_id}_"
+ f"{entity_info.primary_attribute.attribute_id}_"
+ f"{entity_info.entity_description.key}"
+ )
+ if discovery_key in self.discovered_entities:
+ continue
LOGGER.debug(
"Creating %s entity for %s",
entity_info.platform,
entity_info.primary_attribute,
)
+ self.discovered_entities.add(discovery_key)
new_entity = entity_info.entity_class(
self.matter_client, endpoint, entity_info
)
diff --git a/homeassistant/components/matter/binary_sensor.py b/homeassistant/components/matter/binary_sensor.py
index 875b063dc88..6882078a712 100644
--- a/homeassistant/components/matter/binary_sensor.py
+++ b/homeassistant/components/matter/binary_sensor.py
@@ -159,6 +159,7 @@ DISCOVERY_SCHEMAS = [
),
entity_class=MatterBinarySensor,
required_attributes=(clusters.DoorLock.Attributes.DoorState,),
+ featuremap_contains=clusters.DoorLock.Bitmaps.Feature.kDoorPositionSensor,
),
MatterDiscoverySchema(
platform=Platform.BINARY_SENSOR,
diff --git a/homeassistant/components/matter/button.py b/homeassistant/components/matter/button.py
index 918b334061b..153124a4f7e 100644
--- a/homeassistant/components/matter/button.py
+++ b/homeassistant/components/matter/button.py
@@ -69,6 +69,7 @@ DISCOVERY_SCHEMAS = [
entity_class=MatterCommandButton,
required_attributes=(clusters.Identify.Attributes.AcceptedCommandList,),
value_contains=clusters.Identify.Commands.Identify.command_id,
+ allow_multi=True,
),
MatterDiscoverySchema(
platform=Platform.BUTTON,
diff --git a/homeassistant/components/matter/climate.py b/homeassistant/components/matter/climate.py
index cdbe1e36245..0378d0ea226 100644
--- a/homeassistant/components/matter/climate.py
+++ b/homeassistant/components/matter/climate.py
@@ -187,7 +187,7 @@ class MatterClimate(MatterEntity, ClimateEntity):
_attr_temperature_unit: str = UnitOfTemperature.CELSIUS
_attr_hvac_mode: HVACMode = HVACMode.OFF
_feature_map: int | None = None
- _enable_turn_on_off_backwards_compatibility = False
+
_platform_translation_key = "thermostat"
async def async_set_temperature(self, **kwargs: Any) -> None:
diff --git a/homeassistant/components/matter/const.py b/homeassistant/components/matter/const.py
index a0e160a6c01..8018d5e09ed 100644
--- a/homeassistant/components/matter/const.py
+++ b/homeassistant/components/matter/const.py
@@ -13,3 +13,5 @@ LOGGER = logging.getLogger(__package__)
# prefixes to identify device identifier id types
ID_TYPE_DEVICE_ID = "deviceid"
ID_TYPE_SERIAL = "serial"
+
+FEATUREMAP_ATTRIBUTE_ID = 65532
diff --git a/homeassistant/components/matter/discovery.py b/homeassistant/components/matter/discovery.py
index 5b07f9a069f..3b9fb0b8a94 100644
--- a/homeassistant/components/matter/discovery.py
+++ b/homeassistant/components/matter/discovery.py
@@ -13,6 +13,7 @@ from homeassistant.core import callback
from .binary_sensor import DISCOVERY_SCHEMAS as BINARY_SENSOR_SCHEMAS
from .button import DISCOVERY_SCHEMAS as BUTTON_SCHEMAS
from .climate import DISCOVERY_SCHEMAS as CLIMATE_SENSOR_SCHEMAS
+from .const import FEATUREMAP_ATTRIBUTE_ID
from .cover import DISCOVERY_SCHEMAS as COVER_SCHEMAS
from .event import DISCOVERY_SCHEMAS as EVENT_SCHEMAS
from .fan import DISCOVERY_SCHEMAS as FAN_SCHEMAS
@@ -121,12 +122,24 @@ 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 (
- (primary_attribute := next((x for x in schema.required_attributes), None))
- is None
- or (value := endpoint.get_attribute_value(None, primary_attribute)) is None
- or not isinstance(value, list)
- or schema.value_contains not in value
+ 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(
+ int(
+ endpoint.get_attribute_value(
+ primary_attribute.cluster_id, FEATUREMAP_ATTRIBUTE_ID
+ )
+ )
+ & schema.featuremap_contains
+ )
):
continue
@@ -147,6 +160,7 @@ def async_discover_entities(
attributes_to_watch=attributes_to_watch,
entity_description=schema.entity_description,
entity_class=schema.entity_class,
+ discovery_schema=schema,
)
# prevent re-discovery of the primary attribute if not allowed
diff --git a/homeassistant/components/matter/entity.py b/homeassistant/components/matter/entity.py
index 7c378fe465e..50a0f2b1fee 100644
--- a/homeassistant/components/matter/entity.py
+++ b/homeassistant/components/matter/entity.py
@@ -16,9 +16,10 @@ from propcache import cached_property
from homeassistant.core import callback
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, ID_TYPE_DEVICE_ID
+from .const import DOMAIN, FEATUREMAP_ATTRIBUTE_ID, ID_TYPE_DEVICE_ID
from .helpers import get_device_id
if TYPE_CHECKING:
@@ -140,6 +141,19 @@ class MatterEntity(Entity):
node_filter=self._endpoint.node.node_id,
)
)
+ # subscribe to FeatureMap attribute (as that can dynamically change)
+ self._unsubscribes.append(
+ self.matter_client.subscribe_events(
+ callback=self._on_featuremap_update,
+ event_filter=EventType.ATTRIBUTE_UPDATED,
+ node_filter=self._endpoint.node.node_id,
+ attr_path_filter=create_attribute_path(
+ endpoint=self._endpoint.endpoint_id,
+ cluster_id=self._entity_info.primary_attribute.cluster_id,
+ attribute_id=FEATUREMAP_ATTRIBUTE_ID,
+ ),
+ )
+ )
@cached_property
def name(self) -> str | UndefinedType | None:
@@ -159,6 +173,29 @@ class MatterEntity(Entity):
self._update_from_device()
self.async_write_ha_state()
+ @callback
+ def _on_featuremap_update(
+ self, event: EventType, data: tuple[int, str, int] | None
+ ) -> None:
+ """Handle FeatureMap attribute updates."""
+ if data is None:
+ return
+ new_value = data[2]
+ # handle edge case where a Feature is removed from a cluster
+ if (
+ self._entity_info.discovery_schema.featuremap_contains is not None
+ and not bool(
+ new_value & self._entity_info.discovery_schema.featuremap_contains
+ )
+ ):
+ # this entity is no longer supported by the device
+ ent_reg = er.async_get(self.hass)
+ ent_reg.async_remove(self.entity_id)
+
+ return
+ # all other cases, just update the entity
+ self._on_matter_event(event, data)
+
@callback
def _update_from_device(self) -> None:
"""Update data from Matter device."""
diff --git a/homeassistant/components/matter/fan.py b/homeassistant/components/matter/fan.py
index 51c2fb0c882..593693dbbf9 100644
--- a/homeassistant/components/matter/fan.py
+++ b/homeassistant/components/matter/fan.py
@@ -58,7 +58,7 @@ class MatterFan(MatterEntity, FanEntity):
_last_known_preset_mode: str | None = None
_last_known_percentage: int = 0
- _enable_turn_on_off_backwards_compatibility = False
+
_feature_map: int | None = None
_platform_translation_key = "fan"
diff --git a/homeassistant/components/matter/icons.json b/homeassistant/components/matter/icons.json
index 32c9f057e47..ef29601b831 100644
--- a/homeassistant/components/matter/icons.json
+++ b/homeassistant/components/matter/icons.json
@@ -43,6 +43,9 @@
"air_quality": {
"default": "mdi:air-filter"
},
+ "bat_replacement_description": {
+ "default": "mdi:battery-sync"
+ },
"hepa_filter_condition": {
"default": "mdi:filter-check"
},
@@ -54,6 +57,9 @@
},
"valve_position": {
"default": "mdi:valve"
+ },
+ "battery_replacement_description": {
+ "default": "mdi:battery-sync-outline"
}
}
}
diff --git a/homeassistant/components/matter/light.py b/homeassistant/components/matter/light.py
index 6d184bcc01f..5a2768d1d50 100644
--- a/homeassistant/components/matter/light.py
+++ b/homeassistant/components/matter/light.py
@@ -9,10 +9,12 @@ from matter_server.client.models import device_types
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
- ATTR_COLOR_TEMP,
+ ATTR_COLOR_TEMP_KELVIN,
ATTR_HS_COLOR,
ATTR_TRANSITION,
ATTR_XY_COLOR,
+ DEFAULT_MAX_KELVIN,
+ DEFAULT_MIN_KELVIN,
ColorMode,
LightEntity,
LightEntityDescription,
@@ -23,6 +25,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
+from homeassistant.util import color as color_util
from .const import LOGGER
from .entity import MatterEntity
@@ -37,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
@@ -90,6 +93,8 @@ class MatterLight(MatterEntity, LightEntity):
_supports_color_temperature = False
_transitions_disabled = False
_platform_translation_key = "light"
+ _attr_min_color_temp_kelvin = DEFAULT_MIN_KELVIN
+ _attr_max_color_temp_kelvin = DEFAULT_MAX_KELVIN
async def _set_xy_color(
self, xy_color: tuple[float, float], transition: float = 0.0
@@ -131,12 +136,16 @@ class MatterLight(MatterEntity, LightEntity):
)
)
- async def _set_color_temp(self, color_temp: int, transition: float = 0.0) -> None:
+ async def _set_color_temp(
+ self, color_temp_kelvin: int, transition: float = 0.0
+ ) -> None:
"""Set color temperature."""
-
+ color_temp_mired = color_util.color_temperature_kelvin_to_mired(
+ color_temp_kelvin
+ )
await self.send_device_command(
clusters.ColorControl.Commands.MoveToColorTemperature(
- colorTemperatureMireds=color_temp,
+ colorTemperatureMireds=color_temp_mired,
# transition in matter is measured in tenths of a second
transitionTime=int(transition * 10),
# allow setting the color while the light is off,
@@ -286,7 +295,7 @@ class MatterLight(MatterEntity, LightEntity):
hs_color = kwargs.get(ATTR_HS_COLOR)
xy_color = kwargs.get(ATTR_XY_COLOR)
- color_temp = kwargs.get(ATTR_COLOR_TEMP)
+ color_temp_kelvin = kwargs.get(ATTR_COLOR_TEMP_KELVIN)
brightness = kwargs.get(ATTR_BRIGHTNESS)
transition = kwargs.get(ATTR_TRANSITION, 0)
if self._transitions_disabled:
@@ -298,10 +307,10 @@ class MatterLight(MatterEntity, LightEntity):
elif xy_color is not None and ColorMode.XY in self.supported_color_modes:
await self._set_xy_color(xy_color, transition)
elif (
- color_temp is not None
+ color_temp_kelvin is not None
and ColorMode.COLOR_TEMP in self.supported_color_modes
):
- await self._set_color_temp(color_temp, transition)
+ await self._set_color_temp(color_temp_kelvin, transition)
if brightness is not None and self._supports_brightness:
await self._set_brightness(brightness, transition)
@@ -346,21 +355,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
@@ -368,12 +377,16 @@ class MatterLight(MatterEntity, LightEntity):
clusters.ColorControl.Attributes.ColorTempPhysicalMinMireds
)
if min_mireds > 0:
- self._attr_min_mireds = min_mireds
+ self._attr_max_color_temp_kelvin = (
+ color_util.color_temperature_mired_to_kelvin(min_mireds)
+ )
max_mireds = self.get_matter_attribute_value(
clusters.ColorControl.Attributes.ColorTempPhysicalMaxMireds
)
- if min_mireds > 0:
- self._attr_max_mireds = max_mireds
+ if max_mireds > 0:
+ self._attr_min_color_temp_kelvin = (
+ color_util.color_temperature_mired_to_kelvin(max_mireds)
+ )
supported_color_modes = filter_supported_color_modes(supported_color_modes)
self._attr_supported_color_modes = supported_color_modes
@@ -399,8 +412,13 @@ class MatterLight(MatterEntity, LightEntity):
if self._supports_brightness:
self._attr_brightness = self._get_brightness()
- if self._supports_color_temperature:
- self._attr_color_temp = self._get_color_temperature()
+ if (
+ self._supports_color_temperature
+ and (color_temperature := self._get_color_temperature()) > 0
+ ):
+ self._attr_color_temp_kelvin = color_util.color_temperature_mired_to_kelvin(
+ color_temperature
+ )
if self._supports_color:
self._attr_color_mode = color_mode = self._get_color_mode()
@@ -414,7 +432,7 @@ class MatterLight(MatterEntity, LightEntity):
and color_mode == ColorMode.XY
):
self._attr_xy_color = self._get_xy_color()
- elif self._attr_color_temp is not None:
+ elif self._attr_color_temp_kelvin is not None:
self._attr_color_mode = ColorMode.COLOR_TEMP
elif self._attr_brightness is not None:
self._attr_color_mode = ColorMode.BRIGHTNESS
diff --git a/homeassistant/components/matter/lock.py b/homeassistant/components/matter/lock.py
index c5e10554fe7..d69d0fd3dab 100644
--- a/homeassistant/components/matter/lock.py
+++ b/homeassistant/components/matter/lock.py
@@ -206,6 +206,5 @@ DISCOVERY_SCHEMAS = [
),
entity_class=MatterLock,
required_attributes=(clusters.DoorLock.Attributes.LockState,),
- optional_attributes=(clusters.DoorLock.Attributes.DoorState,),
),
]
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 f04c0f7e107..a00963c825a 100644
--- a/homeassistant/components/matter/models.py
+++ b/homeassistant/components/matter/models.py
@@ -51,6 +51,9 @@ class MatterEntityInfo:
# entity class to use to instantiate the entity
entity_class: type
+ # the original discovery schema used to create this entity
+ discovery_schema: MatterDiscoverySchema
+
@property
def primary_attribute(self) -> type[ClusterAttributeDescriptor]:
"""Return Primary Attribute belonging to the entity."""
@@ -113,6 +116,10 @@ class MatterDiscoverySchema:
# 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
+
# [optional] bool to specify if this primary value may be discovered
# by multiple platforms
allow_multi: bool = False
diff --git a/homeassistant/components/matter/sensor.py b/homeassistant/components/matter/sensor.py
index e10f081d497..847c9439b81 100644
--- a/homeassistant/components/matter/sensor.py
+++ b/homeassistant/components/matter/sensor.py
@@ -222,15 +222,29 @@ DISCOVERY_SCHEMAS = [
platform=Platform.SENSOR,
entity_description=MatterSensorEntityDescription(
key="PowerSourceBatVoltage",
- native_unit_of_measurement=UnitOfElectricPotential.VOLT,
+ native_unit_of_measurement=UnitOfElectricPotential.MILLIVOLT,
+ suggested_unit_of_measurement=UnitOfElectricPotential.VOLT,
device_class=SensorDeviceClass.VOLTAGE,
entity_category=EntityCategory.DIAGNOSTIC,
- measurement_to_ha=lambda x: x / 1000,
state_class=SensorStateClass.MEASUREMENT,
),
entity_class=MatterSensor,
required_attributes=(clusters.PowerSource.Attributes.BatVoltage,),
),
+ MatterDiscoverySchema(
+ platform=Platform.SENSOR,
+ entity_description=MatterSensorEntityDescription(
+ key="PowerSourceBatReplacementDescription",
+ translation_key="battery_replacement_description",
+ native_unit_of_measurement=None,
+ device_class=None,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
+ entity_class=MatterSensor,
+ required_attributes=(
+ clusters.PowerSource.Attributes.BatReplacementDescription,
+ ),
+ ),
MatterDiscoverySchema(
platform=Platform.SENSOR,
entity_description=MatterSensorEntityDescription(
@@ -566,10 +580,10 @@ DISCOVERY_SCHEMAS = [
key="ElectricalPowerMeasurementWatt",
device_class=SensorDeviceClass.POWER,
entity_category=EntityCategory.DIAGNOSTIC,
- native_unit_of_measurement=UnitOfPower.WATT,
+ native_unit_of_measurement=UnitOfPower.MILLIWATT,
+ suggested_unit_of_measurement=UnitOfPower.WATT,
suggested_display_precision=2,
state_class=SensorStateClass.MEASUREMENT,
- measurement_to_ha=lambda x: x / 1000,
),
entity_class=MatterSensor,
required_attributes=(
@@ -582,10 +596,10 @@ DISCOVERY_SCHEMAS = [
key="ElectricalPowerMeasurementVoltage",
device_class=SensorDeviceClass.VOLTAGE,
entity_category=EntityCategory.DIAGNOSTIC,
- native_unit_of_measurement=UnitOfElectricPotential.VOLT,
+ native_unit_of_measurement=UnitOfElectricPotential.MILLIVOLT,
+ suggested_unit_of_measurement=UnitOfElectricPotential.VOLT,
suggested_display_precision=0,
state_class=SensorStateClass.MEASUREMENT,
- measurement_to_ha=lambda x: x / 1000,
),
entity_class=MatterSensor,
required_attributes=(clusters.ElectricalPowerMeasurement.Attributes.Voltage,),
@@ -596,10 +610,10 @@ DISCOVERY_SCHEMAS = [
key="ElectricalPowerMeasurementActiveCurrent",
device_class=SensorDeviceClass.CURRENT,
entity_category=EntityCategory.DIAGNOSTIC,
- native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
+ native_unit_of_measurement=UnitOfElectricCurrent.MILLIAMPERE,
+ suggested_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
suggested_display_precision=2,
state_class=SensorStateClass.MEASUREMENT,
- measurement_to_ha=lambda x: x / 1000,
),
entity_class=MatterSensor,
required_attributes=(
@@ -612,11 +626,12 @@ DISCOVERY_SCHEMAS = [
key="ElectricalEnergyMeasurementCumulativeEnergyImported",
device_class=SensorDeviceClass.ENERGY,
entity_category=EntityCategory.DIAGNOSTIC,
- native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
+ native_unit_of_measurement=UnitOfEnergy.MILLIWATT_HOUR,
+ suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
suggested_display_precision=3,
state_class=SensorStateClass.TOTAL_INCREASING,
# id 0 of the EnergyMeasurementStruct is the cumulative energy (in mWh)
- measurement_to_ha=lambda x: x.energy / 1000000,
+ measurement_to_ha=lambda x: x.energy,
),
entity_class=MatterSensor,
required_attributes=(
diff --git a/homeassistant/components/matter/strings.json b/homeassistant/components/matter/strings.json
index 69fa68765b3..ca15538997e 100644
--- a/homeassistant/components/matter/strings.json
+++ b/homeassistant/components/matter/strings.json
@@ -245,6 +245,9 @@
},
"valve_position": {
"name": "Valve position"
+ },
+ "battery_replacement_description": {
+ "name": "Battery type"
}
},
"switch": {
diff --git a/homeassistant/components/matter/vacuum.py b/homeassistant/components/matter/vacuum.py
index 2ecd7128df6..e98e1ad0bbd 100644
--- a/homeassistant/components/matter/vacuum.py
+++ b/homeassistant/components/matter/vacuum.py
@@ -9,16 +9,13 @@ from chip.clusters import Objects as clusters
from matter_server.client.models import device_types
from homeassistant.components.vacuum import (
- STATE_CLEANING,
- STATE_DOCKED,
- STATE_ERROR,
- STATE_RETURNING,
StateVacuumEntity,
StateVacuumEntityDescription,
+ VacuumActivity,
VacuumEntityFeature,
)
from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import STATE_IDLE, Platform
+from homeassistant.const import Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
@@ -127,25 +124,25 @@ class MatterVacuum(MatterEntity, StateVacuumEntity):
operational_state: int = self.get_matter_attribute_value(
clusters.RvcOperationalState.Attributes.OperationalState
)
- state: str | None = None
+ state: VacuumActivity | None = None
if TYPE_CHECKING:
assert self._supported_run_modes is not None
if operational_state in (OperationalState.CHARGING, OperationalState.DOCKED):
- state = STATE_DOCKED
+ state = VacuumActivity.DOCKED
elif operational_state == OperationalState.SEEKING_CHARGER:
- state = STATE_RETURNING
+ state = VacuumActivity.RETURNING
elif operational_state in (
OperationalState.UNABLE_TO_COMPLETE_OPERATION,
OperationalState.UNABLE_TO_START_OR_RESUME,
):
- state = STATE_ERROR
+ state = VacuumActivity.ERROR
elif (run_mode := self._supported_run_modes.get(run_mode_raw)) is not None:
tags = {x.value for x in run_mode.modeTags}
if ModeTag.CLEANING in tags:
- state = STATE_CLEANING
+ state = VacuumActivity.CLEANING
elif ModeTag.IDLE in tags:
- state = STATE_IDLE
- self._attr_state = state
+ state = VacuumActivity.IDLE
+ self._attr_activity = state
@callback
def _calculate_features(self) -> None:
diff --git a/homeassistant/components/maxcube/climate.py b/homeassistant/components/maxcube/climate.py
index b14efbbe073..296da4f0ab4 100644
--- a/homeassistant/components/maxcube/climate.py
+++ b/homeassistant/components/maxcube/climate.py
@@ -73,7 +73,6 @@ class MaxCubeClimate(ClimateEntity):
| ClimateEntityFeature.TURN_OFF
| ClimateEntityFeature.TURN_ON
)
- _enable_turn_on_off_backwards_compatibility = False
def __init__(self, handler, device):
"""Initialize MAX! Cube ClimateEntity."""
@@ -172,8 +171,8 @@ class MaxCubeClimate(ClimateEntity):
else:
return None
- # Assume heating when valve is open
- if valve > 0:
+ # Assume heating when valve is open.
+ if valve:
return HVACAction.HEATING
return HVACAction.OFF if self.hvac_mode == HVACMode.OFF else HVACAction.IDLE
diff --git a/homeassistant/components/maxcube/manifest.json b/homeassistant/components/maxcube/manifest.json
index 6421686d2cf..d57ccacc5b1 100644
--- a/homeassistant/components/maxcube/manifest.json
+++ b/homeassistant/components/maxcube/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/maxcube",
"iot_class": "local_polling",
"loggers": ["maxcube"],
+ "quality_scale": "legacy",
"requirements": ["maxcube-api==0.4.3"]
}
diff --git a/homeassistant/components/mazda/manifest.json b/homeassistant/components/mazda/manifest.json
index 75a83a9f468..fcd39e11a10 100644
--- a/homeassistant/components/mazda/manifest.json
+++ b/homeassistant/components/mazda/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/mazda",
"integration_type": "system",
"iot_class": "cloud_polling",
+ "quality_scale": "legacy",
"requirements": []
}
diff --git a/homeassistant/components/mcp_server/__init__.py b/homeassistant/components/mcp_server/__init__.py
new file mode 100644
index 00000000000..e523f46228f
--- /dev/null
+++ b/homeassistant/components/mcp_server/__init__.py
@@ -0,0 +1,43 @@
+"""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
+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)
+ 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..8d68c6a868a
--- /dev/null
+++ b/homeassistant/components/mcp_server/config_flow.py
@@ -0,0 +1,63 @@
+"""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
+
+_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 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..1aa81f445a1
--- /dev/null
+++ b/homeassistant/components/mcp_server/const.py
@@ -0,0 +1,4 @@
+"""Constants for the Model Context Protocol Server integration."""
+
+DOMAIN = "mcp_server"
+TITLE = "Model Context Protocol Server"
diff --git a/homeassistant/components/mcp_server/http.py b/homeassistant/components/mcp_server/http.py
new file mode 100644
index 00000000000..da706d4a73b
--- /dev/null
+++ b/homeassistant/components/mcp_server/http.py
@@ -0,0 +1,170 @@
+"""Model Context Protocol transport portocol 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(body="Model Context Protocol server is not configured")
+ if len(config_entries) > 1:
+ raise HTTPNotFound(body="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 messagess 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(body=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(body="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/manifest.json b/homeassistant/components/mcp_server/manifest.json
new file mode 100644
index 00000000000..755d2c39065
--- /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.7.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..6f6622de9f7
--- /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
+
+_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.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/__init__.py b/homeassistant/components/mealie/__init__.py
index 443c8fdd991..5e1523b939a 100644
--- a/homeassistant/components/mealie/__init__.py
+++ b/homeassistant/components/mealie/__init__.py
@@ -52,9 +52,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: MealieConfigEntry) -> bo
about = await client.get_about()
version = create_version(about.version)
except MealieAuthenticationError as error:
- raise ConfigEntryAuthFailed from error
+ raise ConfigEntryAuthFailed(
+ translation_domain=DOMAIN,
+ translation_key="auth_failed",
+ ) from error
except MealieError as error:
- raise ConfigEntryNotReady(error) from error
+ raise ConfigEntryNotReady(
+ translation_domain=DOMAIN,
+ translation_key="setup_failed",
+ ) from error
if not version.valid:
LOGGER.warning(
diff --git a/homeassistant/components/mealie/calendar.py b/homeassistant/components/mealie/calendar.py
index 4c11c639c79..729bc16c6fd 100644
--- a/homeassistant/components/mealie/calendar.py
+++ b/homeassistant/components/mealie/calendar.py
@@ -13,6 +13,8 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .coordinator import MealieConfigEntry, MealieMealplanCoordinator
from .entity import MealieEntity
+PARALLEL_UPDATES = 0
+
async def async_setup_entry(
hass: HomeAssistant,
diff --git a/homeassistant/components/mealie/config_flow.py b/homeassistant/components/mealie/config_flow.py
index 2f90ceaf97a..2addd23284e 100644
--- a/homeassistant/components/mealie/config_flow.py
+++ b/homeassistant/components/mealie/config_flow.py
@@ -38,6 +38,10 @@ class MealieConfigFlow(ConfigFlow, domain=DOMAIN):
) -> tuple[dict[str, str], str | None]:
"""Check connection to the Mealie API."""
assert self.host is not None
+
+ if "/hassio/ingress/" in self.host:
+ return {"base": "ingress_url"}, None
+
client = MealieClient(
self.host,
token=api_token,
diff --git a/homeassistant/components/mealie/coordinator.py b/homeassistant/components/mealie/coordinator.py
index 051586e53c2..7d4f23d706e 100644
--- a/homeassistant/components/mealie/coordinator.py
+++ b/homeassistant/components/mealie/coordinator.py
@@ -23,7 +23,7 @@ from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
import homeassistant.util.dt as dt_util
-from .const import LOGGER
+from .const import DOMAIN, LOGGER
WEEK = timedelta(days=7)
@@ -53,7 +53,7 @@ class MealieDataUpdateCoordinator[_DataT](DataUpdateCoordinator[_DataT]):
super().__init__(
hass,
LOGGER,
- name=self._name,
+ name=f"Mealie {self._name}",
update_interval=self._update_interval,
)
self.client = client
@@ -63,9 +63,15 @@ class MealieDataUpdateCoordinator[_DataT](DataUpdateCoordinator[_DataT]):
try:
return await self._async_update_internal()
except MealieAuthenticationError as error:
- raise ConfigEntryAuthFailed from error
+ raise ConfigEntryAuthFailed(
+ translation_domain=DOMAIN,
+ translation_key="auth_failed",
+ ) from error
except MealieConnectionError as error:
- raise UpdateFailed(error) from error
+ raise UpdateFailed(
+ translation_domain=DOMAIN,
+ translation_key=f"update_failed_{self._name}",
+ ) from error
@abstractmethod
async def _async_update_internal(self) -> _DataT:
@@ -77,7 +83,7 @@ class MealieMealplanCoordinator(
):
"""Class to manage fetching Mealie data."""
- _name = "MealieMealplan"
+ _name = "mealplan"
_update_interval = timedelta(hours=1)
async def _async_update_internal(self) -> dict[MealplanEntryType, list[Mealplan]]:
@@ -106,7 +112,7 @@ class MealieShoppingListCoordinator(
):
"""Class to manage fetching Mealie Shopping list data."""
- _name = "MealieShoppingList"
+ _name = "shopping_list"
_update_interval = timedelta(minutes=5)
async def _async_update_internal(
@@ -130,7 +136,7 @@ class MealieShoppingListCoordinator(
class MealieStatisticsCoordinator(MealieDataUpdateCoordinator[Statistics]):
"""Class to manage fetching Mealie Statistics data."""
- _name = "MealieStatistics"
+ _name = "statistics"
_update_interval = timedelta(minutes=15)
async def _async_update_internal(
diff --git a/homeassistant/components/mealie/manifest.json b/homeassistant/components/mealie/manifest.json
index f594f1398e3..6e55abcdcad 100644
--- a/homeassistant/components/mealie/manifest.json
+++ b/homeassistant/components/mealie/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/mealie",
"integration_type": "service",
"iot_class": "local_polling",
- "requirements": ["aiomealie==0.9.3"]
+ "requirements": ["aiomealie==0.9.5"]
}
diff --git a/homeassistant/components/mealie/quality_scale.yaml b/homeassistant/components/mealie/quality_scale.yaml
new file mode 100644
index 00000000000..738c5b99d91
--- /dev/null
+++ b/homeassistant/components/mealie/quality_scale.yaml
@@ -0,0 +1,77 @@
+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: |
+ This integration does not have any configuration parameters.
+ 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: done
+ discovery-update-info: todo
+ discovery: todo
+ docs-data-update: done
+ docs-examples: done
+ docs-known-limitations: todo
+ docs-supported-devices: todo
+ docs-supported-functions: done
+ docs-troubleshooting: todo
+ docs-use-cases: todo
+ dynamic-devices:
+ status: done
+ comment: |
+ The integration adds new todo lists on runtime.
+ entity-category: done
+ entity-device-class: done
+ entity-disabled-by-default:
+ status: exempt
+ comment: |
+ This integration does not have any irrelevant entities.
+ 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: done
+ comment: |
+ The integration removes removed todo lists on runtime.
+
+ # Platinum
+ async-dependency: done
+ inject-websession: done
+ strict-typing: done
diff --git a/homeassistant/components/mealie/sensor.py b/homeassistant/components/mealie/sensor.py
index b4baac34ebe..e4b1655a9d1 100644
--- a/homeassistant/components/mealie/sensor.py
+++ b/homeassistant/components/mealie/sensor.py
@@ -17,6 +17,8 @@ from homeassistant.helpers.typing import StateType
from .coordinator import MealieConfigEntry, MealieStatisticsCoordinator
from .entity import MealieEntity
+PARALLEL_UPDATES = 0
+
@dataclass(frozen=True, kw_only=True)
class MealieStatisticsSensorEntityDescription(SensorEntityDescription):
@@ -28,31 +30,26 @@ class MealieStatisticsSensorEntityDescription(SensorEntityDescription):
SENSOR_TYPES: tuple[MealieStatisticsSensorEntityDescription, ...] = (
MealieStatisticsSensorEntityDescription(
key="recipes",
- native_unit_of_measurement="recipes",
state_class=SensorStateClass.TOTAL,
value_fn=lambda statistics: statistics.total_recipes,
),
MealieStatisticsSensorEntityDescription(
key="users",
- native_unit_of_measurement="users",
state_class=SensorStateClass.TOTAL,
value_fn=lambda statistics: statistics.total_users,
),
MealieStatisticsSensorEntityDescription(
key="categories",
- native_unit_of_measurement="categories",
state_class=SensorStateClass.TOTAL,
value_fn=lambda statistics: statistics.total_categories,
),
MealieStatisticsSensorEntityDescription(
key="tags",
- native_unit_of_measurement="tags",
state_class=SensorStateClass.TOTAL,
value_fn=lambda statistics: statistics.total_tags,
),
MealieStatisticsSensorEntityDescription(
key="tools",
- native_unit_of_measurement="tools",
state_class=SensorStateClass.TOTAL,
value_fn=lambda statistics: statistics.total_tools,
),
diff --git a/homeassistant/components/mealie/services.py b/homeassistant/components/mealie/services.py
index f195be37b11..ca8c28f9d13 100644
--- a/homeassistant/components/mealie/services.py
+++ b/homeassistant/components/mealie/services.py
@@ -92,7 +92,7 @@ SERVICE_SET_MEALPLAN_SCHEMA = vol.Any(
[x.lower() for x in MealplanEntryType]
),
vol.Required(ATTR_NOTE_TITLE): str,
- vol.Required(ATTR_NOTE_TEXT): str,
+ vol.Optional(ATTR_NOTE_TEXT): str,
}
),
)
diff --git a/homeassistant/components/mealie/strings.json b/homeassistant/components/mealie/strings.json
index b59399815ea..fa63252e837 100644
--- a/homeassistant/components/mealie/strings.json
+++ b/homeassistant/components/mealie/strings.json
@@ -1,4 +1,9 @@
{
+ "common": {
+ "data_description_host": "The URL of your Mealie instance, for example, http://192.168.1.123:1234",
+ "data_description_api_token": "The API token of your Mealie instance from your user profile within Mealie.",
+ "data_description_verify_ssl": "Should SSL certificates be verified? This should be off for self-signed certificates."
+ },
"config": {
"step": {
"user": {
@@ -8,13 +13,18 @@
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
},
"data_description": {
- "host": "The URL of your Mealie instance."
+ "host": "[%key:component::mealie::common::data_description_host%]",
+ "api_token": "[%key:component::mealie::common::data_description_api_token%]",
+ "verify_ssl": "[%key:component::mealie::common::data_description_verify_ssl%]"
}
},
"reauth_confirm": {
"description": "Please reauthenticate with Mealie.",
"data": {
"api_token": "[%key:common::config_flow::data::api_token%]"
+ },
+ "data_description": {
+ "api_token": "[%key:component::mealie::common::data_description_api_token%]"
}
},
"reconfigure": {
@@ -23,12 +33,18 @@
"host": "[%key:common::config_flow::data::url%]",
"api_token": "[%key:common::config_flow::data::api_token%]",
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
+ },
+ "data_description": {
+ "host": "[%key:component::mealie::common::data_description_host%]",
+ "api_token": "[%key:component::mealie::common::data_description_api_token%]",
+ "verify_ssl": "[%key:component::mealie::common::data_description_verify_ssl%]"
}
}
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
+ "ingress_url": "Ingress URLs are only used for accessing the Mealie UI. Use your Home Assistant IP address and the network port within the configuration tab of the Mealie add-on.",
"unknown": "[%key:common::config_flow::error::unknown%]",
"mealie_version": "Minimum required version is v1.0.0. Please upgrade Mealie and then retry."
},
@@ -56,19 +72,24 @@
},
"sensor": {
"recipes": {
- "name": "Recipes"
+ "name": "Recipes",
+ "unit_of_measurement": "recipes"
},
"users": {
- "name": "Users"
+ "name": "Users",
+ "unit_of_measurement": "users"
},
"categories": {
- "name": "Categories"
+ "name": "Categories",
+ "unit_of_measurement": "categories"
},
"tags": {
- "name": "Tags"
+ "name": "Tags",
+ "unit_of_measurement": "tags"
},
"tools": {
- "name": "Tools"
+ "name": "Tools",
+ "unit_of_measurement": "tools"
}
}
},
@@ -105,6 +126,21 @@
},
"version_error": {
"message": "You are running {mealie_version} of Mealie. Minimum required version is {min_version}. Please upgrade Mealie and then retry."
+ },
+ "auth_failed": {
+ "message": "Authentication failed. Please reauthenticate."
+ },
+ "update_failed_mealplan": {
+ "message": "Could not fetch mealplan data."
+ },
+ "update_failed_shopping_list": {
+ "message": "Could not fetch shopping list data."
+ },
+ "update_failed_statistics": {
+ "message": "Could not fetch statistics data."
+ },
+ "setup_failed": {
+ "message": "Could not connect to the Mealie instance."
}
},
"services": {
@@ -193,8 +229,8 @@
"description": "The type of dish to set the recipe to."
},
"recipe_id": {
- "name": "[%key:component::mealie::services::get_recipe::fields::recipe_id::name%]",
- "description": "[%key:component::mealie::services::get_recipe::fields::recipe_id::description%]"
+ "name": "Recipe ID",
+ "description": "The recipe ID or the slug of the recipe to get."
},
"note_title": {
"name": "Meal note title",
diff --git a/homeassistant/components/mealie/todo.py b/homeassistant/components/mealie/todo.py
index 508b6aeb5e2..be04b00113e 100644
--- a/homeassistant/components/mealie/todo.py
+++ b/homeassistant/components/mealie/todo.py
@@ -20,6 +20,7 @@ from .const import DOMAIN
from .coordinator import MealieConfigEntry, MealieShoppingListCoordinator
from .entity import MealieEntity
+PARALLEL_UPDATES = 0
TODO_STATUS_MAP = {
False: TodoItemStatus.NEEDS_ACTION,
True: TodoItemStatus.COMPLETED,
@@ -147,29 +148,19 @@ class MealieShoppingListTodoListEntity(MealieEntity, TodoListEntity):
"""Update an item on the list."""
list_items = self.shopping_items
- for items in list_items:
- if items.item_id == item.uid:
- position = items.position
- break
-
list_item: ShoppingItem | None = next(
(x for x in list_items if x.item_id == item.uid), None
)
+ assert list_item is not None
+ position = list_item.position
- if not list_item:
- raise HomeAssistantError(
- translation_domain=DOMAIN,
- translation_key="item_not_found_error",
- translation_placeholders={"shopping_list_item": item.uid or ""},
- )
-
- udpdate_shopping_item = MutateShoppingItem(
+ update_shopping_item = MutateShoppingItem(
item_id=list_item.item_id,
list_id=list_item.list_id,
note=list_item.note,
display=list_item.display,
checked=item.status == TodoItemStatus.COMPLETED,
- position=list_item.position,
+ position=position,
is_food=list_item.is_food,
disable_amount=list_item.disable_amount,
quantity=list_item.quantity,
@@ -181,16 +172,16 @@ class MealieShoppingListTodoListEntity(MealieEntity, TodoListEntity):
stripped_item_summary = item.summary.strip() if item.summary else item.summary
if list_item.display.strip() != stripped_item_summary:
- udpdate_shopping_item.note = stripped_item_summary
- udpdate_shopping_item.position = position
- udpdate_shopping_item.is_food = False
- udpdate_shopping_item.food_id = None
- udpdate_shopping_item.quantity = 0.0
- udpdate_shopping_item.checked = item.status == TodoItemStatus.COMPLETED
+ update_shopping_item.note = stripped_item_summary
+ update_shopping_item.position = position
+ update_shopping_item.is_food = False
+ update_shopping_item.food_id = None
+ update_shopping_item.quantity = 0.0
+ update_shopping_item.checked = item.status == TodoItemStatus.COMPLETED
try:
await self.coordinator.client.update_shopping_item(
- list_item.item_id, udpdate_shopping_item
+ list_item.item_id, update_shopping_item
)
except MealieError as exception:
raise HomeAssistantError(
diff --git a/homeassistant/components/media_extractor/__init__.py b/homeassistant/components/media_extractor/__init__.py
index b8bb5f98cd0..79fa9d6fb9a 100644
--- a/homeassistant/components/media_extractor/__init__.py
+++ b/homeassistant/components/media_extractor/__init__.py
@@ -16,10 +16,9 @@ from homeassistant.components.media_player import (
MEDIA_PLAYER_PLAY_MEDIA_SCHEMA,
SERVICE_PLAY_MEDIA,
)
-from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
+from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_ENTITY_ID
from homeassistant.core import (
- DOMAIN as HOMEASSISTANT_DOMAIN,
HomeAssistant,
ServiceCall,
ServiceResponse,
@@ -27,7 +26,6 @@ from homeassistant.core import (
)
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv
-from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.helpers.typing import ConfigType
from .const import (
@@ -43,19 +41,7 @@ _LOGGER = logging.getLogger(__name__)
CONF_CUSTOMIZE_ENTITIES = "customize"
CONF_DEFAULT_STREAM_QUERY = "default_query"
-CONFIG_SCHEMA = vol.Schema(
- {
- DOMAIN: vol.Schema(
- {
- vol.Optional(CONF_DEFAULT_STREAM_QUERY): cv.string,
- vol.Optional(CONF_CUSTOMIZE_ENTITIES): vol.Schema(
- {cv.entity_id: vol.Schema({cv.string: cv.string})}
- ),
- }
- )
- },
- extra=vol.ALLOW_EXTRA,
-)
+CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
@@ -67,29 +53,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the media extractor service."""
- if DOMAIN in config:
- async_create_issue(
- hass,
- HOMEASSISTANT_DOMAIN,
- f"deprecated_yaml_{DOMAIN}",
- breaks_in_ha_version="2024.12.0",
- is_fixable=False,
- issue_domain=DOMAIN,
- severity=IssueSeverity.WARNING,
- translation_key="deprecated_yaml",
- translation_placeholders={
- "domain": DOMAIN,
- "integration_title": "Media extractor",
- },
- )
-
- hass.async_create_task(
- hass.config_entries.flow.async_init(
- DOMAIN,
- context={"source": SOURCE_IMPORT},
- )
- )
-
async def extract_media_url(call: ServiceCall) -> ServiceResponse:
"""Extract media url."""
diff --git a/homeassistant/components/media_extractor/config_flow.py b/homeassistant/components/media_extractor/config_flow.py
index b91942d7b13..cb2166c35f1 100644
--- a/homeassistant/components/media_extractor/config_flow.py
+++ b/homeassistant/components/media_extractor/config_flow.py
@@ -24,7 +24,3 @@ class MediaExtractorConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_create_entry(title="Media extractor", data={})
return self.async_show_form(step_id="user", data_schema=vol.Schema({}))
-
- async def async_step_import(self, import_data: None) -> ConfigFlowResult:
- """Handle import."""
- return self.async_create_entry(title="Media extractor", data={})
diff --git a/homeassistant/components/media_extractor/manifest.json b/homeassistant/components/media_extractor/manifest.json
index ebfa79d7190..144904fe58c 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]==2024.11.04"],
+ "requirements": ["yt-dlp[default]==2024.12.23"],
"single_config_entry": true
}
diff --git a/homeassistant/components/media_player/strings.json b/homeassistant/components/media_player/strings.json
index ff246e420ce..1c9ba929b38 100644
--- a/homeassistant/components/media_player/strings.json
+++ b/homeassistant/components/media_player/strings.json
@@ -282,7 +282,7 @@
},
"clear_playlist": {
"name": "Clear playlist",
- "description": "Clears the playlist."
+ "description": "Removes all items from the playlist."
},
"shuffle_set": {
"name": "Shuffle",
diff --git a/homeassistant/components/media_source/__init__.py b/homeassistant/components/media_source/__init__.py
index 604f9b7cc88..3ea8f581245 100644
--- a/homeassistant/components/media_source/__init__.py
+++ b/homeassistant/components/media_source/__init__.py
@@ -18,7 +18,7 @@ from homeassistant.components.media_player import (
from homeassistant.components.websocket_api import ActiveConnection
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv
-from homeassistant.helpers.frame import report
+from homeassistant.helpers.frame import report_usage
from homeassistant.helpers.integration_platform import (
async_process_integration_platforms,
)
@@ -156,7 +156,7 @@ async def async_resolve_media(
raise Unresolvable("Media Source not loaded")
if target_media_player is UNDEFINED:
- report(
+ report_usage(
"calls media_source.async_resolve_media without passing an entity_id",
exclude_integrations={DOMAIN},
)
diff --git a/homeassistant/components/mediaroom/manifest.json b/homeassistant/components/mediaroom/manifest.json
index 4cd7b11c22f..060a40b036a 100644
--- a/homeassistant/components/mediaroom/manifest.json
+++ b/homeassistant/components/mediaroom/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/mediaroom",
"iot_class": "local_polling",
"loggers": ["pymediaroom"],
+ "quality_scale": "legacy",
"requirements": ["pymediaroom==0.6.5.4"]
}
diff --git a/homeassistant/components/melcloud/climate.py b/homeassistant/components/melcloud/climate.py
index 08b3658c270..4defd47bc39 100644
--- a/homeassistant/components/melcloud/climate.py
+++ b/homeassistant/components/melcloud/climate.py
@@ -115,7 +115,6 @@ class MelCloudClimate(ClimateEntity):
_attr_temperature_unit = UnitOfTemperature.CELSIUS
_attr_has_entity_name = True
_attr_name = None
- _enable_turn_on_off_backwards_compatibility = False
def __init__(self, device: MelCloudDevice) -> None:
"""Initialize the climate."""
diff --git a/homeassistant/components/melissa/climate.py b/homeassistant/components/melissa/climate.py
index 0ad663faa2a..ff68820d70f 100644
--- a/homeassistant/components/melissa/climate.py
+++ b/homeassistant/components/melissa/climate.py
@@ -65,7 +65,6 @@ class MelissaClimate(ClimateEntity):
| ClimateEntityFeature.TURN_ON
)
_attr_temperature_unit = UnitOfTemperature.CELSIUS
- _enable_turn_on_off_backwards_compatibility = False
def __init__(self, api, serial_number, init_data):
"""Initialize the climate device."""
diff --git a/homeassistant/components/melissa/manifest.json b/homeassistant/components/melissa/manifest.json
index 60d1d7f145f..a583c3b88fa 100644
--- a/homeassistant/components/melissa/manifest.json
+++ b/homeassistant/components/melissa/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/melissa",
"iot_class": "cloud_polling",
"loggers": ["melissa"],
+ "quality_scale": "legacy",
"requirements": ["py-melissa-climate==2.1.4"]
}
diff --git a/homeassistant/components/meraki/manifest.json b/homeassistant/components/meraki/manifest.json
index 4fb7d27d4bb..5b8690ae52d 100644
--- a/homeassistant/components/meraki/manifest.json
+++ b/homeassistant/components/meraki/manifest.json
@@ -4,5 +4,6 @@
"codeowners": [],
"dependencies": ["http"],
"documentation": "https://www.home-assistant.io/integrations/meraki",
- "iot_class": "cloud_polling"
+ "iot_class": "cloud_polling",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/message_bird/manifest.json b/homeassistant/components/message_bird/manifest.json
index d5118dc3486..3b3c56029c5 100644
--- a/homeassistant/components/message_bird/manifest.json
+++ b/homeassistant/components/message_bird/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/message_bird",
"iot_class": "cloud_push",
"loggers": ["messagebird"],
+ "quality_scale": "legacy",
"requirements": ["messagebird==1.2.0"]
}
diff --git a/homeassistant/components/met_eireann/manifest.json b/homeassistant/components/met_eireann/manifest.json
index 72afc6977dd..7b913df4d3c 100644
--- a/homeassistant/components/met_eireann/manifest.json
+++ b/homeassistant/components/met_eireann/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/met_eireann",
"iot_class": "cloud_polling",
"loggers": ["meteireann"],
- "requirements": ["PyMetEireann==2021.8.0"]
+ "requirements": ["PyMetEireann==2024.11.0"]
}
diff --git a/homeassistant/components/meteoalarm/manifest.json b/homeassistant/components/meteoalarm/manifest.json
index 4de91f6a431..58b6a63ed1d 100644
--- a/homeassistant/components/meteoalarm/manifest.json
+++ b/homeassistant/components/meteoalarm/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/meteoalarm",
"iot_class": "cloud_polling",
"loggers": ["meteoalertapi"],
+ "quality_scale": "legacy",
"requirements": ["meteoalertapi==0.3.1"]
}
diff --git a/homeassistant/components/mfi/manifest.json b/homeassistant/components/mfi/manifest.json
index b569009d400..3024fe145c5 100644
--- a/homeassistant/components/mfi/manifest.json
+++ b/homeassistant/components/mfi/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/mfi",
"iot_class": "local_polling",
"loggers": ["mficlient"],
+ "quality_scale": "legacy",
"requirements": ["mficlient==0.5.0"]
}
diff --git a/homeassistant/components/microbees/manifest.json b/homeassistant/components/microbees/manifest.json
index 91b7d66d80f..be28bf881d2 100644
--- a/homeassistant/components/microbees/manifest.json
+++ b/homeassistant/components/microbees/manifest.json
@@ -6,5 +6,5 @@
"dependencies": ["application_credentials"],
"documentation": "https://www.home-assistant.io/integrations/microbees",
"iot_class": "cloud_polling",
- "requirements": ["microBeesPy==0.3.2"]
+ "requirements": ["microBeesPy==0.3.5"]
}
diff --git a/homeassistant/components/microsoft/manifest.json b/homeassistant/components/microsoft/manifest.json
index dba2f58ba98..3d8f0629cec 100644
--- a/homeassistant/components/microsoft/manifest.json
+++ b/homeassistant/components/microsoft/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/microsoft",
"iot_class": "cloud_push",
"loggers": ["pycsspeechtts"],
+ "quality_scale": "legacy",
"requirements": ["pycsspeechtts==1.0.8"]
}
diff --git a/homeassistant/components/microsoft_face/manifest.json b/homeassistant/components/microsoft_face/manifest.json
index 0ef18a12271..e13d1c76ccb 100644
--- a/homeassistant/components/microsoft_face/manifest.json
+++ b/homeassistant/components/microsoft_face/manifest.json
@@ -4,5 +4,6 @@
"codeowners": [],
"dependencies": ["camera"],
"documentation": "https://www.home-assistant.io/integrations/microsoft_face",
- "iot_class": "cloud_push"
+ "iot_class": "cloud_push",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/microsoft_face_detect/manifest.json b/homeassistant/components/microsoft_face_detect/manifest.json
index 1b72ce92c95..f3f9f0fa095 100644
--- a/homeassistant/components/microsoft_face_detect/manifest.json
+++ b/homeassistant/components/microsoft_face_detect/manifest.json
@@ -4,5 +4,6 @@
"codeowners": [],
"dependencies": ["microsoft_face"],
"documentation": "https://www.home-assistant.io/integrations/microsoft_face_detect",
- "iot_class": "cloud_push"
+ "iot_class": "cloud_push",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/microsoft_face_identify/manifest.json b/homeassistant/components/microsoft_face_identify/manifest.json
index 63418ac2a0b..b3964ee1254 100644
--- a/homeassistant/components/microsoft_face_identify/manifest.json
+++ b/homeassistant/components/microsoft_face_identify/manifest.json
@@ -4,5 +4,6 @@
"codeowners": [],
"dependencies": ["microsoft_face"],
"documentation": "https://www.home-assistant.io/integrations/microsoft_face_identify",
- "iot_class": "cloud_push"
+ "iot_class": "cloud_push",
+ "quality_scale": "legacy"
}
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 5c5c7882634..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
@@ -100,27 +100,15 @@ class MillHeater(CoordinatorEntity[MillDataUpdateCoordinator], ClimateEntity):
)
_attr_target_temperature_step = PRECISION_TENTHS
_attr_temperature_unit = UnitOfTemperature.CELSIUS
- _enable_turn_on_off_backwards_compatibility = False
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."""
@@ -144,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
@@ -194,7 +171,6 @@ class LocalMillHeater(CoordinatorEntity[MillDataUpdateCoordinator], ClimateEntit
)
_attr_target_temperature_step = PRECISION_TENTHS
_attr_temperature_unit = UnitOfTemperature.CELSIUS
- _enable_turn_on_off_backwards_compatibility = False
def __init__(self, coordinator: MillDataUpdateCoordinator) -> None:
"""Initialize the thermostat."""
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 16e7bf552ba..6316eb72096 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.11.8", "mill-local==0.3.0"]
+ "requirements": ["millheater==0.12.2", "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 64b9008a82b..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(
@@ -57,6 +60,19 @@ HEATER_SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
state_class=SensorStateClass.TOTAL_INCREASING,
),
+ SensorEntityDescription(
+ key="current_power",
+ translation_key="current_power",
+ device_class=SensorDeviceClass.POWER,
+ native_unit_of_measurement=UnitOfPower.WATT,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ SensorEntityDescription(
+ key="control_signal",
+ translation_key="control_signal",
+ native_unit_of_measurement=PERCENTAGE,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
)
SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
@@ -118,6 +134,16 @@ LOCAL_SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
),
)
+SOCKET_SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
+ SensorEntityDescription(
+ key=HUMIDITY,
+ device_class=SensorDeviceClass.HUMIDITY,
+ native_unit_of_measurement=PERCENTAGE,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ *HEATER_SENSOR_TYPES,
+)
+
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
@@ -145,7 +171,9 @@ async def async_setup_entry(
)
for mill_device in mill_data_coordinator.data.values()
for entity_description in (
- HEATER_SENSOR_TYPES
+ SOCKET_SENSOR_TYPES
+ if isinstance(mill_device, mill.Socket)
+ else HEATER_SENSOR_TYPES
if isinstance(mill_device, mill.Heater)
else SENSOR_TYPES
)
@@ -154,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):
@@ -192,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)
@@ -214,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 8f016e2de00..f937c304471 100644
--- a/homeassistant/components/minecraft_server/__init__.py
+++ b/homeassistant/components/minecraft_server/__init__.py
@@ -43,7 +43,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Minecraft Server from a config entry."""
# Workaround to avoid blocking imports from dnspython (https://github.com/rthalley/dnspython/issues/1083)
- hass.async_add_executor_job(load_dnspython_rdata_classes)
+ await hass.async_add_executor_job(load_dnspython_rdata_classes)
# Create API instance.
api = MinecraftServer(
diff --git a/homeassistant/components/minecraft_server/manifest.json b/homeassistant/components/minecraft_server/manifest.json
index 8e098f98a15..d6ade4853c9 100644
--- a/homeassistant/components/minecraft_server/manifest.json
+++ b/homeassistant/components/minecraft_server/manifest.json
@@ -6,6 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/minecraft_server",
"iot_class": "local_polling",
"loggers": ["dnspython", "mcstatus"],
- "quality_scale": "platinum",
"requirements": ["mcstatus==11.1.1"]
}
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/manifest.json b/homeassistant/components/minio/manifest.json
index 5fee7893841..3ab6b82bb86 100644
--- a/homeassistant/components/minio/manifest.json
+++ b/homeassistant/components/minio/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/minio",
"iot_class": "cloud_push",
"loggers": ["minio"],
+ "quality_scale": "legacy",
"requirements": ["minio==7.1.12"]
}
diff --git a/homeassistant/components/mochad/manifest.json b/homeassistant/components/mochad/manifest.json
index e4680cc6ff5..96795789c8c 100644
--- a/homeassistant/components/mochad/manifest.json
+++ b/homeassistant/components/mochad/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/mochad",
"iot_class": "local_polling",
"loggers": ["pbr", "pymochad"],
+ "quality_scale": "legacy",
"requirements": ["pymochad==0.2.0"]
}
diff --git a/homeassistant/components/modbus/__init__.py b/homeassistant/components/modbus/__init__.py
index 48f8c726836..a7b32119917 100644
--- a/homeassistant/components/modbus/__init__.py
+++ b/homeassistant/components/modbus/__init__.py
@@ -46,9 +46,13 @@ from homeassistant.const import (
CONF_TYPE,
CONF_UNIQUE_ID,
CONF_UNIT_OF_MEASUREMENT,
+ SERVICE_RELOAD,
)
-from homeassistant.core import HomeAssistant
+from homeassistant.core import Event, HomeAssistant, ServiceCall
import homeassistant.helpers.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
from homeassistant.helpers.typing import ConfigType
from .const import (
@@ -85,6 +89,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,
@@ -126,6 +132,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,
@@ -252,6 +260,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(
{
@@ -451,18 +465,29 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up Modbus component."""
if DOMAIN not in config:
return True
+
+ async def _reload_config(call: Event | ServiceCall) -> None:
+ """Reload Modbus."""
+ if DOMAIN 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()
+ reset_platforms = async_get_platforms(hass, DOMAIN)
+ for reset_platform in reset_platforms:
+ _LOGGER.debug("Reload modbus resetting platform: %s", reset_platform.domain)
+ await reset_platform.async_reset()
+ reload_config = await async_integration_yaml_config(hass, DOMAIN)
+ if not reload_config:
+ _LOGGER.debug("Modbus not present anymore")
+ return
+ _LOGGER.debug("Modbus reloading")
+ await async_modbus_setup(hass, reload_config)
+
+ async_register_admin_service(hass, DOMAIN, SERVICE_RELOAD, _reload_config)
+
return await async_modbus_setup(
hass,
config,
)
-
-
-async def async_reset_platform(hass: HomeAssistant, integration_name: str) -> None:
- """Release modbus resources."""
- if DOMAIN not in hass.data:
- _LOGGER.error("Modbus cannot reload, because it was never loaded")
- return
- _LOGGER.debug("Modbus reloading")
- hubs = hass.data[DOMAIN]
- for name in hubs:
- await hubs[name].async_close()
diff --git a/homeassistant/components/modbus/binary_sensor.py b/homeassistant/components/modbus/binary_sensor.py
index b50d21faf42..97ade53762b 100644
--- a/homeassistant/components/modbus/binary_sensor.py
+++ b/homeassistant/components/modbus/binary_sensor.py
@@ -121,7 +121,7 @@ class ModbusBinarySensor(BasePlatform, RestoreEntity, BinarySensorEntity):
else:
self._attr_available = True
if self._input_type in (CALL_TYPE_COIL, CALL_TYPE_DISCRETE):
- self._result = result.bits
+ self._result = [int(bit) for bit in result.bits]
else:
self._result = result.registers
self._attr_is_on = bool(self._result[0] & 1)
diff --git a/homeassistant/components/modbus/climate.py b/homeassistant/components/modbus/climate.py
index bcbaa0f32af..ba09bd08377 100644
--- a/homeassistant/components/modbus/climate.py
+++ b/homeassistant/components/modbus/climate.py
@@ -69,6 +69,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,
@@ -130,7 +132,6 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity):
| ClimateEntityFeature.TURN_OFF
| ClimateEntityFeature.TURN_ON
)
- _enable_turn_on_off_backwards_compatibility = False
def __init__(
self,
@@ -252,6 +253,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:
@@ -267,19 +270,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,
)
@@ -477,7 +487,7 @@ 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()
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/fan.py b/homeassistant/components/modbus/fan.py
index 5d12fe37fd1..bed8ff102bb 100644
--- a/homeassistant/components/modbus/fan.py
+++ b/homeassistant/components/modbus/fan.py
@@ -38,8 +38,6 @@ async def async_setup_platform(
class ModbusFan(BaseSwitch, FanEntity):
"""Class representing a Modbus fan."""
- _enable_turn_on_off_backwards_compatibility = False
-
def __init__(
self, hass: HomeAssistant, hub: ModbusHub, config: dict[str, Any]
) -> None:
diff --git a/homeassistant/components/modbus/manifest.json b/homeassistant/components/modbus/manifest.json
index 4482801482f..120175c65c2 100644
--- a/homeassistant/components/modbus/manifest.json
+++ b/homeassistant/components/modbus/manifest.json
@@ -5,6 +5,5 @@
"documentation": "https://www.home-assistant.io/integrations/modbus",
"iot_class": "local_polling",
"loggers": ["pymodbus"],
- "quality_scale": "silver",
- "requirements": ["pymodbus==3.6.9"]
+ "requirements": ["pymodbus==3.8.3"]
}
diff --git a/homeassistant/components/modbus/modbus.py b/homeassistant/components/modbus/modbus.py
index d85b4e0e67f..8c8a879ead6 100644
--- a/homeassistant/components/modbus/modbus.py
+++ b/homeassistant/components/modbus/modbus.py
@@ -14,8 +14,8 @@ from pymodbus.client import (
AsyncModbusUdpClient,
)
from pymodbus.exceptions import ModbusException
-from pymodbus.pdu import ModbusResponse
-from pymodbus.transaction import ModbusAsciiFramer, ModbusRtuFramer, ModbusSocketFramer
+from pymodbus.framer import FramerType
+from pymodbus.pdu import ModbusPDU
import voluptuous as vol
from homeassistant.const import (
@@ -34,7 +34,6 @@ 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.event import async_call_later
-from homeassistant.helpers.reload import async_setup_reload_service
from homeassistant.helpers.typing import ConfigType
from .const import (
@@ -125,8 +124,6 @@ async def async_modbus_setup(
) -> bool:
"""Set up Modbus component."""
- await async_setup_reload_service(hass, DOMAIN, [DOMAIN])
-
if config[DOMAIN]:
config[DOMAIN] = check_config(hass, config[DOMAIN])
if not config[DOMAIN]:
@@ -158,8 +155,6 @@ async def async_modbus_setup(
async def async_stop_modbus(event: Event) -> None:
"""Stop Modbus service."""
-
- async_dispatcher_send(hass, SIGNAL_STOP_ENTITY)
for client in hub_collect.values():
await client.async_close()
@@ -267,14 +262,13 @@ class ModbusHub:
"port": client_config[CONF_PORT],
"timeout": client_config[CONF_TIMEOUT],
"retries": 3,
- "retry_on_empty": True,
}
if self._config_type == SERIAL:
# serial configuration
if client_config[CONF_METHOD] == "ascii":
- self._pb_params["framer"] = ModbusAsciiFramer
+ self._pb_params["framer"] = FramerType.ASCII
else:
- self._pb_params["framer"] = ModbusRtuFramer
+ self._pb_params["framer"] = FramerType.RTU
self._pb_params.update(
{
"baudrate": client_config[CONF_BAUDRATE],
@@ -287,9 +281,9 @@ class ModbusHub:
# network configuration
self._pb_params["host"] = client_config[CONF_HOST]
if self._config_type == RTUOVERTCP:
- self._pb_params["framer"] = ModbusRtuFramer
+ self._pb_params["framer"] = FramerType.RTU
else:
- self._pb_params["framer"] = ModbusSocketFramer
+ self._pb_params["framer"] = FramerType.SOCKET
if CONF_MSG_WAIT in client_config:
self._msg_wait = client_config[CONF_MSG_WAIT] / 1000
@@ -372,12 +366,12 @@ class ModbusHub:
async def low_level_pb_call(
self, slave: int | None, address: int, value: int | list[int], use_call: str
- ) -> ModbusResponse | None:
+ ) -> ModbusPDU | None:
"""Call sync. pymodbus."""
kwargs = {"slave": slave} if slave else {}
entry = self._pb_request[use_call]
try:
- result: ModbusResponse = await entry.func(address, value, **kwargs)
+ result: ModbusPDU = await entry.func(address, value, **kwargs)
except ModbusException as exception_error:
error = f"Error: device: {slave} address: {address} -> {exception_error!s}"
self._log_error(error)
@@ -405,7 +399,7 @@ class ModbusHub:
address: int,
value: int | list[int],
use_call: str,
- ) -> ModbusResponse | None:
+ ) -> ModbusPDU | None:
"""Convert async to sync pymodbus call."""
if self._config_delay:
return None
diff --git a/homeassistant/components/modern_forms/config_flow.py b/homeassistant/components/modern_forms/config_flow.py
index dee08736234..3c217b5747f 100644
--- a/homeassistant/components/modern_forms/config_flow.py
+++ b/homeassistant/components/modern_forms/config_flow.py
@@ -9,26 +9,34 @@ 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, CONF_NAME
+from homeassistant.const import CONF_HOST, CONF_MAC
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN
+USER_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str})
+
class ModernFormsFlowHandler(ConfigFlow, domain=DOMAIN):
"""Handle a ModernForms config flow."""
VERSION = 1
- host: str | None = None
+ host: str
mac: str | None = None
- name: str | None = None
+ name: str
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle setup by user for Modern Forms integration."""
- return await self._handle_config_flow(user_input)
+ if user_input is None:
+ return self.async_show_form(
+ step_id="user",
+ data_schema=USER_SCHEMA,
+ )
+ self.host = user_input[CONF_HOST]
+ return await self._handle_config_flow()
async def async_step_zeroconf(
self, discovery_info: zeroconf.ZeroconfServiceInfo
@@ -42,73 +50,51 @@ class ModernFormsFlowHandler(ConfigFlow, domain=DOMAIN):
self.mac = discovery_info.properties.get(CONF_MAC)
self.name = name
- # Prepare configuration flow
- return await self._handle_config_flow({}, True)
+ # Loop through self._handle_config_flow to ensure we load the
+ # MAC if it is missing, and abort if already configured
+ return await self._handle_config_flow(True)
async def async_step_zeroconf_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle a flow initiated by zeroconf."""
- return await self._handle_config_flow(user_input)
+ return await self._handle_config_flow()
async def _handle_config_flow(
- self, user_input: dict[str, Any] | None = None, prepare: bool = False
+ self, initial_zeroconf: bool = False
) -> ConfigFlowResult:
"""Config flow handler for ModernForms."""
- source = self.context["source"]
-
- # Request user input, unless we are preparing discovery flow
- if user_input is None:
- user_input = {}
- if not prepare:
- if source == SOURCE_ZEROCONF:
- return self._show_confirm_dialog()
- return self._show_setup_form()
-
- if source == SOURCE_ZEROCONF:
- user_input[CONF_HOST] = self.host
- user_input[CONF_MAC] = self.mac
-
- if user_input.get(CONF_MAC) is None or not prepare:
+ if self.mac is None or not initial_zeroconf:
+ # User flow
+ # Or zeroconf without MAC
+ # Or zeroconf with MAC, but need to ensure device is still available
session = async_get_clientsession(self.hass)
- device = ModernFormsDevice(user_input[CONF_HOST], session=session)
+ device = ModernFormsDevice(self.host, session=session)
try:
device = await device.update()
except ModernFormsConnectionError:
- if source == SOURCE_ZEROCONF:
+ if self.source == SOURCE_ZEROCONF:
return self.async_abort(reason="cannot_connect")
- return self._show_setup_form({"base": "cannot_connect"})
- user_input[CONF_MAC] = device.info.mac_address
- user_input[CONF_NAME] = device.info.device_name
+ return self.async_show_form(
+ step_id="user",
+ data_schema=USER_SCHEMA,
+ errors={"base": "cannot_connect"},
+ )
+ self.mac = device.info.mac_address
+ if self.source != SOURCE_ZEROCONF:
+ self.name = device.info.device_name
# Check if already configured
- await self.async_set_unique_id(user_input[CONF_MAC])
- self._abort_if_unique_id_configured(updates={CONF_HOST: user_input[CONF_HOST]})
+ await self.async_set_unique_id(self.mac)
+ self._abort_if_unique_id_configured(updates={CONF_HOST: self.host})
- title = device.info.device_name
- if source == SOURCE_ZEROCONF:
- title = self.name
-
- if prepare:
- return await self.async_step_zeroconf_confirm()
+ if initial_zeroconf:
+ return self.async_show_form(
+ step_id="zeroconf_confirm",
+ description_placeholders={"name": self.name},
+ )
return self.async_create_entry(
- title=title,
- data={CONF_HOST: user_input[CONF_HOST], CONF_MAC: user_input[CONF_MAC]},
- )
-
- def _show_setup_form(self, errors: dict | None = None) -> ConfigFlowResult:
- """Show the setup form to the user."""
- return self.async_show_form(
- step_id="user",
- data_schema=vol.Schema({vol.Required(CONF_HOST): str}),
- errors=errors or {},
- )
-
- def _show_confirm_dialog(self, errors: dict | None = None) -> ConfigFlowResult:
- """Show the confirm dialog to the user."""
- return self.async_show_form(
- step_id="zeroconf_confirm",
- description_placeholders={"name": self.name},
- errors=errors or {},
+ title=self.name,
+ data={CONF_HOST: self.host, CONF_MAC: self.mac},
)
diff --git a/homeassistant/components/modern_forms/fan.py b/homeassistant/components/modern_forms/fan.py
index a599c5b6dd6..988edcb60e5 100644
--- a/homeassistant/components/modern_forms/fan.py
+++ b/homeassistant/components/modern_forms/fan.py
@@ -78,7 +78,6 @@ class ModernFormsFanEntity(FanEntity, ModernFormsDeviceEntity):
| FanEntityFeature.TURN_ON
)
_attr_translation_key = "fan"
- _enable_turn_on_off_backwards_compatibility = False
def __init__(
self, entry_id: str, coordinator: ModernFormsDataUpdateCoordinator
diff --git a/homeassistant/components/moehlenhoff_alpha2/climate.py b/homeassistant/components/moehlenhoff_alpha2/climate.py
index 33f17271800..7c24dad4469 100644
--- a/homeassistant/components/moehlenhoff_alpha2/climate.py
+++ b/homeassistant/components/moehlenhoff_alpha2/climate.py
@@ -47,7 +47,6 @@ class Alpha2Climate(CoordinatorEntity[Alpha2BaseCoordinator], ClimateEntity):
_attr_hvac_modes = [HVACMode.HEAT, HVACMode.COOL]
_attr_temperature_unit = UnitOfTemperature.CELSIUS
_attr_preset_modes = [PRESET_AUTO, PRESET_DAY, PRESET_NIGHT]
- _enable_turn_on_off_backwards_compatibility = False
def __init__(self, coordinator: Alpha2BaseCoordinator, heat_area_id: str) -> None:
"""Initialize Alpha2 ClimateEntity."""
diff --git a/homeassistant/components/mold_indicator/config_flow.py b/homeassistant/components/mold_indicator/config_flow.py
index e6f795ecc91..5e5512a60bf 100644
--- a/homeassistant/components/mold_indicator/config_flow.py
+++ b/homeassistant/components/mold_indicator/config_flow.py
@@ -51,15 +51,6 @@ async def validate_input(
DATA_SCHEMA_OPTIONS = vol.Schema(
{
- vol.Required(CONF_CALIBRATION_FACTOR): NumberSelector(
- NumberSelectorConfig(step=0.1, mode=NumberSelectorMode.BOX)
- )
- }
-)
-
-DATA_SCHEMA_CONFIG = vol.Schema(
- {
- vol.Required(CONF_NAME, default=DEFAULT_NAME): TextSelector(),
vol.Required(CONF_INDOOR_TEMP): EntitySelector(
EntitySelectorConfig(
domain=Platform.SENSOR, device_class=SensorDeviceClass.TEMPERATURE
@@ -75,6 +66,15 @@ DATA_SCHEMA_CONFIG = vol.Schema(
domain=Platform.SENSOR, device_class=SensorDeviceClass.TEMPERATURE
)
),
+ vol.Required(CONF_CALIBRATION_FACTOR): NumberSelector(
+ NumberSelectorConfig(step=0.1, mode=NumberSelectorMode.BOX)
+ ),
+ }
+)
+
+DATA_SCHEMA_CONFIG = vol.Schema(
+ {
+ vol.Required(CONF_NAME, default=DEFAULT_NAME): TextSelector(),
}
).extend(DATA_SCHEMA_OPTIONS.schema)
diff --git a/homeassistant/components/mold_indicator/strings.json b/homeassistant/components/mold_indicator/strings.json
index e19fed690b2..74614bba139 100644
--- a/homeassistant/components/mold_indicator/strings.json
+++ b/homeassistant/components/mold_indicator/strings.json
@@ -9,7 +9,7 @@
},
"step": {
"user": {
- "description": "Add Mold indicator helper",
+ "description": "Create Mold indicator helper",
"data": {
"name": "[%key:common::config_flow::data::name%]",
"indoor_humidity_sensor": "Indoor humidity sensor",
diff --git a/homeassistant/components/monzo/coordinator.py b/homeassistant/components/monzo/coordinator.py
index 223d7b05ffe..caac551f986 100644
--- a/homeassistant/components/monzo/coordinator.py
+++ b/homeassistant/components/monzo/coordinator.py
@@ -3,13 +3,14 @@
from dataclasses import dataclass
from datetime import timedelta
import logging
+from pprint import pformat
from typing import Any
-from monzopy import AuthorisationExpiredError
+from monzopy import AuthorisationExpiredError, InvalidMonzoAPIResponseError
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
-from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
+from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .api import AuthenticatedMonzoAPI
from .const import DOMAIN
@@ -45,5 +46,16 @@ class MonzoCoordinator(DataUpdateCoordinator[MonzoData]):
pots = await self.api.user_account.pots()
except AuthorisationExpiredError as err:
raise ConfigEntryAuthFailed from err
+ except InvalidMonzoAPIResponseError as err:
+ message = "Invalid Monzo API response."
+ if err.missing_key:
+ _LOGGER.debug(
+ "%s\nMissing key: %s\nResponse:\n%s",
+ message,
+ err.missing_key,
+ pformat(err.response),
+ )
+ message += " Enabling debug logging for details."
+ raise UpdateFailed(message) from err
return MonzoData(accounts, pots)
diff --git a/homeassistant/components/motionblinds_ble/config_flow.py b/homeassistant/components/motionblinds_ble/config_flow.py
index d99096d3a09..30417c62c65 100644
--- a/homeassistant/components/motionblinds_ble/config_flow.py
+++ b/homeassistant/components/motionblinds_ble/config_flow.py
@@ -48,11 +48,12 @@ CONFIG_SCHEMA = vol.Schema({vol.Required(CONF_MAC_CODE): str})
class FlowHandler(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Motionblinds Bluetooth."""
+ _display_name: str
+
def __init__(self) -> None:
"""Initialize a ConfigFlow."""
self._discovery_info: BluetoothServiceInfoBleak | BLEDevice | None = None
self._mac_code: str | None = None
- self._display_name: str | None = None
self._blind_type: MotionBlindType | None = None
async def async_step_bluetooth(
@@ -67,8 +68,8 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
self._discovery_info = discovery_info
self._mac_code = get_mac_from_local_name(discovery_info.name)
- self._display_name = display_name = DISPLAY_NAME.format(mac_code=self._mac_code)
- self.context["title_placeholders"] = {"name": display_name}
+ self._display_name = DISPLAY_NAME.format(mac_code=self._mac_code)
+ self.context["title_placeholders"] = {"name": self._display_name}
return await self.async_step_confirm()
@@ -113,7 +114,7 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
assert self._discovery_info is not None
return self.async_create_entry(
- title=str(self._display_name),
+ title=self._display_name,
data={
CONF_ADDRESS: self._discovery_info.address,
CONF_LOCAL_NAME: self._discovery_info.name,
diff --git a/homeassistant/components/motionblinds_ble/manifest.json b/homeassistant/components/motionblinds_ble/manifest.json
index ce7e7a6bb8b..70cddce30a1 100644
--- a/homeassistant/components/motionblinds_ble/manifest.json
+++ b/homeassistant/components/motionblinds_ble/manifest.json
@@ -14,5 +14,5 @@
"integration_type": "device",
"iot_class": "assumed_state",
"loggers": ["motionblindsble"],
- "requirements": ["motionblindsble==0.1.2"]
+ "requirements": ["motionblindsble==0.1.3"]
}
diff --git a/homeassistant/components/motionblinds_ble/sensor.py b/homeassistant/components/motionblinds_ble/sensor.py
index aa0f5ef7c90..740a0509a9e 100644
--- a/homeassistant/components/motionblinds_ble/sensor.py
+++ b/homeassistant/components/motionblinds_ble/sensor.py
@@ -6,7 +6,6 @@ from collections.abc import Callable
from dataclasses import dataclass
import logging
from math import ceil
-from typing import Generic, TypeVar
from motionblindsble.const import (
MotionBlindType,
@@ -45,11 +44,9 @@ _LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 0
-_T = TypeVar("_T")
-
@dataclass(frozen=True, kw_only=True)
-class MotionblindsBLESensorEntityDescription(SensorEntityDescription, Generic[_T]):
+class MotionblindsBLESensorEntityDescription[_T](SensorEntityDescription):
"""Entity description of a sensor entity with initial_value attribute."""
initial_value: str | None = None
@@ -110,7 +107,7 @@ async def async_setup_entry(
async_add_entities(entities)
-class MotionblindsBLESensorEntity(MotionblindsBLEEntity, SensorEntity, Generic[_T]):
+class MotionblindsBLESensorEntity[_T](MotionblindsBLEEntity, SensorEntity):
"""Representation of a sensor entity."""
entity_description: MotionblindsBLESensorEntityDescription[_T]
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..a79d933a782 100644
--- a/homeassistant/components/mpd/media_player.py
+++ b/homeassistant/components/mpd/media_player.py
@@ -26,15 +26,12 @@ 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
+from homeassistant.core import HomeAssistant
import homeassistant.helpers.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
@@ -71,54 +68,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 907b1a1dd11..624f99d350a 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
@@ -113,8 +112,6 @@ _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 +152,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,
)
@@ -225,144 +213,33 @@ async def async_check_config_schema(
) from exc
-async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
- """Load a config entry."""
- conf: dict[str, Any]
- mqtt_data: MqttData
+async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
+ """Set up the actions and websocket API for the MQTT component."""
- async def _setup_client(
- client_available: asyncio.Future[bool],
- ) -> tuple[MqttData, dict[str, Any]]:
- """Set up the MQTT client."""
- # Fetch configuration
- conf = dict(entry.data)
- 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)
- client = MQTT(hass, entry, conf)
- if DOMAIN in hass.data:
- mqtt_data = hass.data[DATA_MQTT]
- mqtt_data.config = mqtt_yaml
- mqtt_data.client = client
- else:
- # Initial setup
- websocket_api.async_register_command(hass, websocket_subscribe)
- websocket_api.async_register_command(hass, websocket_mqtt_info)
- hass.data[DATA_MQTT] = mqtt_data = MqttData(config=mqtt_yaml, client=client)
- await client.async_start(mqtt_data)
-
- # Restore saved subscriptions
- if mqtt_data.subscriptions_to_restore:
- mqtt_data.client.async_restore_tracked_subscriptions(
- mqtt_data.subscriptions_to_restore
- )
- mqtt_data.subscriptions_to_restore = set()
- mqtt_data.reload_dispatchers.append(
- entry.add_update_listener(_async_config_entry_updated)
- )
-
- return (mqtt_data, conf)
-
- client_available: asyncio.Future[bool]
- if DATA_MQTT_AVAILABLE not in hass.data:
- client_available = hass.data[DATA_MQTT_AVAILABLE] = hass.loop.create_future()
- else:
- client_available = hass.data[DATA_MQTT_AVAILABLE]
-
- mqtt_data, conf = await _setup_client(client_available)
- platforms_used = platforms_from_config(mqtt_data.config)
- platforms_used.update(
- entry.domain
- for entry in er.async_entries_for_config_entry(
- er.async_get(hass), entry.entry_id
- )
- )
- integration = async_get_loaded_integration(hass, DOMAIN)
- # Preload platforms we know we are going to use so
- # discovery can setup each platform synchronously
- # and avoid creating a flood of tasks at startup
- # while waiting for the the imports to complete
- if not integration.platforms_are_loaded(platforms_used):
- with async_pause_setup(hass, SetupPhases.WAIT_IMPORT_PLATFORMS):
- await integration.async_get_platforms(platforms_used)
-
- # Wait to connect until the platforms are loaded so
- # we can be sure discovery does not have to wait for
- # each platform to load when we get the flood of retained
- # messages on connect
- await mqtt_data.client.async_connect(client_available)
+ websocket_api.async_register_command(hass, websocket_subscribe)
+ websocket_api.async_register_command(hass, websocket_mqtt_info)
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)
- payload: PublishPayloadType = call.data.get(ATTR_PAYLOAD)
+ 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": msg_topic},
+ )
+
+ mqtt_data = hass.data[DATA_MQTT]
+ 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(
@@ -402,6 +279,71 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
}
),
)
+ 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)
+ 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)
+ client = MQTT(hass, entry, conf)
+ if DOMAIN in hass.data:
+ mqtt_data = hass.data[DATA_MQTT]
+ mqtt_data.config = mqtt_yaml
+ mqtt_data.client = client
+ else:
+ # Initial setup
+ hass.data[DATA_MQTT] = mqtt_data = MqttData(config=mqtt_yaml, client=client)
+ await client.async_start(mqtt_data)
+
+ # Restore saved subscriptions
+ if mqtt_data.subscriptions_to_restore:
+ mqtt_data.client.async_restore_tracked_subscriptions(
+ mqtt_data.subscriptions_to_restore
+ )
+ mqtt_data.subscriptions_to_restore = set()
+ mqtt_data.reload_dispatchers.append(
+ entry.add_update_listener(_async_config_entry_updated)
+ )
+
+ return (mqtt_data, conf)
+
+ client_available: asyncio.Future[bool]
+ if DATA_MQTT_AVAILABLE not in hass.data:
+ client_available = hass.data[DATA_MQTT_AVAILABLE] = hass.loop.create_future()
+ else:
+ client_available = hass.data[DATA_MQTT_AVAILABLE]
+
+ mqtt_data, conf = await _setup_client()
+ platforms_used = platforms_from_config(mqtt_data.config)
+ platforms_used.update(
+ entry.domain
+ for entry in er.async_entries_for_config_entry(
+ er.async_get(hass), entry.entry_id
+ )
+ )
+ integration = async_get_loaded_integration(hass, DOMAIN)
+ # Preload platforms we know we are going to use so
+ # discovery can setup each platform synchronously
+ # and avoid creating a flood of tasks at startup
+ # while waiting for the the imports to complete
+ if not integration.platforms_are_loaded(platforms_used):
+ with async_pause_setup(hass, SetupPhases.WAIT_IMPORT_PLATFORMS):
+ await integration.async_get_platforms(platforms_used)
+
+ # Wait to connect until the platforms are loaded so
+ # we can be sure discovery does not have to wait for
+ # each platform to load when we get the flood of retained
+ # messages on connect
+ await mqtt_data.client.async_connect(client_available)
# setup platforms and discovery
async def _reload_config(call: ServiceCall) -> None:
@@ -557,10 +499,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
mqtt_data = hass.data[DATA_MQTT]
mqtt_client = mqtt_data.client
- # Unload publish and dump services.
- hass.services.async_remove(DOMAIN, SERVICE_PUBLISH)
- hass.services.async_remove(DOMAIN, SERVICE_DUMP)
-
# Stop the discovery
await discovery.async_stop(hass)
# Unload the platforms
diff --git a/homeassistant/components/mqtt/alarm_control_panel.py b/homeassistant/components/mqtt/alarm_control_panel.py
index 76bac8540a4..613f665c302 100644
--- a/homeassistant/components/mqtt/alarm_control_panel.py
+++ b/homeassistant/components/mqtt/alarm_control_panel.py
@@ -35,6 +35,8 @@ from .util import valid_publish_topic, valid_subscribe_topic
_LOGGER = logging.getLogger(__name__)
+PARALLEL_UPDATES = 0
+
_SUPPORTED_FEATURES = {
"arm_home": AlarmControlPanelEntityFeature.ARM_HOME,
"arm_away": AlarmControlPanelEntityFeature.ARM_AWAY,
diff --git a/homeassistant/components/mqtt/binary_sensor.py b/homeassistant/components/mqtt/binary_sensor.py
index 7f89a78991a..b49dc7aa24c 100644
--- a/homeassistant/components/mqtt/binary_sensor.py
+++ b/homeassistant/components/mqtt/binary_sensor.py
@@ -43,6 +43,8 @@ from .schemas import MQTT_ENTITY_COMMON_SCHEMA
_LOGGER = logging.getLogger(__name__)
+PARALLEL_UPDATES = 0
+
DEFAULT_NAME = "MQTT Binary sensor"
CONF_OFF_DELAY = "off_delay"
DEFAULT_PAYLOAD_OFF = "OFF"
diff --git a/homeassistant/components/mqtt/button.py b/homeassistant/components/mqtt/button.py
index 2aac51890c1..8e5446b532e 100644
--- a/homeassistant/components/mqtt/button.py
+++ b/homeassistant/components/mqtt/button.py
@@ -20,6 +20,8 @@ from .models import MqttCommandTemplate
from .schemas import MQTT_ENTITY_COMMON_SCHEMA
from .util import valid_publish_topic
+PARALLEL_UPDATES = 0
+
CONF_PAYLOAD_PRESS = "payload_press"
DEFAULT_NAME = "MQTT Button"
DEFAULT_PAYLOAD_PRESS = "PRESS"
diff --git a/homeassistant/components/mqtt/camera.py b/homeassistant/components/mqtt/camera.py
index ca622defb25..88fabad0446 100644
--- a/homeassistant/components/mqtt/camera.py
+++ b/homeassistant/components/mqtt/camera.py
@@ -27,6 +27,8 @@ from .util import valid_subscribe_topic
_LOGGER = logging.getLogger(__name__)
+PARALLEL_UPDATES = 0
+
CONF_IMAGE_ENCODING = "image_encoding"
DEFAULT_NAME = "MQTT Camera"
diff --git a/homeassistant/components/mqtt/client.py b/homeassistant/components/mqtt/client.py
index a626e0e5b28..6500c9f91c9 100644
--- a/homeassistant/components/mqtt/client.py
+++ b/homeassistant/components/mqtt/client.py
@@ -119,7 +119,7 @@ MAX_PACKETS_TO_READ = 500
type SocketType = socket.socket | ssl.SSLSocket | mqtt.WebsocketWrapper | Any
-type SubscribePayloadType = str | bytes # Only bytes if encoding is None
+type SubscribePayloadType = str | bytes | bytearray # Only bytes if encoding is None
def publish(
@@ -227,7 +227,7 @@ def async_subscribe_internal(
translation_placeholders={"topic": topic},
) from exc
client = mqtt_data.client
- if not client.connected and not mqtt_config_entry_enabled(hass):
+ if not mqtt_config_entry_enabled(hass):
raise HomeAssistantError(
f"Cannot subscribe to topic '{topic}', MQTT is not enabled",
translation_key="mqtt_not_setup_cannot_subscribe",
@@ -661,7 +661,7 @@ class MQTT:
self.conf.get(CONF_PORT, DEFAULT_PORT),
self.conf.get(CONF_KEEPALIVE, DEFAULT_KEEPALIVE),
)
- except OSError as err:
+ except (OSError, mqtt.WebsocketConnectionError) as err:
_LOGGER.error("Failed to connect to MQTT server due to exception: %s", err)
self._async_connection_result(False)
finally:
@@ -695,12 +695,15 @@ class MQTT:
async def _reconnect_loop(self) -> None:
"""Reconnect to the MQTT server."""
+ # pylint: disable-next=import-outside-toplevel
+ import paho.mqtt.client as mqtt
+
while True:
if not self.connected:
try:
async with self._connection_lock, self._async_connect_in_executor():
await self.hass.async_add_executor_job(self._mqttc.reconnect)
- except OSError as err:
+ except (OSError, mqtt.WebsocketConnectionError) as err:
_LOGGER.debug(
"Error re-connecting to MQTT server due to exception: %s", err
)
@@ -776,7 +779,11 @@ class MQTT:
else:
del self._wildcard_subscriptions[subscription]
except (KeyError, ValueError) as exc:
- raise HomeAssistantError("Can't remove subscription twice") from exc
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="mqtt_not_setup_cannot_unsubscribe_twice",
+ translation_placeholders={"topic": topic},
+ ) from exc
@callback
def _async_queue_subscriptions(
@@ -822,7 +829,11 @@ class MQTT:
) -> Callable[[], None]:
"""Set up a subscription to a topic with the provided qos."""
if not isinstance(topic, str):
- raise HomeAssistantError("Topic needs to be a string!")
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="mqtt_topic_not_a_string",
+ translation_placeholders={"topic": topic},
+ )
if job_type is None:
job_type = get_hassjob_callable_job_type(msg_callback)
@@ -1213,7 +1224,11 @@ class MQTT:
import paho.mqtt.client as mqtt
raise HomeAssistantError(
- f"Error talking to MQTT: {mqtt.error_string(result_code)}"
+ translation_domain=DOMAIN,
+ translation_key="mqtt_broker_error",
+ translation_placeholders={
+ "error_message": mqtt.error_string(result_code)
+ },
)
# Create the mid event if not created, either _mqtt_handle_mid or
diff --git a/homeassistant/components/mqtt/climate.py b/homeassistant/components/mqtt/climate.py
index dd3efa4054b..e62303472ed 100644
--- a/homeassistant/components/mqtt/climate.py
+++ b/homeassistant/components/mqtt/climate.py
@@ -91,6 +91,8 @@ from .util import valid_publish_topic, valid_subscribe_topic
_LOGGER = logging.getLogger(__name__)
+PARALLEL_UPDATES = 0
+
DEFAULT_NAME = "MQTT HVAC"
CONF_FAN_MODE_COMMAND_TEMPLATE = "fan_mode_command_template"
@@ -519,7 +521,6 @@ class MqttClimate(MqttTemperatureControlEntity, ClimateEntity):
_attributes_extra_blocked = MQTT_CLIMATE_ATTRIBUTES_BLOCKED
_attr_target_temperature_low: float | None = None
_attr_target_temperature_high: float | None = None
- _enable_turn_on_off_backwards_compatibility = False
@staticmethod
def config_schema() -> VolSchemaType:
diff --git a/homeassistant/components/mqtt/config_flow.py b/homeassistant/components/mqtt/config_flow.py
index 6e6b44cd4b8..0081246c705 100644
--- a/homeassistant/components/mqtt/config_flow.py
+++ b/homeassistant/components/mqtt/config_flow.py
@@ -18,6 +18,7 @@ import voluptuous as vol
from homeassistant.components.file_upload import process_uploaded_file
from homeassistant.components.hassio import AddonError, AddonManager, AddonState
from homeassistant.config_entries import (
+ SOURCE_RECONFIGURE,
ConfigEntry,
ConfigFlow,
ConfigFlowResult,
@@ -33,7 +34,7 @@ from homeassistant.const import (
CONF_PROTOCOL,
CONF_USERNAME,
)
-from homeassistant.core import callback
+from homeassistant.core import HomeAssistant, callback
from homeassistant.data_entry_flow import AbortFlow
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.hassio import is_hassio
@@ -331,7 +332,9 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
break
else:
raise AddonError(
- f"Failed to correctly start {addon_manager.addon_name} add-on"
+ translation_domain=DOMAIN,
+ translation_key="addon_start_failed",
+ translation_placeholders={"addon": addon_manager.addon_name},
)
async def async_step_user(
@@ -467,20 +470,32 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
errors: dict[str, str] = {}
fields: OrderedDict[Any, Any] = OrderedDict()
validated_user_input: dict[str, Any] = {}
+ if is_reconfigure := (self.source == SOURCE_RECONFIGURE):
+ reconfigure_entry = self._get_reconfigure_entry()
if await async_get_broker_settings(
self,
fields,
- None,
+ reconfigure_entry.data if is_reconfigure else None,
user_input,
validated_user_input,
errors,
):
+ if is_reconfigure:
+ update_password_from_user_input(
+ reconfigure_entry.data.get(CONF_PASSWORD), validated_user_input
+ )
+
can_connect = await self.hass.async_add_executor_job(
try_connection,
validated_user_input,
)
if can_connect:
+ if is_reconfigure:
+ return self.async_update_reload_and_abort(
+ reconfigure_entry,
+ data=validated_user_input,
+ )
validated_user_input[CONF_DISCOVERY] = DEFAULT_DISCOVERY
return self.async_create_entry(
title=validated_user_input[CONF_BROKER],
@@ -493,6 +508,12 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
step_id="broker", data_schema=vol.Schema(fields), errors=errors
)
+ async def async_step_reconfigure(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Handle a reconfiguration flow initialized by the user."""
+ return await self.async_step_broker()
+
async def async_step_hassio(
self, discovery_info: HassioServiceInfo
) -> ConfigFlowResult:
@@ -545,7 +566,7 @@ class MQTTOptionsFlowHandler(OptionsFlow):
def __init__(self) -> None:
"""Initialize MQTT options flow."""
- self.broker_config: dict[str, str | int] = {}
+ self.broker_config: dict[str, Any] = {}
async def async_step_init(self, user_input: None = None) -> ConfigFlowResult:
"""Manage the MQTT options."""
@@ -735,6 +756,16 @@ class MQTTOptionsFlowHandler(OptionsFlow):
)
+async def _get_uploaded_file(hass: HomeAssistant, id: str) -> str:
+ """Get file content from uploaded file."""
+
+ def _proces_uploaded_file() -> str:
+ with process_uploaded_file(hass, id) as file_path:
+ return file_path.read_text(encoding=DEFAULT_ENCODING)
+
+ return await hass.async_add_executor_job(_proces_uploaded_file)
+
+
async def async_get_broker_settings(
flow: ConfigFlow | OptionsFlow,
fields: OrderedDict[Any, Any],
@@ -793,8 +824,7 @@ async def async_get_broker_settings(
return False
certificate_id: str | None = user_input.get(CONF_CERTIFICATE)
if certificate_id:
- with process_uploaded_file(hass, certificate_id) as certificate_file:
- certificate = certificate_file.read_text(encoding=DEFAULT_ENCODING)
+ certificate = await _get_uploaded_file(hass, certificate_id)
# Return to form for file upload CA cert or client cert and key
if (
@@ -810,15 +840,9 @@ async def async_get_broker_settings(
return False
if client_certificate_id:
- with process_uploaded_file(
- hass, client_certificate_id
- ) as client_certificate_file:
- client_certificate = client_certificate_file.read_text(
- encoding=DEFAULT_ENCODING
- )
+ client_certificate = await _get_uploaded_file(hass, client_certificate_id)
if client_key_id:
- with process_uploaded_file(hass, client_key_id) as key_file:
- client_key = key_file.read_text(encoding=DEFAULT_ENCODING)
+ client_key = await _get_uploaded_file(hass, client_key_id)
certificate_data: dict[str, Any] = {}
if certificate:
diff --git a/homeassistant/components/mqtt/cover.py b/homeassistant/components/mqtt/cover.py
index 0b495663803..c7d041848f0 100644
--- a/homeassistant/components/mqtt/cover.py
+++ b/homeassistant/components/mqtt/cover.py
@@ -69,6 +69,8 @@ from .util import valid_publish_topic, valid_subscribe_topic
_LOGGER = logging.getLogger(__name__)
+PARALLEL_UPDATES = 0
+
CONF_GET_POSITION_TOPIC = "position_topic"
CONF_GET_POSITION_TEMPLATE = "position_template"
CONF_SET_POSITION_TOPIC = "set_position_topic"
diff --git a/homeassistant/components/mqtt/device_tracker.py b/homeassistant/components/mqtt/device_tracker.py
index b87db40ccf7..bdf543e046a 100644
--- a/homeassistant/components/mqtt/device_tracker.py
+++ b/homeassistant/components/mqtt/device_tracker.py
@@ -36,6 +36,8 @@ from .util import valid_subscribe_topic
_LOGGER = logging.getLogger(__name__)
+PARALLEL_UPDATES = 0
+
CONF_PAYLOAD_HOME = "payload_home"
CONF_PAYLOAD_NOT_HOME = "payload_not_home"
CONF_SOURCE_TYPE = "source_type"
diff --git a/homeassistant/components/mqtt/device_trigger.py b/homeassistant/components/mqtt/device_trigger.py
index 80faf879587..8665ac26961 100644
--- a/homeassistant/components/mqtt/device_trigger.py
+++ b/homeassistant/components/mqtt/device_trigger.py
@@ -148,7 +148,10 @@ class Trigger:
def async_remove() -> None:
"""Remove trigger."""
if instance not in self.trigger_instances:
- raise HomeAssistantError("Can't remove trigger twice")
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="mqtt_trigger_cannot_remove_twice",
+ )
if instance.remove:
instance.remove()
diff --git a/homeassistant/components/mqtt/entity.py b/homeassistant/components/mqtt/entity.py
index 46b2c9e1d42..fb047cc8d5e 100644
--- a/homeassistant/components/mqtt/entity.py
+++ b/homeassistant/components/mqtt/entity.py
@@ -137,7 +137,7 @@ MQTT_ATTRIBUTES_BLOCKED = {
"extra_state_attributes",
"force_update",
"icon",
- "name",
+ "friendly_name",
"should_poll",
"state",
"supported_features",
@@ -1185,6 +1185,33 @@ def device_info_from_specifications(
return info
+@callback
+def ensure_via_device_exists(
+ hass: HomeAssistant, device_info: DeviceInfo | None, config_entry: ConfigEntry
+) -> None:
+ """Ensure the via device is in the device registry."""
+ if (
+ device_info is None
+ or CONF_VIA_DEVICE not in device_info
+ or (device_registry := dr.async_get(hass)).async_get_device(
+ identifiers={device_info["via_device"]}
+ )
+ ):
+ return
+
+ # Ensure the via device exists in the device registry
+ _LOGGER.debug(
+ "Device identifier %s via_device reference from device_info %s "
+ "not found in the Device Registry, creating new entry",
+ device_info["via_device"],
+ device_info,
+ )
+ device_registry.async_get_or_create(
+ config_entry_id=config_entry.entry_id,
+ identifiers={device_info["via_device"]},
+ )
+
+
class MqttEntityDeviceInfo(Entity):
"""Mixin used for mqtt platforms that support the device registry."""
@@ -1203,6 +1230,7 @@ class MqttEntityDeviceInfo(Entity):
device_info = self.device_info
if device_info is not None:
+ ensure_via_device_exists(self.hass, device_info, self._config_entry)
device_registry.async_get_or_create(
config_entry_id=config_entry_id, **device_info
)
@@ -1256,6 +1284,7 @@ class MqttEntity(
self, hass, discovery_data, self.discovery_update
)
MqttEntityDeviceInfo.__init__(self, config.get(CONF_DEVICE), config_entry)
+ ensure_via_device_exists(self.hass, self.device_info, self._config_entry)
def _init_entity_id(self) -> None:
"""Set entity_id from object_id if defined in config."""
@@ -1490,6 +1519,8 @@ def update_device(
config_entry_id = config_entry.entry_id
device_info = device_info_from_specifications(config[CONF_DEVICE])
+ ensure_via_device_exists(hass, device_info, config_entry)
+
if config_entry_id is not None and device_info is not None:
update_device_info = cast(dict[str, Any], device_info)
update_device_info["config_entry_id"] = config_entry_id
diff --git a/homeassistant/components/mqtt/event.py b/homeassistant/components/mqtt/event.py
index 3f67891ca5e..d9812aaaf48 100644
--- a/homeassistant/components/mqtt/event.py
+++ b/homeassistant/components/mqtt/event.py
@@ -38,6 +38,8 @@ from .schemas import MQTT_ENTITY_COMMON_SCHEMA
_LOGGER = logging.getLogger(__name__)
+PARALLEL_UPDATES = 0
+
CONF_EVENT_TYPES = "event_types"
MQTT_EVENT_ATTRIBUTES_BLOCKED = frozenset(
diff --git a/homeassistant/components/mqtt/fan.py b/homeassistant/components/mqtt/fan.py
index 70187ee9eb1..4d2e764a0d5 100644
--- a/homeassistant/components/mqtt/fan.py
+++ b/homeassistant/components/mqtt/fan.py
@@ -57,6 +57,8 @@ from .models import (
from .schemas import MQTT_ENTITY_COMMON_SCHEMA
from .util import valid_publish_topic, valid_subscribe_topic
+PARALLEL_UPDATES = 0
+
CONF_DIRECTION_STATE_TOPIC = "direction_state_topic"
CONF_DIRECTION_COMMAND_TOPIC = "direction_command_topic"
CONF_DIRECTION_VALUE_TEMPLATE = "direction_value_template"
@@ -224,7 +226,6 @@ class MqttFan(MqttEntity, FanEntity):
_optimistic_preset_mode: bool
_payload: dict[str, Any]
_speed_range: tuple[int, int]
- _enable_turn_on_off_backwards_compatibility = False
@staticmethod
def config_schema() -> VolSchemaType:
diff --git a/homeassistant/components/mqtt/humidifier.py b/homeassistant/components/mqtt/humidifier.py
index 304d293de79..5d1af03ad24 100644
--- a/homeassistant/components/mqtt/humidifier.py
+++ b/homeassistant/components/mqtt/humidifier.py
@@ -59,6 +59,8 @@ from .models import (
from .schemas import MQTT_ENTITY_COMMON_SCHEMA
from .util import valid_publish_topic, valid_subscribe_topic
+PARALLEL_UPDATES = 0
+
CONF_AVAILABLE_MODES_LIST = "modes"
CONF_DEVICE_CLASS = "device_class"
CONF_MODE_COMMAND_TEMPLATE = "mode_command_template"
diff --git a/homeassistant/components/mqtt/image.py b/homeassistant/components/mqtt/image.py
index 6ecdee06489..4b7b2d783d2 100644
--- a/homeassistant/components/mqtt/image.py
+++ b/homeassistant/components/mqtt/image.py
@@ -37,6 +37,8 @@ from .util import valid_subscribe_topic
_LOGGER = logging.getLogger(__name__)
+PARALLEL_UPDATES = 0
+
CONF_CONTENT_TYPE = "content_type"
CONF_IMAGE_ENCODING = "image_encoding"
CONF_IMAGE_TOPIC = "image_topic"
diff --git a/homeassistant/components/mqtt/lawn_mower.py b/homeassistant/components/mqtt/lawn_mower.py
index 11afe4220c4..87577c4b4d9 100644
--- a/homeassistant/components/mqtt/lawn_mower.py
+++ b/homeassistant/components/mqtt/lawn_mower.py
@@ -38,6 +38,8 @@ from .util import valid_publish_topic, valid_subscribe_topic
_LOGGER = logging.getLogger(__name__)
+PARALLEL_UPDATES = 0
+
CONF_ACTIVITY_STATE_TOPIC = "activity_state_topic"
CONF_ACTIVITY_VALUE_TEMPLATE = "activity_value_template"
CONF_DOCK_COMMAND_TOPIC = "dock_command_topic"
diff --git a/homeassistant/components/mqtt/light/__init__.py b/homeassistant/components/mqtt/light/__init__.py
index a1ba955181d..328f80cb5ea 100644
--- a/homeassistant/components/mqtt/light/__init__.py
+++ b/homeassistant/components/mqtt/light/__init__.py
@@ -30,6 +30,8 @@ from .schema_template import (
MqttLightTemplate,
)
+PARALLEL_UPDATES = 0
+
def validate_mqtt_light_discovery(config_value: dict[str, Any]) -> ConfigType:
"""Validate MQTT light schema for discovery."""
diff --git a/homeassistant/components/mqtt/light/schema_basic.py b/homeassistant/components/mqtt/light/schema_basic.py
index de6a9d4c126..159a23d14d9 100644
--- a/homeassistant/components/mqtt/light/schema_basic.py
+++ b/homeassistant/components/mqtt/light/schema_basic.py
@@ -9,20 +9,25 @@ from typing import Any, cast
import voluptuous as vol
from homeassistant.components.light import (
+ _DEPRECATED_ATTR_COLOR_TEMP,
+ _DEPRECATED_ATTR_MAX_MIREDS,
+ _DEPRECATED_ATTR_MIN_MIREDS,
ATTR_BRIGHTNESS,
ATTR_COLOR_MODE,
- ATTR_COLOR_TEMP,
+ ATTR_COLOR_TEMP_KELVIN,
ATTR_EFFECT,
ATTR_EFFECT_LIST,
ATTR_HS_COLOR,
- ATTR_MAX_MIREDS,
- ATTR_MIN_MIREDS,
+ ATTR_MAX_COLOR_TEMP_KELVIN,
+ ATTR_MIN_COLOR_TEMP_KELVIN,
ATTR_RGB_COLOR,
ATTR_RGBW_COLOR,
ATTR_RGBWW_COLOR,
ATTR_SUPPORTED_COLOR_MODES,
ATTR_WHITE,
ATTR_XY_COLOR,
+ DEFAULT_MAX_KELVIN,
+ DEFAULT_MIN_KELVIN,
ENTITY_ID_FORMAT,
ColorMode,
LightEntity,
@@ -112,12 +117,15 @@ MQTT_LIGHT_ATTRIBUTES_BLOCKED = frozenset(
{
ATTR_COLOR_MODE,
ATTR_BRIGHTNESS,
- ATTR_COLOR_TEMP,
+ _DEPRECATED_ATTR_COLOR_TEMP.value,
+ ATTR_COLOR_TEMP_KELVIN,
ATTR_EFFECT,
ATTR_EFFECT_LIST,
ATTR_HS_COLOR,
- ATTR_MAX_MIREDS,
- ATTR_MIN_MIREDS,
+ ATTR_MAX_COLOR_TEMP_KELVIN,
+ _DEPRECATED_ATTR_MAX_MIREDS.value,
+ ATTR_MIN_COLOR_TEMP_KELVIN,
+ _DEPRECATED_ATTR_MIN_MIREDS.value,
ATTR_RGB_COLOR,
ATTR_RGBW_COLOR,
ATTR_RGBWW_COLOR,
@@ -240,7 +248,7 @@ class MqttLight(MqttEntity, LightEntity, RestoreEntity):
_optimistic: bool
_optimistic_brightness: bool
_optimistic_color_mode: bool
- _optimistic_color_temp: bool
+ _optimistic_color_temp_kelvin: bool
_optimistic_effect: bool
_optimistic_hs_color: bool
_optimistic_rgb_color: bool
@@ -255,8 +263,16 @@ class MqttLight(MqttEntity, LightEntity, RestoreEntity):
def _setup_from_config(self, config: ConfigType) -> None:
"""(Re)Setup the entity."""
- self._attr_min_mireds = config.get(CONF_MIN_MIREDS, super().min_mireds)
- self._attr_max_mireds = config.get(CONF_MAX_MIREDS, super().max_mireds)
+ 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
+ )
+ 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
+ )
self._attr_effect_list = config.get(CONF_EFFECT_LIST)
topic: dict[str, str | None] = {
@@ -321,7 +337,7 @@ class MqttLight(MqttEntity, LightEntity, RestoreEntity):
and topic[CONF_RGB_STATE_TOPIC] is None
)
)
- self._optimistic_color_temp = (
+ self._optimistic_color_temp_kelvin = (
optimistic or topic[CONF_COLOR_TEMP_STATE_TOPIC] is None
)
self._optimistic_effect = optimistic or topic[CONF_EFFECT_STATE_TOPIC] is None
@@ -472,10 +488,8 @@ class MqttLight(MqttEntity, LightEntity, RestoreEntity):
def _converter(
r: int, g: int, b: int, cw: int, ww: int
) -> tuple[int, int, int]:
- min_kelvin = color_util.color_temperature_mired_to_kelvin(self.max_mireds)
- max_kelvin = color_util.color_temperature_mired_to_kelvin(self.min_mireds)
return color_util.color_rgbww_to_rgb(
- r, g, b, cw, ww, min_kelvin, max_kelvin
+ r, g, b, cw, ww, self.min_color_temp_kelvin, self.max_color_temp_kelvin
)
rgbww = self._rgbx_received(
@@ -512,7 +526,9 @@ class MqttLight(MqttEntity, LightEntity, RestoreEntity):
if self._optimistic_color_mode:
self._attr_color_mode = ColorMode.COLOR_TEMP
- self._attr_color_temp = int(payload)
+ self._attr_color_temp_kelvin = color_util.color_temperature_mired_to_kelvin(
+ int(payload)
+ )
@callback
def _effect_received(self, msg: ReceiveMessage) -> None:
@@ -586,7 +602,7 @@ class MqttLight(MqttEntity, LightEntity, RestoreEntity):
self.add_subscription(
CONF_COLOR_TEMP_STATE_TOPIC,
self._color_temp_received,
- {"_attr_color_mode", "_attr_color_temp"},
+ {"_attr_color_mode", "_attr_color_temp_kelvin"},
)
self.add_subscription(
CONF_EFFECT_STATE_TOPIC, self._effect_received, {"_attr_effect"}
@@ -625,7 +641,7 @@ class MqttLight(MqttEntity, LightEntity, RestoreEntity):
restore_state(ATTR_RGBW_COLOR)
restore_state(ATTR_RGBWW_COLOR)
restore_state(ATTR_COLOR_MODE)
- restore_state(ATTR_COLOR_TEMP)
+ restore_state(ATTR_COLOR_TEMP_KELVIN)
restore_state(ATTR_EFFECT)
restore_state(ATTR_HS_COLOR)
restore_state(ATTR_XY_COLOR)
@@ -797,14 +813,21 @@ class MqttLight(MqttEntity, LightEntity, RestoreEntity):
await publish(CONF_RGBWW_COMMAND_TOPIC, rgbww_s)
should_update |= set_optimistic(ATTR_BRIGHTNESS, kwargs[ATTR_BRIGHTNESS])
if (
- ATTR_COLOR_TEMP in kwargs
+ ATTR_COLOR_TEMP_KELVIN in kwargs
and self._topic[CONF_COLOR_TEMP_COMMAND_TOPIC] is not None
):
ct_command_tpl = self._command_templates[CONF_COLOR_TEMP_COMMAND_TEMPLATE]
- color_temp = ct_command_tpl(int(kwargs[ATTR_COLOR_TEMP]), None)
+ color_temp = ct_command_tpl(
+ color_util.color_temperature_kelvin_to_mired(
+ kwargs[ATTR_COLOR_TEMP_KELVIN]
+ ),
+ None,
+ )
await publish(CONF_COLOR_TEMP_COMMAND_TOPIC, color_temp)
should_update |= set_optimistic(
- ATTR_COLOR_TEMP, kwargs[ATTR_COLOR_TEMP], ColorMode.COLOR_TEMP
+ ATTR_COLOR_TEMP_KELVIN,
+ kwargs[ATTR_COLOR_TEMP_KELVIN],
+ ColorMode.COLOR_TEMP,
)
if (
diff --git a/homeassistant/components/mqtt/light/schema_json.py b/homeassistant/components/mqtt/light/schema_json.py
index 89f338f6bab..f6efdd3281d 100644
--- a/homeassistant/components/mqtt/light/schema_json.py
+++ b/homeassistant/components/mqtt/light/schema_json.py
@@ -12,7 +12,7 @@ import voluptuous as vol
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
ATTR_COLOR_MODE,
- ATTR_COLOR_TEMP,
+ ATTR_COLOR_TEMP_KELVIN,
ATTR_EFFECT,
ATTR_FLASH,
ATTR_HS_COLOR,
@@ -22,6 +22,8 @@ from homeassistant.components.light import (
ATTR_TRANSITION,
ATTR_WHITE,
ATTR_XY_COLOR,
+ DEFAULT_MAX_KELVIN,
+ DEFAULT_MIN_KELVIN,
DOMAIN as LIGHT_DOMAIN,
ENTITY_ID_FORMAT,
FLASH_LONG,
@@ -273,8 +275,16 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity):
def _setup_from_config(self, config: ConfigType) -> None:
"""(Re)Setup the entity."""
- self._attr_max_mireds = config.get(CONF_MAX_MIREDS, super().max_mireds)
- self._attr_min_mireds = config.get(CONF_MIN_MIREDS, super().min_mireds)
+ 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
+ )
+ 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
+ )
self._attr_effect_list = config.get(CONF_EFFECT_LIST)
self._topic = {
@@ -370,7 +380,11 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity):
return
try:
if color_mode == ColorMode.COLOR_TEMP:
- self._attr_color_temp = int(values["color_temp"])
+ self._attr_color_temp_kelvin = (
+ color_util.color_temperature_mired_to_kelvin(
+ values["color_temp"]
+ )
+ )
self._attr_color_mode = ColorMode.COLOR_TEMP
elif color_mode == ColorMode.HS:
hue = float(values["color"]["h"])
@@ -469,12 +483,16 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity):
# Deprecated color handling
try:
if values["color_temp"] is None:
- self._attr_color_temp = None
+ self._attr_color_temp_kelvin = None
else:
- self._attr_color_temp = int(values["color_temp"]) # type: ignore[arg-type]
+ self._attr_color_temp_kelvin = (
+ color_util.color_temperature_mired_to_kelvin(
+ values["color_temp"] # type: ignore[arg-type]
+ )
+ )
except KeyError:
pass
- except ValueError:
+ except (TypeError, ValueError):
_LOGGER.warning(
"Invalid color temp value '%s' received for entity %s",
values["color_temp"],
@@ -496,7 +514,7 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity):
self._state_received,
{
"_attr_brightness",
- "_attr_color_temp",
+ "_attr_color_temp_kelvin",
"_attr_effect",
"_attr_hs_color",
"_attr_is_on",
@@ -522,8 +540,8 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity):
self._attr_color_mode = last_attributes.get(
ATTR_COLOR_MODE, self.color_mode
)
- self._attr_color_temp = last_attributes.get(
- ATTR_COLOR_TEMP, self.color_temp
+ self._attr_color_temp_kelvin = last_attributes.get(
+ ATTR_COLOR_TEMP_KELVIN, self.color_temp_kelvin
)
self._attr_effect = last_attributes.get(ATTR_EFFECT, self.effect)
self._attr_hs_color = last_attributes.get(ATTR_HS_COLOR, self.hs_color)
@@ -623,7 +641,7 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity):
message["color"]["s"] = hs_color[1]
if self._optimistic:
- self._attr_color_temp = None
+ self._attr_color_temp_kelvin = None
self._attr_hs_color = kwargs[ATTR_HS_COLOR]
should_update = True
@@ -690,12 +708,14 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity):
self._attr_brightness = kwargs[ATTR_BRIGHTNESS]
should_update = True
- if ATTR_COLOR_TEMP in kwargs:
- message["color_temp"] = int(kwargs[ATTR_COLOR_TEMP])
+ if ATTR_COLOR_TEMP_KELVIN in kwargs:
+ message["color_temp"] = 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 = kwargs[ATTR_COLOR_TEMP]
+ self._attr_color_temp_kelvin = kwargs[ATTR_COLOR_TEMP_KELVIN]
self._attr_hs_color = None
should_update = True
diff --git a/homeassistant/components/mqtt/light/schema_template.py b/homeassistant/components/mqtt/light/schema_template.py
index c4f9cad44c5..722bd864366 100644
--- a/homeassistant/components/mqtt/light/schema_template.py
+++ b/homeassistant/components/mqtt/light/schema_template.py
@@ -10,11 +10,13 @@ import voluptuous as vol
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
- ATTR_COLOR_TEMP,
+ ATTR_COLOR_TEMP_KELVIN,
ATTR_EFFECT,
ATTR_FLASH,
ATTR_HS_COLOR,
ATTR_TRANSITION,
+ DEFAULT_MAX_KELVIN,
+ DEFAULT_MIN_KELVIN,
ENTITY_ID_FORMAT,
ColorMode,
LightEntity,
@@ -126,8 +128,16 @@ class MqttLightTemplate(MqttEntity, LightEntity, RestoreEntity):
def _setup_from_config(self, config: ConfigType) -> None:
"""(Re)Setup the entity."""
- self._attr_max_mireds = config.get(CONF_MAX_MIREDS, super().max_mireds)
- self._attr_min_mireds = config.get(CONF_MIN_MIREDS, super().min_mireds)
+ 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
+ )
+ 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
+ )
self._attr_effect_list = config.get(CONF_EFFECT_LIST)
self._topics = {
@@ -213,8 +223,10 @@ class MqttLightTemplate(MqttEntity, LightEntity, RestoreEntity):
color_temp = self._value_templates[CONF_COLOR_TEMP_TEMPLATE](
msg.payload
)
- self._attr_color_temp = (
- int(color_temp) if color_temp != "None" else None
+ self._attr_color_temp_kelvin = (
+ color_util.color_temperature_mired_to_kelvin(int(color_temp))
+ if color_temp != "None"
+ else None
)
except ValueError:
_LOGGER.warning("Invalid color temperature value received")
@@ -256,7 +268,7 @@ class MqttLightTemplate(MqttEntity, LightEntity, RestoreEntity):
{
"_attr_brightness",
"_attr_color_mode",
- "_attr_color_temp",
+ "_attr_color_temp_kelvin",
"_attr_effect",
"_attr_hs_color",
"_attr_is_on",
@@ -275,8 +287,10 @@ class MqttLightTemplate(MqttEntity, LightEntity, RestoreEntity):
if last_state.attributes.get(ATTR_HS_COLOR):
self._attr_hs_color = last_state.attributes.get(ATTR_HS_COLOR)
self._update_color_mode()
- if last_state.attributes.get(ATTR_COLOR_TEMP):
- self._attr_color_temp = last_state.attributes.get(ATTR_COLOR_TEMP)
+ if last_state.attributes.get(ATTR_COLOR_TEMP_KELVIN):
+ self._attr_color_temp_kelvin = last_state.attributes.get(
+ ATTR_COLOR_TEMP_KELVIN
+ )
if last_state.attributes.get(ATTR_EFFECT):
self._attr_effect = last_state.attributes.get(ATTR_EFFECT)
@@ -295,11 +309,13 @@ class MqttLightTemplate(MqttEntity, LightEntity, RestoreEntity):
if self._optimistic:
self._attr_brightness = kwargs[ATTR_BRIGHTNESS]
- if ATTR_COLOR_TEMP in kwargs:
- values["color_temp"] = int(kwargs[ATTR_COLOR_TEMP])
+ if ATTR_COLOR_TEMP_KELVIN in kwargs:
+ values["color_temp"] = color_util.color_temperature_kelvin_to_mired(
+ kwargs[ATTR_COLOR_TEMP_KELVIN]
+ )
if self._optimistic:
- self._attr_color_temp = kwargs[ATTR_COLOR_TEMP]
+ self._attr_color_temp_kelvin = kwargs[ATTR_COLOR_TEMP_KELVIN]
self._attr_hs_color = None
self._update_color_mode()
@@ -325,7 +341,7 @@ class MqttLightTemplate(MqttEntity, LightEntity, RestoreEntity):
values["sat"] = hs_color[1]
if self._optimistic:
- self._attr_color_temp = None
+ self._attr_color_temp_kelvin = None
self._attr_hs_color = kwargs[ATTR_HS_COLOR]
self._update_color_mode()
diff --git a/homeassistant/components/mqtt/lock.py b/homeassistant/components/mqtt/lock.py
index e58d15b659d..2113dbbd5ba 100644
--- a/homeassistant/components/mqtt/lock.py
+++ b/homeassistant/components/mqtt/lock.py
@@ -45,6 +45,8 @@ from .schemas import MQTT_ENTITY_COMMON_SCHEMA
_LOGGER = logging.getLogger(__name__)
+PARALLEL_UPDATES = 0
+
CONF_CODE_FORMAT = "code_format"
CONF_PAYLOAD_LOCK = "payload_lock"
diff --git a/homeassistant/components/mqtt/manifest.json b/homeassistant/components/mqtt/manifest.json
index 25e98c01aaf..081449b142a 100644
--- a/homeassistant/components/mqtt/manifest.json
+++ b/homeassistant/components/mqtt/manifest.json
@@ -7,7 +7,6 @@
"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 4a5ccc02774..84442e75e73 100644
--- a/homeassistant/components/mqtt/notify.py
+++ b/homeassistant/components/mqtt/notify.py
@@ -20,6 +20,8 @@ from .models import MqttCommandTemplate
from .schemas import MQTT_ENTITY_COMMON_SCHEMA
from .util import valid_publish_topic
+PARALLEL_UPDATES = 0
+
DEFAULT_NAME = "MQTT notify"
PLATFORM_SCHEMA_MODERN = MQTT_BASE_SCHEMA.extend(
diff --git a/homeassistant/components/mqtt/number.py b/homeassistant/components/mqtt/number.py
index 895334f2e1e..a9bf1829b63 100644
--- a/homeassistant/components/mqtt/number.py
+++ b/homeassistant/components/mqtt/number.py
@@ -50,6 +50,8 @@ from .schemas import MQTT_ENTITY_COMMON_SCHEMA
_LOGGER = logging.getLogger(__name__)
+PARALLEL_UPDATES = 0
+
CONF_MIN = "min"
CONF_MAX = "max"
CONF_STEP = "step"
diff --git a/homeassistant/components/mqtt/quality_scale.yaml b/homeassistant/components/mqtt/quality_scale.yaml
new file mode 100644
index 00000000000..26ce8cb08dd
--- /dev/null
+++ b/homeassistant/components/mqtt/quality_scale.yaml
@@ -0,0 +1,131 @@
+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: done
+ comment: >
+ Entities are updated through dispatchers, and these are
+ cleaned up when the integration unloads.
+ entity-unique-id:
+ status: exempt
+ comment: >
+ This is user configurable, but not required.
+ It is required though when a user wants to use device based discovery.
+ has-entity-name: done
+ runtime-data:
+ status: exempt
+ comment: >
+ Runtime data is not used, as the mqtt entry data is only used to set up the
+ MQTT broker, this happens during integration setup,
+ and only one config entry is allowed.
+ test-before-configure: done
+ test-before-setup:
+ status: exempt
+ comment: >
+ We choose to early exit the entry as it can take some time for the client
+ to connect. Waiting for the client would increase the overall setup time.
+ unique-config-entry: done
+ # Silver
+ config-entry-unloading: done
+ log-when-unavailable: done
+ entity-unavailable:
+ status: done
+ comment: |
+ Only supported for entities the user has assigned a unique_id.
+ action-exceptions: done
+ reauthentication-flow: done
+ parallel-updates: done
+ test-coverage: done
+ integration-owner: done
+ docs-installation-parameters: done
+ docs-configuration-parameters: done
+
+ # Gold
+ entity-translations:
+ status: exempt
+ comment: >
+ This is not possible because the integrations generates entities
+ based on a user supplied config or discovery.
+ entity-device-class:
+ status: done
+ comment: An entity device class can be configured by the user for each entity.
+ devices:
+ status: done
+ comment: >
+ A device context can be configured by the user for each entity.
+ It is not required though, except when using device based discovery.
+ entity-category:
+ status: done
+ comment: An entity category can be configured by the user for each entity.
+ entity-disabled-by-default:
+ status: done
+ comment: >
+ The user can configure this through YAML or discover
+ entities that are disabled by default.
+ discovery:
+ status: done
+ comment: >
+ When the Mosquitto MQTT broker add on is installed,
+ a MQTT config flow allows an automatic setup from its discovered settings.
+ stale-devices:
+ status: exempt
+ comment: >
+ This is is only supported for entities that are configured through MQTT discovery.
+ Users must manually cleanup stale entities that were set up though YAML.
+ diagnostics: done
+ exception-translations: done
+ icon-translations:
+ status: exempt
+ 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.
+ dynamic-devices:
+ status: done
+ comment: |
+ MQTT allow to dynamically create and remove devices through MQTT discovery.
+ discovery-update-info:
+ status: done
+ comment: >
+ If the Mosquitto broker add-on is used to set up MQTT from discovery,
+ and the broker add-on is re-installed,
+ MQTT will automatically update from the new brokers credentials.
+ repair-issues:
+ status: done
+ comment: >
+ This integration uses repair-issues when entities are set up through YAML.
+ To avoid user panic, discovery deprecation issues are logged only.
+ It is the responsibility of the maintainer or the service or device to
+ correct the discovery messages. Extra options are allowed
+ in MQTT messages to avoid breaking issues.
+ 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:
+ status: exempt
+ comment: |
+ This integration does not use web sessions.
+ strict-typing:
+ status: todo
+ comment: |
+ Requirement 'paho-mqtt==1.6.1' appears untyped
diff --git a/homeassistant/components/mqtt/scene.py b/homeassistant/components/mqtt/scene.py
index dad596d9c4f..314bd716ee0 100644
--- a/homeassistant/components/mqtt/scene.py
+++ b/homeassistant/components/mqtt/scene.py
@@ -21,6 +21,8 @@ from .entity import MqttEntity, async_setup_entity_entry_helper
from .schemas import MQTT_ENTITY_COMMON_SCHEMA
from .util import valid_publish_topic
+PARALLEL_UPDATES = 0
+
DEFAULT_NAME = "MQTT Scene"
DEFAULT_RETAIN = False
diff --git a/homeassistant/components/mqtt/select.py b/homeassistant/components/mqtt/select.py
index 37d3287988f..55d56ecd774 100644
--- a/homeassistant/components/mqtt/select.py
+++ b/homeassistant/components/mqtt/select.py
@@ -37,6 +37,8 @@ from .schemas import MQTT_ENTITY_COMMON_SCHEMA
_LOGGER = logging.getLogger(__name__)
+PARALLEL_UPDATES = 0
+
DEFAULT_NAME = "MQTT Select"
MQTT_SELECT_ATTRIBUTES_BLOCKED = frozenset(
diff --git a/homeassistant/components/mqtt/sensor.py b/homeassistant/components/mqtt/sensor.py
index 17ea0ab1f5b..bacbf4d323e 100644
--- a/homeassistant/components/mqtt/sensor.py
+++ b/homeassistant/components/mqtt/sensor.py
@@ -47,6 +47,8 @@ from .util import check_state_too_long
_LOGGER = logging.getLogger(__name__)
+PARALLEL_UPDATES = 0
+
CONF_EXPIRE_AFTER = "expire_after"
CONF_LAST_RESET_VALUE_TEMPLATE = "last_reset_value_template"
CONF_SUGGESTED_DISPLAY_PRECISION = "suggested_display_precision"
diff --git a/homeassistant/components/mqtt/siren.py b/homeassistant/components/mqtt/siren.py
index 1937b60fde0..22f64053d23 100644
--- a/homeassistant/components/mqtt/siren.py
+++ b/homeassistant/components/mqtt/siren.py
@@ -55,6 +55,8 @@ from .models import (
)
from .schemas import MQTT_ENTITY_COMMON_SCHEMA
+PARALLEL_UPDATES = 0
+
DEFAULT_NAME = "MQTT Siren"
DEFAULT_PAYLOAD_ON = "ON"
DEFAULT_PAYLOAD_OFF = "OFF"
diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json
index 8ab31e37857..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": {
@@ -61,6 +53,7 @@
"client_id": "The unique ID to identify the Home Assistant MQTT API as MQTT client. It is recommended to leave this option blank.",
"client_cert": "The client certificate to authenticate against your MQTT broker.",
"client_key": "The private key file that belongs to your client certificate.",
+ "keepalive": "A value less than 90 seconds is advised.",
"tls_insecure": "Option to ignore validation of your MQTT broker's certificate.",
"protocol": "The MQTT protocol your broker operates at. For example 3.1.1.",
"set_ca_cert": "Select **Auto** for automatic CA validation, or **Custom** and select **Next** to set a custom CA certificate, to allow validating your MQTT brokers certificate.",
@@ -100,6 +93,7 @@
"addon_connection_failed": "Failed to connect to the {addon} add-on. Check the add-on status and try again later.",
"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%]",
"single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]"
},
"error": {
@@ -113,7 +107,7 @@
"bad_ws_headers": "Supply valid HTTP headers as a JSON object",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
- "invalid_inclusion": "The client certificate and private key must be configurered together"
+ "invalid_inclusion": "The client certificate and private key must be configured together"
}
},
"device_automation": {
@@ -172,6 +166,7 @@
"client_id": "[%key:component::mqtt::config::step::broker::data_description::client_id%]",
"client_cert": "[%key:component::mqtt::config::step::broker::data_description::client_cert%]",
"client_key": "[%key:component::mqtt::config::step::broker::data_description::client_key%]",
+ "keepalive": "[%key:component::mqtt::config::step::broker::data_description::keepalive%]",
"tls_insecure": "[%key:component::mqtt::config::step::broker::data_description::tls_insecure%]",
"protocol": "[%key:component::mqtt::config::step::broker::data_description::protocol%]",
"set_ca_cert": "[%key:component::mqtt::config::step::broker::data_description::set_ca_cert%]",
@@ -206,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.",
@@ -287,6 +282,9 @@
}
},
"exceptions": {
+ "addon_start_failed": {
+ "message": "Failed to correctly start {addon} add-on."
+ },
"command_template_error": {
"message": "Parsing template `{command_template}` for entity `{entity_id}` failed with error: {error}."
},
@@ -296,11 +294,23 @@
"invalid_publish_topic": {
"message": "Unable to publish: topic template `{topic_template}` produced an invalid topic `{topic}` after rendering ({error})"
},
+ "mqtt_broker_error": {
+ "message": "Error talking to MQTT: {error_message}."
+ },
"mqtt_not_setup_cannot_subscribe": {
"message": "Cannot subscribe to topic \"{topic}\", make sure MQTT is set up correctly."
},
"mqtt_not_setup_cannot_publish": {
"message": "Cannot publish to topic \"{topic}\", make sure MQTT is set up correctly."
+ },
+ "mqtt_not_setup_cannot_unsubscribe_twice": {
+ "message": "Cannot unsubscribe topic \"{topic}\" twice."
+ },
+ "mqtt_topic_not_a_string": {
+ "message": "Topic needs to be a string! Got: {topic}."
+ },
+ "mqtt_trigger_cannot_remove_twice": {
+ "message": "Can't remove trigger twice."
}
}
}
diff --git a/homeassistant/components/mqtt/subscription.py b/homeassistant/components/mqtt/subscription.py
index 3f3f67970f3..08d501ede12 100644
--- a/homeassistant/components/mqtt/subscription.py
+++ b/homeassistant/components/mqtt/subscription.py
@@ -86,7 +86,7 @@ class EntitySubscription:
@callback
def async_prepare_subscribe_topics(
hass: HomeAssistant,
- new_state: dict[str, EntitySubscription] | None,
+ sub_state: dict[str, EntitySubscription] | None,
topics: dict[str, dict[str, Any]],
) -> dict[str, EntitySubscription]:
"""Prepare (re)subscribe to a set of MQTT topics.
@@ -101,8 +101,9 @@ def async_prepare_subscribe_topics(
sets of topics. Every call to async_subscribe_topics must always
contain _all_ the topics the subscription state should manage.
"""
- current_subscriptions = new_state if new_state is not None else {}
- new_state = {}
+ current_subscriptions: dict[str, EntitySubscription]
+ current_subscriptions = sub_state if sub_state is not None else {}
+ sub_state = {}
for key, value in topics.items():
# Extract the new requested subscription
requested = EntitySubscription(
@@ -119,7 +120,7 @@ def async_prepare_subscribe_topics(
# Get the current subscription state
current = current_subscriptions.pop(key, None)
requested.resubscribe_if_necessary(hass, current)
- new_state[key] = requested
+ sub_state[key] = requested
# Go through all remaining subscriptions and unsubscribe them
for remaining in current_subscriptions.values():
@@ -132,7 +133,7 @@ def async_prepare_subscribe_topics(
remaining.entity_id,
)
- return new_state
+ return sub_state
async def async_subscribe_topics(
diff --git a/homeassistant/components/mqtt/switch.py b/homeassistant/components/mqtt/switch.py
index a73c4fe53f8..0a54bcdb378 100644
--- a/homeassistant/components/mqtt/switch.py
+++ b/homeassistant/components/mqtt/switch.py
@@ -43,6 +43,8 @@ from .models import (
)
from .schemas import MQTT_ENTITY_COMMON_SCHEMA
+PARALLEL_UPDATES = 0
+
DEFAULT_NAME = "MQTT Switch"
DEFAULT_PAYLOAD_ON = "ON"
DEFAULT_PAYLOAD_OFF = "OFF"
@@ -89,7 +91,7 @@ class MqttSwitch(MqttEntity, SwitchEntity, RestoreEntity):
_entity_id_format = switch.ENTITY_ID_FORMAT
_optimistic: bool
- _is_on_map: dict[str | bytes, bool | None]
+ _is_on_map: dict[str | bytes | bytearray, bool | None]
_command_template: Callable[[PublishPayloadType], PublishPayloadType]
_value_template: Callable[[ReceivePayloadType], ReceivePayloadType]
diff --git a/homeassistant/components/mqtt/text.py b/homeassistant/components/mqtt/text.py
index edfecfbc038..b4ed33a7730 100644
--- a/homeassistant/components/mqtt/text.py
+++ b/homeassistant/components/mqtt/text.py
@@ -40,6 +40,8 @@ from .util import check_state_too_long
_LOGGER = logging.getLogger(__name__)
+PARALLEL_UPDATES = 0
+
CONF_MAX = "max"
CONF_MIN = "min"
CONF_PATTERN = "pattern"
diff --git a/homeassistant/components/mqtt/update.py b/homeassistant/components/mqtt/update.py
index 8878ff63127..99b4e5cb821 100644
--- a/homeassistant/components/mqtt/update.py
+++ b/homeassistant/components/mqtt/update.py
@@ -32,6 +32,8 @@ from .util import valid_publish_topic, valid_subscribe_topic
_LOGGER = logging.getLogger(__name__)
+PARALLEL_UPDATES = 0
+
DEFAULT_NAME = "MQTT Update"
CONF_DISPLAY_PRECISION = "display_precision"
diff --git a/homeassistant/components/mqtt/vacuum.py b/homeassistant/components/mqtt/vacuum.py
index 86b32aa281b..743bfb363f3 100644
--- a/homeassistant/components/mqtt/vacuum.py
+++ b/homeassistant/components/mqtt/vacuum.py
@@ -10,20 +10,12 @@ import voluptuous as vol
from homeassistant.components import vacuum
from homeassistant.components.vacuum import (
ENTITY_ID_FORMAT,
- STATE_CLEANING,
- STATE_DOCKED,
- STATE_ERROR,
- STATE_RETURNING,
StateVacuumEntity,
+ VacuumActivity,
VacuumEntityFeature,
)
from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import (
- ATTR_SUPPORTED_FEATURES,
- CONF_NAME,
- STATE_IDLE,
- STATE_PAUSED,
-)
+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.entity_platform import AddEntitiesCallback
@@ -39,17 +31,26 @@ from .models import ReceiveMessage
from .schemas import MQTT_ENTITY_COMMON_SCHEMA
from .util import valid_publish_topic
+PARALLEL_UPDATES = 0
+
BATTERY = "battery_level"
FAN_SPEED = "fan_speed"
STATE = "state"
-POSSIBLE_STATES: dict[str, str] = {
- STATE_IDLE: STATE_IDLE,
- STATE_DOCKED: STATE_DOCKED,
- STATE_ERROR: STATE_ERROR,
- STATE_PAUSED: STATE_PAUSED,
- STATE_RETURNING: STATE_RETURNING,
- STATE_CLEANING: STATE_CLEANING,
+STATE_IDLE = "idle"
+STATE_DOCKED = "docked"
+STATE_ERROR = "error"
+STATE_PAUSED = "paused"
+STATE_RETURNING = "returning"
+STATE_CLEANING = "cleaning"
+
+POSSIBLE_STATES: dict[str, VacuumActivity] = {
+ STATE_IDLE: VacuumActivity.IDLE,
+ STATE_DOCKED: VacuumActivity.DOCKED,
+ STATE_ERROR: VacuumActivity.ERROR,
+ STATE_PAUSED: VacuumActivity.PAUSED,
+ STATE_RETURNING: VacuumActivity.RETURNING,
+ STATE_CLEANING: VacuumActivity.CLEANING,
}
CONF_SUPPORTED_FEATURES = ATTR_SUPPORTED_FEATURES
@@ -263,7 +264,7 @@ class MqttStateVacuum(MqttEntity, StateVacuumEntity):
if STATE in payload and (
(state := payload[STATE]) in POSSIBLE_STATES or state is None
):
- self._attr_state = (
+ self._attr_activity = (
POSSIBLE_STATES[cast(str, state)] if payload[STATE] else None
)
del payload[STATE]
@@ -275,7 +276,7 @@ class MqttStateVacuum(MqttEntity, StateVacuumEntity):
self.add_subscription(
CONF_STATE_TOPIC,
self._state_message_received,
- {"_attr_battery_level", "_attr_fan_speed", "_attr_state"},
+ {"_attr_battery_level", "_attr_fan_speed", "_attr_activity"},
)
async def _subscribe_topics(self) -> None:
diff --git a/homeassistant/components/mqtt/valve.py b/homeassistant/components/mqtt/valve.py
index 00d3d7d79bd..50c5960f801 100644
--- a/homeassistant/components/mqtt/valve.py
+++ b/homeassistant/components/mqtt/valve.py
@@ -63,6 +63,8 @@ from .util import valid_publish_topic, valid_subscribe_topic
_LOGGER = logging.getLogger(__name__)
+PARALLEL_UPDATES = 0
+
CONF_REPORTS_POSITION = "reports_position"
DEFAULT_NAME = "MQTT Valve"
diff --git a/homeassistant/components/mqtt/water_heater.py b/homeassistant/components/mqtt/water_heater.py
index b98d73e0bfe..4c1d3fa8a53 100644
--- a/homeassistant/components/mqtt/water_heater.py
+++ b/homeassistant/components/mqtt/water_heater.py
@@ -72,6 +72,8 @@ from .util import valid_publish_topic, valid_subscribe_topic
_LOGGER = logging.getLogger(__name__)
+PARALLEL_UPDATES = 0
+
DEFAULT_NAME = "MQTT Water Heater"
MQTT_WATER_HEATER_ATTRIBUTES_BLOCKED = frozenset(
diff --git a/homeassistant/components/mqtt_eventstream/manifest.json b/homeassistant/components/mqtt_eventstream/manifest.json
index 978b11de994..95e97ebb5fa 100644
--- a/homeassistant/components/mqtt_eventstream/manifest.json
+++ b/homeassistant/components/mqtt_eventstream/manifest.json
@@ -4,5 +4,6 @@
"codeowners": [],
"dependencies": ["mqtt"],
"documentation": "https://www.home-assistant.io/integrations/mqtt_eventstream",
- "iot_class": "local_polling"
+ "iot_class": "local_polling",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/mqtt_json/manifest.json b/homeassistant/components/mqtt_json/manifest.json
index 24ed99979cc..ccaa4996fea 100644
--- a/homeassistant/components/mqtt_json/manifest.json
+++ b/homeassistant/components/mqtt_json/manifest.json
@@ -4,5 +4,6 @@
"codeowners": [],
"dependencies": ["mqtt"],
"documentation": "https://www.home-assistant.io/integrations/mqtt_json",
- "iot_class": "local_push"
+ "iot_class": "local_push",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/mqtt_room/manifest.json b/homeassistant/components/mqtt_room/manifest.json
index efc5e375cfd..858a1cbb98c 100644
--- a/homeassistant/components/mqtt_room/manifest.json
+++ b/homeassistant/components/mqtt_room/manifest.json
@@ -4,5 +4,6 @@
"codeowners": [],
"dependencies": ["mqtt"],
"documentation": "https://www.home-assistant.io/integrations/mqtt_room",
- "iot_class": "local_push"
+ "iot_class": "local_push",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/mqtt_statestream/manifest.json b/homeassistant/components/mqtt_statestream/manifest.json
index 134cd80d383..c3c278a08bb 100644
--- a/homeassistant/components/mqtt_statestream/manifest.json
+++ b/homeassistant/components/mqtt_statestream/manifest.json
@@ -4,5 +4,6 @@
"codeowners": [],
"dependencies": ["mqtt"],
"documentation": "https://www.home-assistant.io/integrations/mqtt_statestream",
- "iot_class": "local_push"
+ "iot_class": "local_push",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/msteams/manifest.json b/homeassistant/components/msteams/manifest.json
index e4b40140441..3ded77c2176 100644
--- a/homeassistant/components/msteams/manifest.json
+++ b/homeassistant/components/msteams/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/msteams",
"iot_class": "cloud_push",
"loggers": ["pymsteams"],
+ "quality_scale": "legacy",
"requirements": ["pymsteams==0.1.12"]
}
diff --git a/homeassistant/components/music_assistant/__init__.py b/homeassistant/components/music_assistant/__init__.py
index 9f0fc1aad27..052f4f556c1 100644
--- a/homeassistant/components/music_assistant/__init__.py
+++ b/homeassistant/components/music_assistant/__init__.py
@@ -17,24 +17,30 @@ from homeassistant.core import Event, HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.aiohttp_client import async_get_clientsession
+import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.issue_registry import (
IssueSeverity,
async_create_issue,
async_delete_issue,
)
+from .actions import register_actions
from .const import DOMAIN, LOGGER
if TYPE_CHECKING:
from music_assistant_models.event import MassEvent
-type MusicAssistantConfigEntry = ConfigEntry[MusicAssistantEntryData]
+ from homeassistant.helpers.typing import ConfigType
PLATFORMS = [Platform.MEDIA_PLAYER]
CONNECT_TIMEOUT = 10
LISTEN_READY_TIMEOUT = 30
+CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
+
+type MusicAssistantConfigEntry = ConfigEntry[MusicAssistantEntryData]
+
@dataclass
class MusicAssistantEntryData:
@@ -44,10 +50,16 @@ class MusicAssistantEntryData:
listen_task: asyncio.Task
+async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
+ """Set up the Music Assistant component."""
+ register_actions(hass)
+ return True
+
+
async def async_setup_entry(
hass: HomeAssistant, entry: MusicAssistantConfigEntry
) -> bool:
- """Set up from a config entry."""
+ """Set up Music Assistant from a config entry."""
http_session = async_get_clientsession(hass, verify_ssl=False)
mass_url = entry.data[CONF_URL]
mass = MusicAssistantClient(mass_url, http_session)
@@ -97,6 +109,7 @@ async def async_setup_entry(
listen_task.cancel()
raise ConfigEntryNotReady("Music Assistant client not ready") from err
+ # store the listen task and mass client in the entry data
entry.runtime_data = MusicAssistantEntryData(mass, listen_task)
# If the listen task is already failed, we need to raise ConfigEntryNotReady
diff --git a/homeassistant/components/music_assistant/actions.py b/homeassistant/components/music_assistant/actions.py
new file mode 100644
index 00000000000..f3297bf0a6f
--- /dev/null
+++ b/homeassistant/components/music_assistant/actions.py
@@ -0,0 +1,212 @@
+"""Custom actions (previously known as services) for the Music Assistant integration."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from music_assistant_models.enums import MediaType
+import voluptuous as vol
+
+from homeassistant.config_entries import ConfigEntryState
+from homeassistant.core import (
+ HomeAssistant,
+ ServiceCall,
+ ServiceResponse,
+ SupportsResponse,
+ callback,
+)
+from homeassistant.exceptions import ServiceValidationError
+import homeassistant.helpers.config_validation as cv
+
+from .const import (
+ ATTR_ALBUM_ARTISTS_ONLY,
+ ATTR_ALBUM_TYPE,
+ ATTR_ALBUMS,
+ ATTR_ARTISTS,
+ ATTR_CONFIG_ENTRY_ID,
+ ATTR_FAVORITE,
+ ATTR_ITEMS,
+ ATTR_LIBRARY_ONLY,
+ ATTR_LIMIT,
+ ATTR_MEDIA_TYPE,
+ ATTR_OFFSET,
+ ATTR_ORDER_BY,
+ ATTR_PLAYLISTS,
+ ATTR_RADIO,
+ ATTR_SEARCH,
+ ATTR_SEARCH_ALBUM,
+ ATTR_SEARCH_ARTIST,
+ ATTR_SEARCH_NAME,
+ ATTR_TRACKS,
+ DOMAIN,
+)
+from .schemas import (
+ LIBRARY_RESULTS_SCHEMA,
+ SEARCH_RESULT_SCHEMA,
+ media_item_dict_from_mass_item,
+)
+
+if TYPE_CHECKING:
+ from music_assistant_client import MusicAssistantClient
+
+ from . import MusicAssistantConfigEntry
+
+SERVICE_SEARCH = "search"
+SERVICE_GET_LIBRARY = "get_library"
+DEFAULT_OFFSET = 0
+DEFAULT_LIMIT = 25
+DEFAULT_SORT_ORDER = "name"
+
+
+@callback
+def get_music_assistant_client(
+ hass: HomeAssistant, config_entry_id: str
+) -> MusicAssistantClient:
+ """Get the Music Assistant client for the given config entry."""
+ entry: MusicAssistantConfigEntry | None
+ if not (entry := hass.config_entries.async_get_entry(config_entry_id)):
+ raise ServiceValidationError("Entry not found")
+ if entry.state is not ConfigEntryState.LOADED:
+ raise ServiceValidationError("Entry not loaded")
+ return entry.runtime_data.mass
+
+
+@callback
+def register_actions(hass: HomeAssistant) -> None:
+ """Register custom actions."""
+ hass.services.async_register(
+ DOMAIN,
+ SERVICE_SEARCH,
+ handle_search,
+ schema=vol.Schema(
+ {
+ vol.Required(ATTR_CONFIG_ENTRY_ID): str,
+ vol.Required(ATTR_SEARCH_NAME): cv.string,
+ vol.Optional(ATTR_MEDIA_TYPE): vol.All(
+ cv.ensure_list, [vol.Coerce(MediaType)]
+ ),
+ vol.Optional(ATTR_SEARCH_ARTIST): cv.string,
+ vol.Optional(ATTR_SEARCH_ALBUM): cv.string,
+ vol.Optional(ATTR_LIMIT, default=5): vol.Coerce(int),
+ vol.Optional(ATTR_LIBRARY_ONLY, default=False): cv.boolean,
+ }
+ ),
+ supports_response=SupportsResponse.ONLY,
+ )
+ hass.services.async_register(
+ DOMAIN,
+ SERVICE_GET_LIBRARY,
+ handle_get_library,
+ schema=vol.Schema(
+ {
+ vol.Required(ATTR_CONFIG_ENTRY_ID): str,
+ vol.Required(ATTR_MEDIA_TYPE): vol.Coerce(MediaType),
+ vol.Optional(ATTR_FAVORITE): cv.boolean,
+ vol.Optional(ATTR_SEARCH): cv.string,
+ vol.Optional(ATTR_LIMIT): cv.positive_int,
+ vol.Optional(ATTR_OFFSET): int,
+ vol.Optional(ATTR_ORDER_BY): cv.string,
+ vol.Optional(ATTR_ALBUM_TYPE): list[MediaType],
+ vol.Optional(ATTR_ALBUM_ARTISTS_ONLY): cv.boolean,
+ }
+ ),
+ supports_response=SupportsResponse.ONLY,
+ )
+
+
+async def handle_search(call: ServiceCall) -> ServiceResponse:
+ """Handle queue_command action."""
+ mass = get_music_assistant_client(call.hass, call.data[ATTR_CONFIG_ENTRY_ID])
+ search_name = call.data[ATTR_SEARCH_NAME]
+ search_artist = call.data.get(ATTR_SEARCH_ARTIST)
+ search_album = call.data.get(ATTR_SEARCH_ALBUM)
+ if search_album and search_artist:
+ search_name = f"{search_artist} - {search_album} - {search_name}"
+ elif search_album:
+ search_name = f"{search_album} - {search_name}"
+ elif search_artist:
+ search_name = f"{search_artist} - {search_name}"
+ search_results = await mass.music.search(
+ search_query=search_name,
+ media_types=call.data.get(ATTR_MEDIA_TYPE, MediaType.ALL),
+ limit=call.data[ATTR_LIMIT],
+ library_only=call.data[ATTR_LIBRARY_ONLY],
+ )
+ response: ServiceResponse = SEARCH_RESULT_SCHEMA(
+ {
+ ATTR_ARTISTS: [
+ media_item_dict_from_mass_item(mass, item)
+ for item in search_results.artists
+ ],
+ ATTR_ALBUMS: [
+ media_item_dict_from_mass_item(mass, item)
+ for item in search_results.albums
+ ],
+ ATTR_TRACKS: [
+ media_item_dict_from_mass_item(mass, item)
+ for item in search_results.tracks
+ ],
+ ATTR_PLAYLISTS: [
+ media_item_dict_from_mass_item(mass, item)
+ for item in search_results.playlists
+ ],
+ ATTR_RADIO: [
+ media_item_dict_from_mass_item(mass, item)
+ for item in search_results.radio
+ ],
+ }
+ )
+ return response
+
+
+async def handle_get_library(call: ServiceCall) -> ServiceResponse:
+ """Handle get_library action."""
+ mass = get_music_assistant_client(call.hass, call.data[ATTR_CONFIG_ENTRY_ID])
+ media_type = call.data[ATTR_MEDIA_TYPE]
+ limit = call.data.get(ATTR_LIMIT, DEFAULT_LIMIT)
+ offset = call.data.get(ATTR_OFFSET, DEFAULT_OFFSET)
+ order_by = call.data.get(ATTR_ORDER_BY, DEFAULT_SORT_ORDER)
+ base_params = {
+ "favorite": call.data.get(ATTR_FAVORITE),
+ "search": call.data.get(ATTR_SEARCH),
+ "limit": limit,
+ "offset": offset,
+ "order_by": order_by,
+ }
+ if media_type == MediaType.ALBUM:
+ library_result = await mass.music.get_library_albums(
+ **base_params,
+ album_types=call.data.get(ATTR_ALBUM_TYPE),
+ )
+ elif media_type == MediaType.ARTIST:
+ library_result = await mass.music.get_library_artists(
+ **base_params,
+ album_artists_only=call.data.get(ATTR_ALBUM_ARTISTS_ONLY),
+ )
+ elif media_type == MediaType.TRACK:
+ library_result = await mass.music.get_library_tracks(
+ **base_params,
+ )
+ elif media_type == MediaType.RADIO:
+ library_result = await mass.music.get_library_radios(
+ **base_params,
+ )
+ elif media_type == MediaType.PLAYLIST:
+ library_result = await mass.music.get_library_playlists(
+ **base_params,
+ )
+ else:
+ raise ServiceValidationError(f"Unsupported media type {media_type}")
+
+ response: ServiceResponse = LIBRARY_RESULTS_SCHEMA(
+ {
+ ATTR_ITEMS: [
+ media_item_dict_from_mass_item(mass, item) for item in library_result
+ ],
+ ATTR_LIMIT: limit,
+ ATTR_OFFSET: offset,
+ ATTR_ORDER_BY: order_by,
+ ATTR_MEDIA_TYPE: media_type,
+ }
+ )
+ return response
diff --git a/homeassistant/components/music_assistant/const.py b/homeassistant/components/music_assistant/const.py
index 6512f58b96c..1980c495278 100644
--- a/homeassistant/components/music_assistant/const.py
+++ b/homeassistant/components/music_assistant/const.py
@@ -14,5 +14,55 @@ ATTR_GROUP_PARENTS = "group_parents"
ATTR_MASS_PLAYER_TYPE = "mass_player_type"
ATTR_ACTIVE_QUEUE = "active_queue"
ATTR_STREAM_TITLE = "stream_title"
+ATTR_MEDIA_TYPE = "media_type"
+ATTR_SEARCH_NAME = "name"
+ATTR_SEARCH_ARTIST = "artist"
+ATTR_SEARCH_ALBUM = "album"
+ATTR_LIMIT = "limit"
+ATTR_LIBRARY_ONLY = "library_only"
+ATTR_FAVORITE = "favorite"
+ATTR_SEARCH = "search"
+ATTR_OFFSET = "offset"
+ATTR_ORDER_BY = "order_by"
+ATTR_ALBUM_TYPE = "album_type"
+ATTR_ALBUM_ARTISTS_ONLY = "album_artists_only"
+ATTR_CONFIG_ENTRY_ID = "config_entry_id"
+ATTR_URI = "uri"
+ATTR_IMAGE = "image"
+ATTR_VERSION = "version"
+ATTR_ARTISTS = "artists"
+ATTR_ALBUMS = "albums"
+ATTR_TRACKS = "tracks"
+ATTR_PLAYLISTS = "playlists"
+ATTR_RADIO = "radio"
+ATTR_ITEMS = "items"
+ATTR_RADIO_MODE = "radio_mode"
+ATTR_MEDIA_ID = "media_id"
+ATTR_ARTIST = "artist"
+ATTR_ALBUM = "album"
+ATTR_URL = "url"
+ATTR_USE_PRE_ANNOUNCE = "use_pre_announce"
+ATTR_ANNOUNCE_VOLUME = "announce_volume"
+ATTR_SOURCE_PLAYER = "source_player"
+ATTR_AUTO_PLAY = "auto_play"
+ATTR_QUEUE_ID = "queue_id"
+ATTR_ACTIVE = "active"
+ATTR_SHUFFLE_ENABLED = "shuffle_enabled"
+ATTR_REPEAT_MODE = "repeat_mode"
+ATTR_CURRENT_INDEX = "current_index"
+ATTR_ELAPSED_TIME = "elapsed_time"
+ATTR_CURRENT_ITEM = "current_item"
+ATTR_NEXT_ITEM = "next_item"
+ATTR_QUEUE_ITEM_ID = "queue_item_id"
+ATTR_DURATION = "duration"
+ATTR_MEDIA_ITEM = "media_item"
+ATTR_STREAM_DETAILS = "stream_details"
+ATTR_CONTENT_TYPE = "content_type"
+ATTR_SAMPLE_RATE = "sample_rate"
+ATTR_BIT_DEPTH = "bit_depth"
+ATTR_STREAM_TITLE = "stream_title"
+ATTR_PROVIDER = "provider"
+ATTR_ITEM_ID = "item_id"
+
LOGGER = logging.getLogger(__package__)
diff --git a/homeassistant/components/music_assistant/icons.json b/homeassistant/components/music_assistant/icons.json
new file mode 100644
index 00000000000..0fa64b8d273
--- /dev/null
+++ b/homeassistant/components/music_assistant/icons.json
@@ -0,0 +1,10 @@
+{
+ "services": {
+ "play_media": { "service": "mdi:play" },
+ "play_announcement": { "service": "mdi:bullhorn" },
+ "transfer_queue": { "service": "mdi:transfer" },
+ "search": { "service": "mdi:magnify" },
+ "get_queue": { "service": "mdi:playlist-music" },
+ "get_library": { "service": "mdi:music-box-multiple" }
+ }
+}
diff --git a/homeassistant/components/music_assistant/manifest.json b/homeassistant/components/music_assistant/manifest.json
index 23401f30abc..f5cdcf50673 100644
--- a/homeassistant/components/music_assistant/manifest.json
+++ b/homeassistant/components/music_assistant/manifest.json
@@ -4,10 +4,9 @@
"after_dependencies": ["media_source", "media_player"],
"codeowners": ["@music-assistant"],
"config_flow": true,
- "documentation": "https://music-assistant.io",
+ "documentation": "https://www.home-assistant.io/integrations/music_assistant",
"iot_class": "local_push",
- "issue_tracker": "https://github.com/music-assistant/hass-music-assistant/issues",
"loggers": ["music_assistant"],
- "requirements": ["music-assistant-client==1.0.5"],
+ "requirements": ["music-assistant-client==1.0.8"],
"zeroconf": ["_mass._tcp.local."]
}
diff --git a/homeassistant/components/music_assistant/media_browser.py b/homeassistant/components/music_assistant/media_browser.py
new file mode 100644
index 00000000000..e65d6d4a975
--- /dev/null
+++ b/homeassistant/components/music_assistant/media_browser.py
@@ -0,0 +1,351 @@
+"""Media Source Implementation."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Any
+
+from music_assistant_models.media_items import MediaItemType
+
+from homeassistant.components import media_source
+from homeassistant.components.media_player import (
+ BrowseError,
+ BrowseMedia,
+ MediaClass,
+ MediaType,
+)
+from homeassistant.core import HomeAssistant
+
+from .const import DEFAULT_NAME, DOMAIN
+
+if TYPE_CHECKING:
+ from music_assistant_client import MusicAssistantClient
+
+MEDIA_TYPE_RADIO = "radio"
+
+PLAYABLE_MEDIA_TYPES = [
+ MediaType.PLAYLIST,
+ MediaType.ALBUM,
+ MediaType.ARTIST,
+ MEDIA_TYPE_RADIO,
+ MediaType.TRACK,
+]
+
+LIBRARY_ARTISTS = "artists"
+LIBRARY_ALBUMS = "albums"
+LIBRARY_TRACKS = "tracks"
+LIBRARY_PLAYLISTS = "playlists"
+LIBRARY_RADIO = "radio"
+
+
+LIBRARY_TITLE_MAP = {
+ LIBRARY_ARTISTS: "Artists",
+ LIBRARY_ALBUMS: "Albums",
+ LIBRARY_TRACKS: "Tracks",
+ LIBRARY_PLAYLISTS: "Playlists",
+ LIBRARY_RADIO: "Radio stations",
+}
+
+LIBRARY_MEDIA_CLASS_MAP = {
+ LIBRARY_ARTISTS: MediaClass.ARTIST,
+ LIBRARY_ALBUMS: MediaClass.ALBUM,
+ LIBRARY_TRACKS: MediaClass.TRACK,
+ LIBRARY_PLAYLISTS: MediaClass.PLAYLIST,
+ LIBRARY_RADIO: MediaClass.MUSIC, # radio is not accepted by HA
+}
+
+MEDIA_CONTENT_TYPE_FLAC = "audio/flac"
+THUMB_SIZE = 200
+
+
+def media_source_filter(item: BrowseMedia) -> bool:
+ """Filter media sources."""
+ return item.media_content_type.startswith("audio/")
+
+
+async def async_browse_media(
+ hass: HomeAssistant,
+ mass: MusicAssistantClient,
+ media_content_id: str | None,
+ media_content_type: str | None,
+) -> BrowseMedia:
+ """Browse media."""
+ if media_content_id is None:
+ return await build_main_listing(hass)
+
+ assert media_content_type is not None
+
+ if media_source.is_media_source_id(media_content_id):
+ return await media_source.async_browse_media(
+ hass, media_content_id, content_filter=media_source_filter
+ )
+
+ if media_content_id == LIBRARY_ARTISTS:
+ return await build_artists_listing(mass)
+ if media_content_id == LIBRARY_ALBUMS:
+ return await build_albums_listing(mass)
+ if media_content_id == LIBRARY_TRACKS:
+ return await build_tracks_listing(mass)
+ if media_content_id == LIBRARY_PLAYLISTS:
+ return await build_playlists_listing(mass)
+ if media_content_id == LIBRARY_RADIO:
+ return await build_radio_listing(mass)
+ if "artist" in media_content_id:
+ return await build_artist_items_listing(mass, media_content_id)
+ if "album" in media_content_id:
+ return await build_album_items_listing(mass, media_content_id)
+ if "playlist" in media_content_id:
+ return await build_playlist_items_listing(mass, media_content_id)
+
+ raise BrowseError(f"Media not found: {media_content_type} / {media_content_id}")
+
+
+async def build_main_listing(hass: HomeAssistant) -> BrowseMedia:
+ """Build main browse listing."""
+ children: list[BrowseMedia] = []
+ for library, media_class in LIBRARY_MEDIA_CLASS_MAP.items():
+ child_source = BrowseMedia(
+ media_class=MediaClass.DIRECTORY,
+ media_content_id=library,
+ media_content_type=DOMAIN,
+ title=LIBRARY_TITLE_MAP[library],
+ children_media_class=media_class,
+ can_play=False,
+ can_expand=True,
+ )
+ children.append(child_source)
+
+ try:
+ item = await media_source.async_browse_media(
+ hass, None, content_filter=media_source_filter
+ )
+ # If domain is None, it's overview of available sources
+ if item.domain is None and item.children is not None:
+ children.extend(item.children)
+ else:
+ children.append(item)
+ except media_source.BrowseError:
+ pass
+
+ return BrowseMedia(
+ media_class=MediaClass.DIRECTORY,
+ media_content_id="",
+ media_content_type=DOMAIN,
+ title=DEFAULT_NAME,
+ can_play=False,
+ can_expand=True,
+ children=children,
+ )
+
+
+async def build_playlists_listing(mass: MusicAssistantClient) -> BrowseMedia:
+ """Build Playlists browse listing."""
+ media_class = LIBRARY_MEDIA_CLASS_MAP[LIBRARY_PLAYLISTS]
+ return BrowseMedia(
+ media_class=MediaClass.DIRECTORY,
+ media_content_id=LIBRARY_PLAYLISTS,
+ media_content_type=MediaType.PLAYLIST,
+ title=LIBRARY_TITLE_MAP[LIBRARY_PLAYLISTS],
+ can_play=False,
+ can_expand=True,
+ children_media_class=media_class,
+ children=sorted(
+ [
+ build_item(mass, item, can_expand=True)
+ # we only grab the first page here because the
+ # HA media browser does not support paging
+ for item in await mass.music.get_library_playlists(limit=500)
+ if item.available
+ ],
+ key=lambda x: x.title,
+ ),
+ )
+
+
+async def build_playlist_items_listing(
+ mass: MusicAssistantClient, identifier: str
+) -> BrowseMedia:
+ """Build Playlist items browse listing."""
+ playlist = await mass.music.get_item_by_uri(identifier)
+
+ return BrowseMedia(
+ media_class=MediaClass.PLAYLIST,
+ media_content_id=playlist.uri,
+ media_content_type=MediaType.PLAYLIST,
+ title=playlist.name,
+ can_play=True,
+ can_expand=True,
+ children_media_class=MediaClass.TRACK,
+ children=[
+ build_item(mass, item, can_expand=False)
+ # we only grab the first page here because the
+ # HA media browser does not support paging
+ for item in await mass.music.get_playlist_tracks(
+ playlist.item_id, playlist.provider
+ )
+ if item.available
+ ],
+ )
+
+
+async def build_artists_listing(mass: MusicAssistantClient) -> BrowseMedia:
+ """Build Albums browse listing."""
+ media_class = LIBRARY_MEDIA_CLASS_MAP[LIBRARY_ARTISTS]
+
+ return BrowseMedia(
+ media_class=MediaClass.DIRECTORY,
+ media_content_id=LIBRARY_ARTISTS,
+ media_content_type=MediaType.ARTIST,
+ title=LIBRARY_TITLE_MAP[LIBRARY_ARTISTS],
+ can_play=False,
+ can_expand=True,
+ children_media_class=media_class,
+ children=sorted(
+ [
+ build_item(mass, artist, can_expand=True)
+ # we only grab the first page here because the
+ # HA media browser does not support paging
+ for artist in await mass.music.get_library_artists(limit=500)
+ if artist.available
+ ],
+ key=lambda x: x.title,
+ ),
+ )
+
+
+async def build_artist_items_listing(
+ mass: MusicAssistantClient, identifier: str
+) -> BrowseMedia:
+ """Build Artist items browse listing."""
+ artist = await mass.music.get_item_by_uri(identifier)
+ albums = await mass.music.get_artist_albums(artist.item_id, artist.provider)
+
+ return BrowseMedia(
+ media_class=MediaType.ARTIST,
+ media_content_id=artist.uri,
+ media_content_type=MediaType.ARTIST,
+ title=artist.name,
+ can_play=True,
+ can_expand=True,
+ children_media_class=MediaClass.ALBUM,
+ children=[
+ build_item(mass, album, can_expand=True)
+ for album in albums
+ if album.available
+ ],
+ )
+
+
+async def build_albums_listing(mass: MusicAssistantClient) -> BrowseMedia:
+ """Build Albums browse listing."""
+ media_class = LIBRARY_MEDIA_CLASS_MAP[LIBRARY_ALBUMS]
+
+ return BrowseMedia(
+ media_class=MediaClass.DIRECTORY,
+ media_content_id=LIBRARY_ALBUMS,
+ media_content_type=MediaType.ALBUM,
+ title=LIBRARY_TITLE_MAP[LIBRARY_ALBUMS],
+ can_play=False,
+ can_expand=True,
+ children_media_class=media_class,
+ children=sorted(
+ [
+ build_item(mass, album, can_expand=True)
+ # we only grab the first page here because the
+ # HA media browser does not support paging
+ for album in await mass.music.get_library_albums(limit=500)
+ if album.available
+ ],
+ key=lambda x: x.title,
+ ),
+ )
+
+
+async def build_album_items_listing(
+ mass: MusicAssistantClient, identifier: str
+) -> BrowseMedia:
+ """Build Album items browse listing."""
+ album = await mass.music.get_item_by_uri(identifier)
+ tracks = await mass.music.get_album_tracks(album.item_id, album.provider)
+
+ return BrowseMedia(
+ media_class=MediaType.ALBUM,
+ media_content_id=album.uri,
+ media_content_type=MediaType.ALBUM,
+ title=album.name,
+ can_play=True,
+ can_expand=True,
+ children_media_class=MediaClass.TRACK,
+ children=[
+ build_item(mass, track, False) for track in tracks if track.available
+ ],
+ )
+
+
+async def build_tracks_listing(mass: MusicAssistantClient) -> BrowseMedia:
+ """Build Tracks browse listing."""
+ media_class = LIBRARY_MEDIA_CLASS_MAP[LIBRARY_TRACKS]
+
+ return BrowseMedia(
+ media_class=MediaClass.DIRECTORY,
+ media_content_id=LIBRARY_TRACKS,
+ media_content_type=MediaType.TRACK,
+ title=LIBRARY_TITLE_MAP[LIBRARY_TRACKS],
+ can_play=False,
+ can_expand=True,
+ children_media_class=media_class,
+ children=sorted(
+ [
+ build_item(mass, track, can_expand=False)
+ # we only grab the first page here because the
+ # HA media browser does not support paging
+ for track in await mass.music.get_library_tracks(limit=500)
+ if track.available
+ ],
+ key=lambda x: x.title,
+ ),
+ )
+
+
+async def build_radio_listing(mass: MusicAssistantClient) -> BrowseMedia:
+ """Build Radio browse listing."""
+ media_class = LIBRARY_MEDIA_CLASS_MAP[LIBRARY_RADIO]
+ return BrowseMedia(
+ media_class=MediaClass.DIRECTORY,
+ media_content_id=LIBRARY_RADIO,
+ media_content_type=DOMAIN,
+ title=LIBRARY_TITLE_MAP[LIBRARY_RADIO],
+ can_play=False,
+ can_expand=True,
+ children_media_class=media_class,
+ children=[
+ build_item(mass, track, can_expand=False, media_class=media_class)
+ # we only grab the first page here because the
+ # HA media browser does not support paging
+ for track in await mass.music.get_library_radios(limit=500)
+ if track.available
+ ],
+ )
+
+
+def build_item(
+ mass: MusicAssistantClient,
+ item: MediaItemType,
+ can_expand: bool = True,
+ media_class: Any = None,
+) -> BrowseMedia:
+ """Return BrowseMedia for MediaItem."""
+ if artists := getattr(item, "artists", None):
+ title = f"{artists[0].name} - {item.name}"
+ else:
+ title = item.name
+ img_url = mass.get_media_item_image_url(item)
+
+ return BrowseMedia(
+ media_class=media_class or item.media_type.value,
+ media_content_id=item.uri,
+ media_content_type=MediaType.MUSIC,
+ title=title,
+ can_play=True,
+ can_expand=can_expand,
+ thumbnail=img_url,
+ )
diff --git a/homeassistant/components/music_assistant/media_player.py b/homeassistant/components/music_assistant/media_player.py
index f0f3675ee32..9aa7498a2ee 100644
--- a/homeassistant/components/music_assistant/media_player.py
+++ b/homeassistant/components/music_assistant/media_player.py
@@ -3,25 +3,28 @@
from __future__ import annotations
import asyncio
-from collections.abc import Awaitable, Callable, Coroutine, Mapping
+from collections.abc import Callable, Coroutine, Mapping
from contextlib import suppress
import functools
import os
-from typing import TYPE_CHECKING, Any
+from typing import TYPE_CHECKING, Any, Concatenate
from music_assistant_models.enums import (
EventType,
MediaType,
PlayerFeature,
+ PlayerState as MassPlayerState,
QueueOption,
RepeatMode as MassRepeatMode,
)
from music_assistant_models.errors import MediaNotFoundError, MusicAssistantError
from music_assistant_models.event import MassEvent
from music_assistant_models.media_items import ItemMapping, MediaItemType, Track
+import voluptuous as vol
from homeassistant.components import media_source
from homeassistant.components.media_player import (
+ ATTR_MEDIA_ENQUEUE,
ATTR_MEDIA_EXTRA,
BrowseMedia,
MediaPlayerDeviceClass,
@@ -33,16 +36,45 @@ from homeassistant.components.media_player import (
RepeatMode,
async_process_play_media_url,
)
-from homeassistant.const import STATE_OFF
-from homeassistant.core import HomeAssistant
+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
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity_platform import (
+ AddEntitiesCallback,
+ async_get_current_platform,
+)
from homeassistant.util.dt import utc_from_timestamp
from . import MusicAssistantConfigEntry
-from .const import ATTR_ACTIVE_QUEUE, ATTR_MASS_PLAYER_TYPE, DOMAIN
+from .const import (
+ ATTR_ACTIVE,
+ ATTR_ACTIVE_QUEUE,
+ ATTR_ALBUM,
+ ATTR_ANNOUNCE_VOLUME,
+ ATTR_ARTIST,
+ ATTR_AUTO_PLAY,
+ ATTR_CURRENT_INDEX,
+ ATTR_CURRENT_ITEM,
+ ATTR_ELAPSED_TIME,
+ ATTR_ITEMS,
+ ATTR_MASS_PLAYER_TYPE,
+ ATTR_MEDIA_ID,
+ ATTR_MEDIA_TYPE,
+ ATTR_NEXT_ITEM,
+ ATTR_QUEUE_ID,
+ ATTR_RADIO_MODE,
+ ATTR_REPEAT_MODE,
+ ATTR_SHUFFLE_ENABLED,
+ ATTR_SOURCE_PLAYER,
+ ATTR_URL,
+ ATTR_USE_PRE_ANNOUNCE,
+ DOMAIN,
+)
from .entity import MusicAssistantEntity
+from .media_browser import async_browse_media
+from .schemas import QUEUE_DETAILS_SCHEMA, queue_item_dict_from_mass_item
if TYPE_CHECKING:
from music_assistant_client import MusicAssistantClient
@@ -78,27 +110,21 @@ QUEUE_OPTION_MAP = {
MediaPlayerEnqueue.REPLACE: QueueOption.REPLACE,
}
-ATTR_RADIO_MODE = "radio_mode"
-ATTR_MEDIA_ID = "media_id"
-ATTR_MEDIA_TYPE = "media_type"
-ATTR_ARTIST = "artist"
-ATTR_ALBUM = "album"
-ATTR_URL = "url"
-ATTR_USE_PRE_ANNOUNCE = "use_pre_announce"
-ATTR_ANNOUNCE_VOLUME = "announce_volume"
-ATTR_SOURCE_PLAYER = "source_player"
-ATTR_AUTO_PLAY = "auto_play"
+SERVICE_PLAY_MEDIA_ADVANCED = "play_media"
+SERVICE_PLAY_ANNOUNCEMENT = "play_announcement"
+SERVICE_TRANSFER_QUEUE = "transfer_queue"
+SERVICE_GET_QUEUE = "get_queue"
def catch_musicassistant_error[_R, **P](
- func: Callable[..., Awaitable[_R]],
-) -> Callable[..., Coroutine[Any, Any, _R | None]]:
+ func: Callable[Concatenate[MusicAssistantPlayer, P], Coroutine[Any, Any, _R]],
+) -> Callable[Concatenate[MusicAssistantPlayer, P], Coroutine[Any, Any, _R]]:
"""Check and log commands to players."""
@functools.wraps(func)
async def wrapper(
self: MusicAssistantPlayer, *args: P.args, **kwargs: P.kwargs
- ) -> _R | None:
+ ) -> _R:
"""Catch Music Assistant errors and convert to Home Assistant error."""
try:
return await func(self, *args, **kwargs)
@@ -137,6 +163,44 @@ async def async_setup_entry(
async_add_entities(mass_players)
+ # add platform service for play_media with advanced options
+ platform = async_get_current_platform()
+ platform.async_register_entity_service(
+ SERVICE_PLAY_MEDIA_ADVANCED,
+ {
+ vol.Required(ATTR_MEDIA_ID): vol.All(cv.ensure_list, [cv.string]),
+ vol.Optional(ATTR_MEDIA_TYPE): vol.Coerce(MediaType),
+ vol.Optional(ATTR_MEDIA_ENQUEUE): vol.Coerce(QueueOption),
+ vol.Optional(ATTR_ARTIST): cv.string,
+ vol.Optional(ATTR_ALBUM): cv.string,
+ vol.Optional(ATTR_RADIO_MODE): vol.Coerce(bool),
+ },
+ "_async_handle_play_media",
+ )
+ platform.async_register_entity_service(
+ SERVICE_PLAY_ANNOUNCEMENT,
+ {
+ vol.Required(ATTR_URL): cv.string,
+ vol.Optional(ATTR_USE_PRE_ANNOUNCE): vol.Coerce(bool),
+ vol.Optional(ATTR_ANNOUNCE_VOLUME): vol.Coerce(int),
+ },
+ "_async_handle_play_announcement",
+ )
+ platform.async_register_entity_service(
+ SERVICE_TRANSFER_QUEUE,
+ {
+ vol.Optional(ATTR_SOURCE_PLAYER): cv.entity_id,
+ vol.Optional(ATTR_AUTO_PLAY): vol.Coerce(bool),
+ },
+ "_async_handle_transfer_queue",
+ )
+ platform.async_register_entity_service(
+ SERVICE_GET_QUEUE,
+ schema=None,
+ func="_async_handle_get_queue",
+ supports_response=SupportsResponse.ONLY,
+ )
+
class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity):
"""Representation of MediaPlayerEntity from Music Assistant Player."""
@@ -150,8 +214,10 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity):
super().__init__(mass, player_id)
self._attr_icon = self.player.icon.replace("mdi-", "mdi:")
self._attr_supported_features = SUPPORTED_FEATURES
- if PlayerFeature.SYNC in self.player.supported_features:
+ if PlayerFeature.SET_MEMBERS in self.player.supported_features:
self._attr_supported_features |= MediaPlayerEntityFeature.GROUPING
+ if PlayerFeature.VOLUME_MUTE in self.player.supported_features:
+ self._attr_supported_features |= MediaPlayerEntityFeature.VOLUME_MUTE
self._attr_device_class = MediaPlayerDeviceClass.SPEAKER
self._prev_time: float = 0
@@ -219,7 +285,9 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity):
)
)
]
- self._attr_group_members = group_members_entity_ids
+ # NOTE: we sort the group_members for now,
+ # until the MA API returns them sorted (group_childs is now a set)
+ self._attr_group_members = sorted(group_members_entity_ids)
self._attr_volume_level = (
player.volume_level / 100 if player.volume_level is not None else None
)
@@ -353,24 +421,26 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity):
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[str] = []
+ entity_registry = er.async_get(self.hass)
for child_entity_id in group_members:
# resolve HA entity_id to MA player_id
- if (hass_state := self.hass.states.get(child_entity_id)) is None:
- continue
- if (mass_player_id := hass_state.attributes.get("mass_player_id")) is None:
- continue
- player_ids.append(mass_player_id)
- await self.mass.players.player_command_sync_many(self.player_id, player_ids)
+ if not (entity_reg_entry := entity_registry.async_get(child_entity_id)):
+ raise HomeAssistantError(f"Entity {child_entity_id} not found")
+ # unique id is the MA player_id
+ player_ids.append(entity_reg_entry.unique_id)
+ await self.mass.players.player_command_group_many(self.player_id, player_ids)
@catch_musicassistant_error
async def async_unjoin_player(self) -> None:
"""Remove this player from any group."""
- await self.mass.players.player_command_unsync(self.player_id)
+ await self.mass.players.player_command_ungroup(self.player_id)
@catch_musicassistant_error
async def _async_handle_play_media(
self,
media_id: list[str],
+ artist: str | None = None,
+ album: str | None = None,
enqueue: MediaPlayerEnqueue | QueueOption | None = None,
radio_mode: bool | None = None,
media_type: str | None = None,
@@ -397,6 +467,14 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity):
elif await asyncio.to_thread(os.path.isfile, media_id_str):
media_uris.append(media_id_str)
continue
+ # last resort: search for media item by name/search
+ if item := await self.mass.music.get_item_by_name(
+ name=media_id_str,
+ artist=artist,
+ album=album,
+ media_type=MediaType(media_type) if media_type else None,
+ ):
+ media_uris.append(item.uri)
if not media_uris:
raise HomeAssistantError(
@@ -430,16 +508,69 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity):
self.player_id, url, use_pre_announce, announce_volume
)
+ @catch_musicassistant_error
+ async def _async_handle_transfer_queue(
+ self, source_player: str | None = None, auto_play: bool | None = None
+ ) -> None:
+ """Transfer the current queue to another player."""
+ if not source_player:
+ # no source player given; try to find a playing player(queue)
+ for queue in self.mass.player_queues:
+ if queue.state == MassPlayerState.PLAYING:
+ source_queue_id = queue.queue_id
+ break
+ else:
+ raise HomeAssistantError(
+ "Source player not specified and no playing player found."
+ )
+ else:
+ # resolve HA entity_id to MA player_id
+ entity_registry = er.async_get(self.hass)
+ if (entity := entity_registry.async_get(source_player)) is None:
+ raise HomeAssistantError("Source player not available.")
+ source_queue_id = entity.unique_id # unique_id is the MA player_id
+ target_queue_id = self.player_id
+ await self.mass.player_queues.transfer_queue(
+ source_queue_id, target_queue_id, auto_play
+ )
+
+ @catch_musicassistant_error
+ async def _async_handle_get_queue(self) -> ServiceResponse:
+ """Handle get_queue action."""
+ if not self.active_queue:
+ raise HomeAssistantError("No active queue found")
+ active_queue = self.active_queue
+ response: ServiceResponse = QUEUE_DETAILS_SCHEMA(
+ {
+ ATTR_QUEUE_ID: active_queue.queue_id,
+ ATTR_ACTIVE: active_queue.active,
+ ATTR_NAME: active_queue.display_name,
+ ATTR_ITEMS: active_queue.items,
+ ATTR_SHUFFLE_ENABLED: active_queue.shuffle_enabled,
+ ATTR_REPEAT_MODE: active_queue.repeat_mode.value,
+ ATTR_CURRENT_INDEX: active_queue.current_index,
+ ATTR_ELAPSED_TIME: active_queue.corrected_elapsed_time,
+ ATTR_CURRENT_ITEM: queue_item_dict_from_mass_item(
+ self.mass, active_queue.current_item
+ ),
+ ATTR_NEXT_ITEM: queue_item_dict_from_mass_item(
+ self.mass, active_queue.next_item
+ ),
+ }
+ )
+ return response
+
async def async_browse_media(
self,
media_content_type: MediaType | str | None = None,
media_content_id: str | None = None,
) -> BrowseMedia:
"""Implement the websocket media browsing helper."""
- return await media_source.async_browse_media(
+ return await async_browse_media(
self.hass,
+ self.mass,
media_content_id,
- content_filter=lambda item: item.media_content_type.startswith("audio/"),
+ media_content_type,
)
def _update_media_image_url(
@@ -461,7 +592,6 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity):
self, player: Player, queue: PlayerQueue | None
) -> None:
"""Update media attributes for the active queue item."""
- # pylint: disable=too-many-statements
self._attr_media_artist = None
self._attr_media_album_artist = None
self._attr_media_album_name = None
@@ -482,17 +612,13 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity):
# shuffle and repeat are not (yet) supported for external sources
self._attr_shuffle = None
self._attr_repeat = None
- if TYPE_CHECKING:
- assert player.elapsed_time is not None
- self._attr_media_position = int(player.elapsed_time)
+ self._attr_media_position = int(player.elapsed_time or 0)
self._attr_media_position_updated_at = (
utc_from_timestamp(player.elapsed_time_last_updated)
if player.elapsed_time_last_updated
else None
)
- if TYPE_CHECKING:
- assert player.elapsed_time is not None
- self._prev_time = player.elapsed_time
+ self._prev_time = player.elapsed_time or 0
return
if queue is None:
diff --git a/homeassistant/components/music_assistant/schemas.py b/homeassistant/components/music_assistant/schemas.py
new file mode 100644
index 00000000000..9caae2ee0b4
--- /dev/null
+++ b/homeassistant/components/music_assistant/schemas.py
@@ -0,0 +1,182 @@
+"""Voluptuous schemas for Music Assistant integration service responses."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Any
+
+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 .const import (
+ ATTR_ACTIVE,
+ ATTR_ALBUM,
+ ATTR_ALBUMS,
+ ATTR_ARTISTS,
+ ATTR_BIT_DEPTH,
+ ATTR_CONTENT_TYPE,
+ ATTR_CURRENT_INDEX,
+ ATTR_CURRENT_ITEM,
+ ATTR_DURATION,
+ ATTR_ELAPSED_TIME,
+ ATTR_IMAGE,
+ ATTR_ITEM_ID,
+ ATTR_ITEMS,
+ ATTR_LIMIT,
+ ATTR_MEDIA_ITEM,
+ ATTR_MEDIA_TYPE,
+ ATTR_NEXT_ITEM,
+ ATTR_OFFSET,
+ ATTR_ORDER_BY,
+ ATTR_PLAYLISTS,
+ ATTR_PROVIDER,
+ ATTR_QUEUE_ID,
+ ATTR_QUEUE_ITEM_ID,
+ ATTR_RADIO,
+ ATTR_REPEAT_MODE,
+ ATTR_SAMPLE_RATE,
+ ATTR_SHUFFLE_ENABLED,
+ ATTR_STREAM_DETAILS,
+ ATTR_STREAM_TITLE,
+ ATTR_TRACKS,
+ ATTR_URI,
+ ATTR_VERSION,
+)
+
+if TYPE_CHECKING:
+ from music_assistant_client import MusicAssistantClient
+ from music_assistant_models.media_items import ItemMapping, MediaItemType
+ from music_assistant_models.queue_item import QueueItem
+
+MEDIA_ITEM_SCHEMA = vol.Schema(
+ {
+ vol.Required(ATTR_MEDIA_TYPE): vol.Coerce(MediaType),
+ vol.Required(ATTR_URI): cv.string,
+ vol.Required(ATTR_NAME): cv.string,
+ vol.Required(ATTR_VERSION): cv.string,
+ vol.Optional(ATTR_IMAGE, default=None): vol.Any(None, cv.string),
+ vol.Optional(ATTR_ARTISTS): [vol.Self],
+ vol.Optional(ATTR_ALBUM): vol.Self,
+ }
+)
+
+
+def media_item_dict_from_mass_item(
+ mass: MusicAssistantClient,
+ item: MediaItemType | ItemMapping | None,
+) -> dict[str, Any] | None:
+ """Parse a Music Assistant MediaItem."""
+ if not item:
+ return None
+ base = {
+ ATTR_MEDIA_TYPE: item.media_type,
+ ATTR_URI: item.uri,
+ ATTR_NAME: item.name,
+ ATTR_VERSION: item.version,
+ ATTR_IMAGE: mass.get_media_item_image_url(item),
+ }
+ if artists := getattr(item, "artists", None):
+ base[ATTR_ARTISTS] = [media_item_dict_from_mass_item(mass, x) for x in artists]
+ if album := getattr(item, "album", None):
+ base[ATTR_ALBUM] = media_item_dict_from_mass_item(mass, album)
+ return base
+
+
+SEARCH_RESULT_SCHEMA = vol.Schema(
+ {
+ vol.Required(ATTR_ARTISTS): vol.All(
+ cv.ensure_list, [vol.Schema(MEDIA_ITEM_SCHEMA)]
+ ),
+ vol.Required(ATTR_ALBUMS): vol.All(
+ cv.ensure_list, [vol.Schema(MEDIA_ITEM_SCHEMA)]
+ ),
+ vol.Required(ATTR_TRACKS): vol.All(
+ cv.ensure_list, [vol.Schema(MEDIA_ITEM_SCHEMA)]
+ ),
+ vol.Required(ATTR_PLAYLISTS): vol.All(
+ cv.ensure_list, [vol.Schema(MEDIA_ITEM_SCHEMA)]
+ ),
+ vol.Required(ATTR_RADIO): vol.All(
+ cv.ensure_list, [vol.Schema(MEDIA_ITEM_SCHEMA)]
+ ),
+ },
+)
+
+LIBRARY_RESULTS_SCHEMA = vol.Schema(
+ {
+ vol.Required(ATTR_ITEMS): vol.All(
+ cv.ensure_list, [vol.Schema(MEDIA_ITEM_SCHEMA)]
+ ),
+ vol.Required(ATTR_LIMIT): int,
+ vol.Required(ATTR_OFFSET): int,
+ vol.Required(ATTR_ORDER_BY): str,
+ vol.Required(ATTR_MEDIA_TYPE): vol.Coerce(MediaType),
+ }
+)
+
+AUDIO_FORMAT_SCHEMA = vol.Schema(
+ {
+ vol.Required(ATTR_CONTENT_TYPE): str,
+ vol.Required(ATTR_SAMPLE_RATE): int,
+ vol.Required(ATTR_BIT_DEPTH): int,
+ vol.Required(ATTR_PROVIDER): str,
+ vol.Required(ATTR_ITEM_ID): str,
+ }
+)
+
+QUEUE_ITEM_SCHEMA = vol.Schema(
+ {
+ vol.Required(ATTR_QUEUE_ITEM_ID): cv.string,
+ vol.Required(ATTR_NAME): cv.string,
+ vol.Optional(ATTR_DURATION, default=None): vol.Any(None, int),
+ vol.Optional(ATTR_MEDIA_ITEM, default=None): vol.Any(
+ None, vol.Schema(MEDIA_ITEM_SCHEMA)
+ ),
+ vol.Optional(ATTR_STREAM_DETAILS): vol.Schema(AUDIO_FORMAT_SCHEMA),
+ vol.Optional(ATTR_STREAM_TITLE, default=None): vol.Any(None, cv.string),
+ }
+)
+
+
+def queue_item_dict_from_mass_item(
+ mass: MusicAssistantClient,
+ item: QueueItem | None,
+) -> dict[str, Any] | None:
+ """Parse a Music Assistant QueueItem."""
+ if not item:
+ return None
+ base = {
+ ATTR_QUEUE_ITEM_ID: item.queue_item_id,
+ ATTR_NAME: item.name,
+ ATTR_DURATION: item.duration,
+ ATTR_MEDIA_ITEM: media_item_dict_from_mass_item(mass, item.media_item),
+ }
+ if streamdetails := item.streamdetails:
+ base[ATTR_STREAM_TITLE] = streamdetails.stream_title
+ base[ATTR_STREAM_DETAILS] = {
+ ATTR_CONTENT_TYPE: streamdetails.audio_format.content_type.value,
+ ATTR_SAMPLE_RATE: streamdetails.audio_format.sample_rate,
+ ATTR_BIT_DEPTH: streamdetails.audio_format.bit_depth,
+ ATTR_PROVIDER: streamdetails.provider,
+ ATTR_ITEM_ID: streamdetails.item_id,
+ }
+
+ return base
+
+
+QUEUE_DETAILS_SCHEMA = vol.Schema(
+ {
+ vol.Required(ATTR_QUEUE_ID): str,
+ vol.Required(ATTR_ACTIVE): bool,
+ vol.Required(ATTR_NAME): str,
+ vol.Required(ATTR_ITEMS): int,
+ vol.Required(ATTR_SHUFFLE_ENABLED): bool,
+ vol.Required(ATTR_REPEAT_MODE): str,
+ vol.Required(ATTR_CURRENT_INDEX): vol.Any(None, int),
+ vol.Required(ATTR_ELAPSED_TIME): vol.Coerce(int),
+ vol.Required(ATTR_CURRENT_ITEM): vol.Any(None, QUEUE_ITEM_SCHEMA),
+ vol.Required(ATTR_NEXT_ITEM): vol.Any(None, QUEUE_ITEM_SCHEMA),
+ }
+)
diff --git a/homeassistant/components/music_assistant/services.yaml b/homeassistant/components/music_assistant/services.yaml
new file mode 100644
index 00000000000..73e8e2d7521
--- /dev/null
+++ b/homeassistant/components/music_assistant/services.yaml
@@ -0,0 +1,233 @@
+# Descriptions for Music Assistant custom services
+
+play_media:
+ target:
+ entity:
+ domain: media_player
+ integration: music_assistant
+ supported_features:
+ - media_player.MediaPlayerEntityFeature.PLAY_MEDIA
+ fields:
+ media_id:
+ required: true
+ example: "spotify://playlist/aabbccddeeff"
+ selector:
+ object:
+ media_type:
+ example: "playlist"
+ selector:
+ select:
+ translation_key: media_type
+ options:
+ - artist
+ - album
+ - playlist
+ - track
+ - radio
+ artist:
+ example: "Queen"
+ selector:
+ text:
+ album:
+ example: "News of the world"
+ selector:
+ text:
+ enqueue:
+ selector:
+ select:
+ options:
+ - "play"
+ - "replace"
+ - "next"
+ - "replace_next"
+ - "add"
+ translation_key: enqueue
+ radio_mode:
+ advanced: true
+ selector:
+ boolean:
+
+play_announcement:
+ target:
+ entity:
+ domain: media_player
+ integration: music_assistant
+ supported_features:
+ - media_player.MediaPlayerEntityFeature.PLAY_MEDIA
+ - media_player.MediaPlayerEntityFeature.MEDIA_ANNOUNCE
+ fields:
+ url:
+ required: true
+ example: "http://someremotesite.com/doorbell.mp3"
+ selector:
+ text:
+ use_pre_announce:
+ example: "true"
+ selector:
+ boolean:
+ announce_volume:
+ example: 75
+ selector:
+ number:
+ min: 1
+ max: 100
+ step: 1
+
+transfer_queue:
+ target:
+ entity:
+ domain: media_player
+ integration: music_assistant
+ fields:
+ source_player:
+ selector:
+ entity:
+ domain: media_player
+ integration: music_assistant
+ auto_play:
+ example: "true"
+ selector:
+ boolean:
+
+get_queue:
+ target:
+ entity:
+ domain: media_player
+ integration: music_assistant
+ supported_features:
+ - media_player.MediaPlayerEntityFeature.PLAY_MEDIA
+
+search:
+ fields:
+ config_entry_id:
+ required: true
+ selector:
+ config_entry:
+ integration: music_assistant
+ name:
+ required: true
+ example: "We Are The Champions"
+ selector:
+ text:
+ media_type:
+ example: "playlist"
+ selector:
+ select:
+ multiple: true
+ translation_key: media_type
+ options:
+ - artist
+ - album
+ - playlist
+ - track
+ - radio
+ artist:
+ example: "Queen"
+ selector:
+ text:
+ album:
+ example: "News of the world"
+ selector:
+ text:
+ limit:
+ advanced: true
+ example: 25
+ default: 5
+ selector:
+ number:
+ min: 1
+ max: 100
+ step: 1
+ library_only:
+ example: "true"
+ default: false
+ selector:
+ boolean:
+
+get_library:
+ fields:
+ config_entry_id:
+ required: true
+ selector:
+ config_entry:
+ integration: music_assistant
+ media_type:
+ required: true
+ example: "playlist"
+ selector:
+ select:
+ translation_key: media_type
+ options:
+ - artist
+ - album
+ - playlist
+ - track
+ - radio
+ favorite:
+ example: "true"
+ default: false
+ selector:
+ boolean:
+ search:
+ example: "We Are The Champions"
+ selector:
+ text:
+ limit:
+ advanced: true
+ example: 25
+ default: 25
+ selector:
+ number:
+ min: 1
+ max: 500
+ step: 1
+ offset:
+ advanced: true
+ example: 25
+ default: 0
+ selector:
+ number:
+ min: 1
+ max: 1000000
+ step: 1
+ order_by:
+ example: "random"
+ selector:
+ select:
+ translation_key: order_by
+ options:
+ - name
+ - name_desc
+ - sort_name
+ - sort_name_desc
+ - timestamp_added
+ - timestamp_added_desc
+ - last_played
+ - last_played_desc
+ - play_count
+ - play_count_desc
+ - year
+ - year_desc
+ - position
+ - position_desc
+ - artist_name
+ - artist_name_desc
+ - random
+ - random_play_count
+ album_type:
+ example: "single"
+ selector:
+ select:
+ multiple: true
+ translation_key: album_type
+ options:
+ - album
+ - single
+ - compilation
+ - ep
+ - unknown
+ album_artists_only:
+ example: "true"
+ default: false
+ selector:
+ boolean:
diff --git a/homeassistant/components/music_assistant/strings.json b/homeassistant/components/music_assistant/strings.json
index f15b0b1b306..af366c94310 100644
--- a/homeassistant/components/music_assistant/strings.json
+++ b/homeassistant/components/music_assistant/strings.json
@@ -37,6 +37,150 @@
"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.",
+ "fields": {
+ "media_id": {
+ "name": "Media ID(s)",
+ "description": "URI or name of the item you want to play. Specify a list if you want to play/enqueue multiple items."
+ },
+ "media_type": {
+ "name": "Media type",
+ "description": "The type of the content to play. Such as artist, album, track or playlist. Will be auto-determined if omitted."
+ },
+ "enqueue": {
+ "name": "Enqueue",
+ "description": "If the content should be played now or added to the queue."
+ },
+ "artist": {
+ "name": "Artist name",
+ "description": "When specifying a track or album by name in the Media ID field, you can optionally restrict results by this artist name."
+ },
+ "album": {
+ "name": "Album name",
+ "description": "When specifying a track by name in the Media ID field, you can optionally restrict results by this album name."
+ },
+ "radio_mode": {
+ "name": "Enable radio mode",
+ "description": "Enable radio mode to auto-generate a playlist based on the selection."
+ }
+ }
+ },
+ "play_announcement": {
+ "name": "Play announcement",
+ "description": "Play announcement on a Music Assistant player with more fine-grained control options.",
+ "fields": {
+ "url": {
+ "name": "URL",
+ "description": "URL to the notification sound."
+ },
+ "use_pre_announce": {
+ "name": "Use pre-announce",
+ "description": "Use pre-announcement sound for the announcement. Omit to use the player default."
+ },
+ "announce_volume": {
+ "name": "Announce volume",
+ "description": "Use a forced volume level for the announcement. Omit to use player default."
+ }
+ }
+ },
+ "transfer_queue": {
+ "name": "Transfer queue",
+ "description": "Transfer the player's queue to another player.",
+ "fields": {
+ "source_player": {
+ "name": "Source media player",
+ "description": "The source media player which has the queue you want to transfer. When omitted, the first playing player will be used."
+ },
+ "auto_play": {
+ "name": "Auto play",
+ "description": "Start playing the queue on the target player. Omit to use the default behavior."
+ }
+ }
+ },
+ "get_queue": {
+ "name": "Get playerQueue details (advanced)",
+ "description": "Get 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.",
+ "fields": {
+ "config_entry_id": {
+ "name": "Music Assistant instance",
+ "description": "Select the Music Assistant instance to perform the search on."
+ },
+ "name": {
+ "name": "Search name",
+ "description": "The name/title to search for."
+ },
+ "media_type": {
+ "name": "Media type(s)",
+ "description": "The type of the content to search. Such as artist, album, track, radio, or playlist. All types if omitted."
+ },
+ "artist": {
+ "name": "Artist name",
+ "description": "When specifying a track or album name in the name field, you can optionally restrict results by this artist name."
+ },
+ "album": {
+ "name": "Album name",
+ "description": "When specifying a track name in the name field, you can optionally restrict results by this album name."
+ },
+ "limit": {
+ "name": "Limit",
+ "description": "Maximum number of items to return (per media type)."
+ },
+ "library_only": {
+ "name": "Only library items",
+ "description": "Only include results that are in the library."
+ }
+ }
+ },
+ "get_library": {
+ "name": "Get Library items",
+ "description": "Get items from a Music Assistant library.",
+ "fields": {
+ "config_entry_id": {
+ "name": "[%key:component::music_assistant::services::search::fields::config_entry_id::name%]",
+ "description": "[%key:component::music_assistant::services::search::fields::config_entry_id::description%]"
+ },
+ "media_type": {
+ "name": "Media type",
+ "description": "The media type for which to request details for."
+ },
+ "favorite": {
+ "name": "Favorites only",
+ "description": "Filter items so only favorites items are returned."
+ },
+ "search": {
+ "name": "Search",
+ "description": "Optional search string to search through this library."
+ },
+ "limit": {
+ "name": "Limit",
+ "description": "Maximum number of items to return."
+ },
+ "offset": {
+ "name": "Offset",
+ "description": "Offset to start the list from."
+ },
+ "order_by": {
+ "name": "Order By",
+ "description": "Sort the list by this field."
+ },
+ "album_type": {
+ "name": "Album type filter (albums library only)",
+ "description": "Filter albums by type."
+ },
+ "album_artists_only": {
+ "name": "Enable album artists filter (only for artist library)",
+ "description": "Only return Album Artists when listing the Artists library items."
+ }
+ }
+ }
+ },
"selector": {
"enqueue": {
"options": {
@@ -46,6 +190,46 @@
"replace": "Play now and clear queue",
"replace_next": "Play next and clear queue"
}
+ },
+ "media_type": {
+ "options": {
+ "artist": "Artist",
+ "album": "Album",
+ "track": "Track",
+ "playlist": "Playlist",
+ "radio": "Radio"
+ }
+ },
+ "order_by": {
+ "options": {
+ "name": "Name",
+ "name_desc": "Name (desc)",
+ "sort_name": "Sort name",
+ "sort_name_desc": "Sort name (desc)",
+ "timestamp_added": "Added",
+ "timestamp_added_desc": "Added (desc)",
+ "last_played": "Last played",
+ "last_played_desc": "Last played (desc)",
+ "play_count": "Play count",
+ "play_count_desc": "Play count (desc)",
+ "year": "Year",
+ "year_desc": "Year (desc)",
+ "position": "Position",
+ "position_desc": "Position (desc)",
+ "artist_name": "Artist name",
+ "artist_name_desc": "Artist name (desc)",
+ "random": "Random",
+ "random_play_count": "Random + least played"
+ }
+ },
+ "album_type": {
+ "options": {
+ "album": "Album",
+ "single": "Single",
+ "ep": "EP",
+ "compilation": "Compilation",
+ "unknown": "Unknown"
+ }
}
}
}
diff --git a/homeassistant/components/mvglive/manifest.json b/homeassistant/components/mvglive/manifest.json
index f73d4612c2e..2c4e6a7e735 100644
--- a/homeassistant/components/mvglive/manifest.json
+++ b/homeassistant/components/mvglive/manifest.json
@@ -6,5 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/mvglive",
"iot_class": "cloud_polling",
"loggers": ["MVGLive"],
+ "quality_scale": "legacy",
"requirements": ["PyMVGLive==1.1.4"]
}
diff --git a/homeassistant/components/mycroft/manifest.json b/homeassistant/components/mycroft/manifest.json
index 9b8731f0701..568bb8b1784 100644
--- a/homeassistant/components/mycroft/manifest.json
+++ b/homeassistant/components/mycroft/manifest.json
@@ -6,5 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/mycroft",
"iot_class": "local_push",
"loggers": ["mycroftapi"],
+ "quality_scale": "legacy",
"requirements": ["mycroftapi==2.0"]
}
diff --git a/homeassistant/components/mysensors/climate.py b/homeassistant/components/mysensors/climate.py
index ce15faa589c..23b7c47ebf3 100644
--- a/homeassistant/components/mysensors/climate.py
+++ b/homeassistant/components/mysensors/climate.py
@@ -72,7 +72,6 @@ class MySensorsHVAC(MySensorsChildEntity, ClimateEntity):
"""Representation of a MySensors HVAC."""
_attr_hvac_modes = OPERATION_LIST
- _enable_turn_on_off_backwards_compatibility = False
@property
def supported_features(self) -> ClimateEntityFeature:
diff --git a/homeassistant/components/mythicbeastsdns/manifest.json b/homeassistant/components/mythicbeastsdns/manifest.json
index ed0b96575c9..a4381c312bc 100644
--- a/homeassistant/components/mythicbeastsdns/manifest.json
+++ b/homeassistant/components/mythicbeastsdns/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/mythicbeastsdns",
"iot_class": "cloud_push",
"loggers": ["mbddns"],
+ "quality_scale": "legacy",
"requirements": ["mbddns==0.1.2"]
}
diff --git a/homeassistant/components/myuplink/__init__.py b/homeassistant/components/myuplink/__init__.py
index d801f27817d..5ad114e973e 100644
--- a/homeassistant/components/myuplink/__init__.py
+++ b/homeassistant/components/myuplink/__init__.py
@@ -3,8 +3,10 @@
from __future__ import annotations
from http import HTTPStatus
+import logging
from aiohttp import ClientError, ClientResponseError
+import jwt
from myuplink import MyUplinkAPI, get_manufacturer, get_model, get_system_name
from homeassistant.config_entries import ConfigEntry
@@ -22,6 +24,8 @@ from .api import AsyncConfigEntryAuth
from .const import DOMAIN, OAUTH2_SCOPES
from .coordinator import MyUplinkDataCoordinator
+_LOGGER = logging.getLogger(__name__)
+
PLATFORMS: list[Platform] = [
Platform.BINARY_SENSOR,
Platform.NUMBER,
@@ -51,13 +55,25 @@ async def async_setup_entry(
await auth.async_get_access_token()
except ClientResponseError as err:
if err.status in {HTTPStatus.UNAUTHORIZED, HTTPStatus.FORBIDDEN}:
- raise ConfigEntryAuthFailed from err
- raise ConfigEntryNotReady from err
+ raise ConfigEntryAuthFailed(
+ translation_domain=DOMAIN,
+ translation_key="config_entry_auth_failed",
+ ) from err
+ raise ConfigEntryNotReady(
+ translation_domain=DOMAIN,
+ translation_key="config_entry_not_ready",
+ ) from err
except ClientError as err:
- raise ConfigEntryNotReady from err
+ raise ConfigEntryNotReady(
+ translation_domain=DOMAIN,
+ translation_key="config_entry_not_ready",
+ ) from err
if set(config_entry.data["token"]["scope"].split(" ")) != set(OAUTH2_SCOPES):
- raise ConfigEntryAuthFailed("Incorrect OAuth2 scope")
+ raise ConfigEntryAuthFailed(
+ translation_domain=DOMAIN,
+ translation_key="incorrect_oauth2_scope",
+ )
# Setup MyUplinkAPI and coordinator for data fetch
api = MyUplinkAPI(auth)
@@ -73,14 +89,16 @@ 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: MyUplinkConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
@callback
def create_devices(
- hass: HomeAssistant, config_entry: ConfigEntry, coordinator: MyUplinkDataCoordinator
+ hass: HomeAssistant,
+ config_entry: MyUplinkConfigEntry,
+ coordinator: MyUplinkDataCoordinator,
) -> None:
"""Update all devices."""
device_registry = dr.async_get(hass)
@@ -109,3 +127,27 @@ async def async_remove_config_entry_device(
return not device_entry.identifiers.intersection(
(DOMAIN, device_id) for device_id in myuplink_data.data.devices
)
+
+
+async def async_migrate_entry(
+ hass: HomeAssistant, config_entry: MyUplinkConfigEntry
+) -> bool:
+ """Migrate old entry."""
+
+ # Use sub(ject) from access_token as unique_id
+ if config_entry.version == 1 and config_entry.minor_version == 1:
+ token = jwt.decode(
+ config_entry.data["token"]["access_token"],
+ options={"verify_signature": False},
+ )
+ uid = token["sub"]
+ hass.config_entries.async_update_entry(
+ config_entry, unique_id=uid, minor_version=2
+ )
+ _LOGGER.info(
+ "Migration to version %s.%s successful",
+ config_entry.version,
+ config_entry.minor_version,
+ )
+
+ return True
diff --git a/homeassistant/components/myuplink/binary_sensor.py b/homeassistant/components/myuplink/binary_sensor.py
index 0ba6ac7b078..d903c7cbfae 100644
--- a/homeassistant/components/myuplink/binary_sensor.py
+++ b/homeassistant/components/myuplink/binary_sensor.py
@@ -12,11 +12,12 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import MyUplinkConfigEntry, MyUplinkDataCoordinator
+from .const import F_SERIES
from .entity import MyUplinkEntity, MyUplinkSystemEntity
-from .helpers import find_matching_platform
+from .helpers import find_matching_platform, transform_model_series
CATEGORY_BASED_DESCRIPTIONS: dict[str, dict[str, BinarySensorEntityDescription]] = {
- "F730": {
+ F_SERIES: {
"43161": BinarySensorEntityDescription(
key="elect_add",
translation_key="elect_add",
@@ -50,6 +51,7 @@ def get_description(device_point: DevicePoint) -> BinarySensorEntityDescription
2. Default to None
"""
prefix, _, _ = device_point.category.partition(" ")
+ prefix = transform_model_series(prefix)
return CATEGORY_BASED_DESCRIPTIONS.get(prefix, {}).get(device_point.parameter_id)
@@ -153,7 +155,7 @@ class MyUplinkDeviceBinarySensor(MyUplinkEntity, BinarySensorEntity):
self,
coordinator: MyUplinkDataCoordinator,
device_id: str,
- entity_description: BinarySensorEntityDescription | None,
+ entity_description: BinarySensorEntityDescription,
unique_id_suffix: str,
) -> None:
"""Initialize the binary_sensor."""
@@ -163,8 +165,7 @@ class MyUplinkDeviceBinarySensor(MyUplinkEntity, BinarySensorEntity):
unique_id_suffix=unique_id_suffix,
)
- if entity_description is not None:
- self.entity_description = entity_description
+ self.entity_description = entity_description
@property
def is_on(self) -> bool:
@@ -183,7 +184,7 @@ class MyUplinkSystemBinarySensor(MyUplinkSystemEntity, BinarySensorEntity):
coordinator: MyUplinkDataCoordinator,
system_id: str,
device_id: str,
- entity_description: BinarySensorEntityDescription | None,
+ entity_description: BinarySensorEntityDescription,
unique_id_suffix: str,
) -> None:
"""Initialize the binary_sensor."""
@@ -194,8 +195,7 @@ class MyUplinkSystemBinarySensor(MyUplinkSystemEntity, BinarySensorEntity):
unique_id_suffix=unique_id_suffix,
)
- if entity_description is not None:
- self.entity_description = entity_description
+ self.entity_description = entity_description
@property
def is_on(self) -> bool | None:
diff --git a/homeassistant/components/myuplink/config_flow.py b/homeassistant/components/myuplink/config_flow.py
index 554347cfd19..cf0428f59ce 100644
--- a/homeassistant/components/myuplink/config_flow.py
+++ b/homeassistant/components/myuplink/config_flow.py
@@ -4,7 +4,13 @@ from collections.abc import Mapping
import logging
from typing import Any
-from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult
+import jwt
+
+from homeassistant.config_entries import (
+ SOURCE_REAUTH,
+ SOURCE_RECONFIGURE,
+ ConfigFlowResult,
+)
from homeassistant.helpers import config_entry_oauth2_flow
from .const import DOMAIN, OAUTH2_SCOPES
@@ -15,6 +21,8 @@ class OAuth2FlowHandler(
):
"""Config flow to handle myUplink OAuth2 authentication."""
+ VERSION = 1
+ MINOR_VERSION = 2
DOMAIN = DOMAIN
@property
@@ -44,10 +52,30 @@ class OAuth2FlowHandler(
return await self.async_step_user()
+ async def async_step_reconfigure(
+ self, user_input: Mapping[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """User initiated reconfiguration."""
+ return await self.async_step_user()
+
async def async_oauth_create_entry(self, data: dict) -> ConfigFlowResult:
"""Create or update the config entry."""
+
+ token = jwt.decode(
+ data["token"]["access_token"], options={"verify_signature": False}
+ )
+ uid = token["sub"]
+ await self.async_set_unique_id(uid)
+
if self.source == SOURCE_REAUTH:
+ self._abort_if_unique_id_mismatch(reason="account_mismatch")
return self.async_update_reload_and_abort(
self._get_reauth_entry(), data=data
)
+ if self.source == SOURCE_RECONFIGURE:
+ self._abort_if_unique_id_mismatch(reason="account_mismatch")
+ return self.async_update_reload_and_abort(
+ self._get_reconfigure_entry(), data=data
+ )
+ self._abort_if_unique_id_configured()
return await super().async_oauth_create_entry(data)
diff --git a/homeassistant/components/myuplink/const.py b/homeassistant/components/myuplink/const.py
index 3541a8078c3..6fd354a21ec 100644
--- a/homeassistant/components/myuplink/const.py
+++ b/homeassistant/components/myuplink/const.py
@@ -6,3 +6,5 @@ API_ENDPOINT = "https://api.myuplink.com"
OAUTH2_AUTHORIZE = "https://api.myuplink.com/oauth/authorize"
OAUTH2_TOKEN = "https://api.myuplink.com/oauth/token"
OAUTH2_SCOPES = ["WRITESYSTEM", "READSYSTEM", "offline_access"]
+
+F_SERIES = "f-series"
diff --git a/homeassistant/components/myuplink/helpers.py b/homeassistant/components/myuplink/helpers.py
index eb4881c410e..bd875d8a872 100644
--- a/homeassistant/components/myuplink/helpers.py
+++ b/homeassistant/components/myuplink/helpers.py
@@ -6,6 +6,8 @@ from homeassistant.components.number import NumberEntityDescription
from homeassistant.components.sensor import SensorEntityDescription
from homeassistant.const import Platform
+from .const import F_SERIES
+
def find_matching_platform(
device_point: DevicePoint,
@@ -86,17 +88,24 @@ PARAMETER_ID_TO_EXCLUDE_F730 = (
"47941",
"47975",
"48009",
- "48042",
"48072",
+ "48442",
+ "49909",
"50113",
)
PARAMETER_ID_TO_INCLUDE_SMO20 = (
+ "40013",
+ "40033",
"40940",
+ "44069",
+ "44071",
+ "44073",
"47011",
"47015",
"47028",
"47032",
+ "47398",
"50004",
)
@@ -110,7 +119,7 @@ def skip_entity(model: str, device_point: DevicePoint) -> bool:
):
return False
return True
- if "F730" in model:
+ if model.lower().startswith("f"):
# Entity names containing weekdays are used for advanced scheduling in the
# heat pump and should not be exposed in the integration
if any(d in device_point.parameter_name.lower() for d in WEEKDAYS):
@@ -118,3 +127,10 @@ def skip_entity(model: str, device_point: DevicePoint) -> bool:
if device_point.parameter_id in PARAMETER_ID_TO_EXCLUDE_F730:
return True
return False
+
+
+def transform_model_series(prefix: str) -> str:
+ """Remap all F-series models."""
+ if prefix.lower().startswith("f"):
+ return F_SERIES
+ return prefix
diff --git a/homeassistant/components/myuplink/manifest.json b/homeassistant/components/myuplink/manifest.json
index 0e638a72715..8438d24194c 100644
--- a/homeassistant/components/myuplink/manifest.json
+++ b/homeassistant/components/myuplink/manifest.json
@@ -6,5 +6,6 @@
"dependencies": ["application_credentials"],
"documentation": "https://www.home-assistant.io/integrations/myuplink",
"iot_class": "cloud_polling",
+ "quality_scale": "silver",
"requirements": ["myuplink==0.6.0"]
}
diff --git a/homeassistant/components/myuplink/number.py b/homeassistant/components/myuplink/number.py
index 0c7da0c716f..e1cbd393947 100644
--- a/homeassistant/components/myuplink/number.py
+++ b/homeassistant/components/myuplink/number.py
@@ -10,8 +10,9 @@ from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import MyUplinkConfigEntry, MyUplinkDataCoordinator
+from .const import DOMAIN, F_SERIES
from .entity import MyUplinkEntity
-from .helpers import find_matching_platform, skip_entity
+from .helpers import find_matching_platform, skip_entity, transform_model_series
DEVICE_POINT_UNIT_DESCRIPTIONS: dict[str, NumberEntityDescription] = {
"DM": NumberEntityDescription(
@@ -22,7 +23,7 @@ DEVICE_POINT_UNIT_DESCRIPTIONS: dict[str, NumberEntityDescription] = {
}
CATEGORY_BASED_DESCRIPTIONS: dict[str, dict[str, NumberEntityDescription]] = {
- "F730": {
+ F_SERIES: {
"40940": NumberEntityDescription(
key="degree_minutes",
translation_key="degree_minutes",
@@ -48,6 +49,7 @@ def get_description(device_point: DevicePoint) -> NumberEntityDescription | None
3. Default to None
"""
prefix, _, _ = device_point.category.partition(" ")
+ prefix = transform_model_series(prefix)
description = CATEGORY_BASED_DESCRIPTIONS.get(prefix, {}).get(
device_point.parameter_id
)
@@ -108,13 +110,16 @@ class MyUplinkNumber(MyUplinkEntity, NumberEntity):
# Internal properties
self.point_id = device_point.parameter_id
self._attr_name = device_point.parameter_name
+ _scale = float(device_point.scale_value if device_point.scale_value else 1.0)
self._attr_native_min_value = (
- device_point.raw["minValue"] if device_point.raw["minValue"] else -30000
- ) * float(device_point.raw.get("scaleValue", 1))
+ device_point.min_value if device_point.min_value else -30000
+ ) * _scale
self._attr_native_max_value = (
- device_point.raw["maxValue"] if device_point.raw["maxValue"] else 30000
- ) * float(device_point.raw.get("scaleValue", 1))
- self._attr_step_value = device_point.raw.get("stepValue", 20)
+ device_point.max_value if device_point.max_value else 30000
+ ) * _scale
+ self._attr_native_step = (
+ device_point.step_value if device_point.step_value else 1.0
+ ) * _scale
if entity_description is not None:
self.entity_description = entity_description
@@ -132,7 +137,13 @@ class MyUplinkNumber(MyUplinkEntity, NumberEntity):
)
except ClientError as err:
raise HomeAssistantError(
- f"Failed to set new value {value} for {self.point_id}/{self.entity_id}"
+ translation_domain=DOMAIN,
+ translation_key="set_number_error",
+ translation_placeholders={
+ "entity": self.entity_id,
+ "point": self.point_id,
+ "value": str(value),
+ },
) from err
await self.coordinator.async_request_refresh()
diff --git a/homeassistant/components/myuplink/quality_scale.yaml b/homeassistant/components/myuplink/quality_scale.yaml
new file mode 100644
index 00000000000..be0780a206c
--- /dev/null
+++ b/homeassistant/components/myuplink/quality_scale.yaml
@@ -0,0 +1,96 @@
+rules:
+ # Bronze
+ action-setup:
+ status: exempt
+ comment: |
+ No custom actions are defined.
+ appropriate-polling: done
+ brands: done
+ common-modules: done
+ config-flow-test-coverage: done
+ config-flow: done
+ dependency-transparency: done
+ docs-actions:
+ status: exempt
+ comment: |
+ No custom actions are defined.
+ 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: done
+ test-before-setup: done
+ unique-config-entry: done
+
+ # Silver
+ action-exceptions:
+ status: exempt
+ comment: |
+ No custom actions are defined.
+ config-entry-unloading: done
+ docs-configuration-parameters:
+ status: exempt
+ comment: No configuration parameters
+ docs-installation-parameters:
+ status: done
+ comment: Described in installation instructions
+ entity-unavailable: done
+ integration-owner: done
+ log-when-unavailable: done
+ parallel-updates:
+ status: exempt
+ comment: Handled by coordinator
+ reauthentication-flow: done
+ test-coverage: done
+
+ # Gold
+ devices: done
+ diagnostics: done
+ discovery-update-info:
+ status: exempt
+ comment: |
+ Not possible to discover these devices.
+ discovery:
+ status: exempt
+ comment: |
+ Not possible to discover these devices.
+ docs-data-update: done
+ docs-examples: done
+ docs-known-limitations: done
+ docs-supported-devices: done
+ docs-supported-functions: todo
+ docs-troubleshooting: done
+ docs-use-cases: done
+ dynamic-devices: todo
+ entity-category: done
+ entity-device-class: done
+ entity-disabled-by-default: done
+ entity-translations:
+ status: done
+ comment: |
+ Datapoint names are read from the API metadata and used as entity names in HA.
+ It is not feasible to use the API names as translation keys as they can change between
+ firmware and API upgrades and the number of appliance models and firmware releases are huge.
+ Entity names translations are therefore not implemented for the time being.
+ exception-translations: done
+ icon-translations: done
+ reconfiguration-flow: done
+ repair-issues:
+ status: exempt
+ comment: |
+ No repair-issues are raised.
+ stale-devices:
+ status: done
+ comment: |
+ There is no way for the integration to know if a device is gone temporarily or permanently. User is allowed to delete a stale device from GUI.
+
+ # Platinum
+ async-dependency: done
+ inject-websession: done
+ strict-typing: done
diff --git a/homeassistant/components/myuplink/select.py b/homeassistant/components/myuplink/select.py
index c0fb66602de..0074d1c75ff 100644
--- a/homeassistant/components/myuplink/select.py
+++ b/homeassistant/components/myuplink/select.py
@@ -5,13 +5,14 @@ from typing import cast
from aiohttp import ClientError
from myuplink import DevicePoint
-from homeassistant.components.select import SelectEntity, SelectEntityDescription
+from homeassistant.components.select import SelectEntity
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import MyUplinkConfigEntry, MyUplinkDataCoordinator
+from .const import DOMAIN
from .entity import MyUplinkEntity
from .helpers import find_matching_platform, skip_entity
@@ -30,14 +31,12 @@ async def async_setup_entry(
for point_id, device_point in point_data.items():
if skip_entity(device_point.category, device_point):
continue
- description = None
- if find_matching_platform(device_point, description) == Platform.SELECT:
+ if find_matching_platform(device_point, None) == Platform.SELECT:
entities.append(
MyUplinkSelect(
coordinator=coordinator,
device_id=device_id,
device_point=device_point,
- entity_description=description,
unique_id_suffix=point_id,
)
)
@@ -53,7 +52,6 @@ class MyUplinkSelect(MyUplinkEntity, SelectEntity):
coordinator: MyUplinkDataCoordinator,
device_id: str,
device_point: DevicePoint,
- entity_description: SelectEntityDescription | None,
unique_id_suffix: str,
) -> None:
"""Initialize the select."""
@@ -89,7 +87,13 @@ class MyUplinkSelect(MyUplinkEntity, SelectEntity):
)
except ClientError as err:
raise HomeAssistantError(
- f"Failed to set new option {self.options_rev[option]} for {self.point_id}/{self.entity_id}"
+ translation_domain=DOMAIN,
+ translation_key="set_select_error",
+ translation_placeholders={
+ "entity": self.entity_id,
+ "option": self.options_rev[option],
+ "point": self.point_id,
+ },
) from err
await self.coordinator.async_request_refresh()
diff --git a/homeassistant/components/myuplink/sensor.py b/homeassistant/components/myuplink/sensor.py
index 7feb20bc093..ef827fc1fb1 100644
--- a/homeassistant/components/myuplink/sensor.py
+++ b/homeassistant/components/myuplink/sensor.py
@@ -25,8 +25,9 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import StateType
from . import MyUplinkConfigEntry, MyUplinkDataCoordinator
+from .const import F_SERIES
from .entity import MyUplinkEntity
-from .helpers import find_matching_platform, skip_entity
+from .helpers import find_matching_platform, skip_entity, transform_model_series
DEVICE_POINT_UNIT_DESCRIPTIONS: dict[str, SensorEntityDescription] = {
"°C": SensorEntityDescription(
@@ -139,7 +140,7 @@ DEVICE_POINT_UNIT_DESCRIPTIONS: dict[str, SensorEntityDescription] = {
MARKER_FOR_UNKNOWN_VALUE = -32768
CATEGORY_BASED_DESCRIPTIONS: dict[str, dict[str, SensorEntityDescription]] = {
- "F730": {
+ F_SERIES: {
"43108": SensorEntityDescription(
key="fan_mode",
translation_key="fan_mode",
@@ -200,6 +201,7 @@ def get_description(device_point: DevicePoint) -> SensorEntityDescription | None
"""
description = None
prefix, _, _ = device_point.category.partition(" ")
+ prefix = transform_model_series(prefix)
description = CATEGORY_BASED_DESCRIPTIONS.get(prefix, {}).get(
device_point.parameter_id
)
diff --git a/homeassistant/components/myuplink/strings.json b/homeassistant/components/myuplink/strings.json
index 9ec5c355d78..939aa2f17c8 100644
--- a/homeassistant/components/myuplink/strings.json
+++ b/homeassistant/components/myuplink/strings.json
@@ -1,6 +1,6 @@
{
"application_credentials": {
- "description": "Follow the [instructions]({more_info_url}) to give Home Assistant access to your myUplink account. You also need to create application credentials linked to your account:\n1. Go to [Applications at myUplink developer site]({create_creds_url}) and get credentials from an existing application or select **Create New Application**.\n1. Set appropriate Application name and Description\n2. Enter `{callback_url}` as Callback Url"
+ "description": "Follow the [instructions]({more_info_url}) to give Home Assistant access to your myUplink account. You also need to create application credentials linked to your account:\n1. Go to [Applications at myUplink developer site]({create_creds_url}) and get credentials from an existing application or select **Create New Application**.\n1. Set appropriate Application name and Description\n1. Enter `{callback_url}` as Callback URL"
},
"config": {
"step": {
@@ -23,6 +23,8 @@
"oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]",
"oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
+ "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
+ "account_mismatch": "The used account does not match the original account",
"user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]"
},
"create_entry": {
@@ -40,5 +42,25 @@
"name": "Status"
}
}
+ },
+ "exceptions": {
+ "config_entry_auth_failed": {
+ "message": "Error while logging in to the API. Please check your credentials."
+ },
+ "config_entry_not_ready": {
+ "message": "Error while loading the integration."
+ },
+ "incorrect_oauth2_scope": {
+ "message": "Stored permissions are invalid. Please login again to update permissions."
+ },
+ "set_number_error": {
+ "message": "Failed to set new value {value} for {point}/{entity}."
+ },
+ "set_select_error": {
+ "message": "Failed to set new option {option} for {point}/{entity}."
+ },
+ "set_switch_error": {
+ "message": "Failed to set state for {entity}."
+ }
}
}
diff --git a/homeassistant/components/myuplink/switch.py b/homeassistant/components/myuplink/switch.py
index 5c47c8294fe..3addc7ce6a9 100644
--- a/homeassistant/components/myuplink/switch.py
+++ b/homeassistant/components/myuplink/switch.py
@@ -12,11 +12,12 @@ from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import MyUplinkConfigEntry, MyUplinkDataCoordinator
+from .const import DOMAIN, F_SERIES
from .entity import MyUplinkEntity
-from .helpers import find_matching_platform, skip_entity
+from .helpers import find_matching_platform, skip_entity, transform_model_series
CATEGORY_BASED_DESCRIPTIONS: dict[str, dict[str, SwitchEntityDescription]] = {
- "F730": {
+ F_SERIES: {
"50004": SwitchEntityDescription(
key="temporary_lux",
translation_key="temporary_lux",
@@ -47,6 +48,7 @@ def get_description(device_point: DevicePoint) -> SwitchEntityDescription | None
2. Default to None
"""
prefix, _, _ = device_point.category.partition(" ")
+ prefix = transform_model_series(prefix)
return CATEGORY_BASED_DESCRIPTIONS.get(prefix, {}).get(device_point.parameter_id)
@@ -127,7 +129,11 @@ class MyUplinkDevicePointSwitch(MyUplinkEntity, SwitchEntity):
)
except aiohttp.ClientError as err:
raise HomeAssistantError(
- f"Failed to set state for {self.entity_id}"
+ translation_domain=DOMAIN,
+ translation_key="set_switch_error",
+ translation_placeholders={
+ "entity": self.entity_id,
+ },
) from err
await self.coordinator.async_request_refresh()
diff --git a/homeassistant/components/nad/manifest.json b/homeassistant/components/nad/manifest.json
index 2e2d44341af..64c7855af2d 100644
--- a/homeassistant/components/nad/manifest.json
+++ b/homeassistant/components/nad/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/nad",
"iot_class": "local_polling",
"loggers": ["nad_receiver"],
+ "quality_scale": "legacy",
"requirements": ["nad-receiver==0.3.0"]
}
diff --git a/homeassistant/components/nam/manifest.json b/homeassistant/components/nam/manifest.json
index 7b37d1f7ede..c3a559de50b 100644
--- a/homeassistant/components/nam/manifest.json
+++ b/homeassistant/components/nam/manifest.json
@@ -7,8 +7,7 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["nettigo_air_monitor"],
- "quality_scale": "platinum",
- "requirements": ["nettigo-air-monitor==3.3.0"],
+ "requirements": ["nettigo-air-monitor==4.0.0"],
"zeroconf": [
{
"type": "_http._tcp.local.",
diff --git a/homeassistant/components/namecheapdns/manifest.json b/homeassistant/components/namecheapdns/manifest.json
index fc9aa3cc033..f97f6568192 100644
--- a/homeassistant/components/namecheapdns/manifest.json
+++ b/homeassistant/components/namecheapdns/manifest.json
@@ -4,5 +4,6 @@
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/namecheapdns",
"iot_class": "cloud_push",
+ "quality_scale": "legacy",
"requirements": ["defusedxml==0.7.1"]
}
diff --git a/homeassistant/components/nanoleaf/light.py b/homeassistant/components/nanoleaf/light.py
index 19d817b9999..681053fa573 100644
--- a/homeassistant/components/nanoleaf/light.py
+++ b/homeassistant/components/nanoleaf/light.py
@@ -2,12 +2,11 @@
from __future__ import annotations
-import math
from typing import Any
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
- ATTR_COLOR_TEMP,
+ ATTR_COLOR_TEMP_KELVIN,
ATTR_EFFECT,
ATTR_HS_COLOR,
ATTR_TRANSITION,
@@ -17,10 +16,6 @@ from homeassistant.components.light import (
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from homeassistant.util.color import (
- color_temperature_kelvin_to_mired as kelvin_to_mired,
- color_temperature_mired_to_kelvin as mired_to_kelvin,
-)
from . import NanoleafConfigEntry
from .coordinator import NanoleafCoordinator
@@ -51,10 +46,8 @@ class NanoleafLight(NanoleafEntity, LightEntity):
"""Initialize the Nanoleaf light."""
super().__init__(coordinator)
self._attr_unique_id = self._nanoleaf.serial_no
- self._attr_min_mireds = math.ceil(
- 1000000 / self._nanoleaf.color_temperature_max
- )
- self._attr_max_mireds = kelvin_to_mired(self._nanoleaf.color_temperature_min)
+ self._attr_max_color_temp_kelvin = self._nanoleaf.color_temperature_max
+ self._attr_min_color_temp_kelvin = self._nanoleaf.color_temperature_min
@property
def brightness(self) -> int:
@@ -62,9 +55,9 @@ class NanoleafLight(NanoleafEntity, LightEntity):
return int(self._nanoleaf.brightness * 2.55)
@property
- def color_temp(self) -> int:
- """Return the current color temperature."""
- return kelvin_to_mired(self._nanoleaf.color_temperature)
+ def color_temp_kelvin(self) -> int | None:
+ """Return the color temperature value in Kelvin."""
+ return self._nanoleaf.color_temperature
@property
def effect(self) -> str | None:
@@ -106,7 +99,7 @@ class NanoleafLight(NanoleafEntity, LightEntity):
"""Instruct the light to turn on."""
brightness = kwargs.get(ATTR_BRIGHTNESS)
hs_color = kwargs.get(ATTR_HS_COLOR)
- color_temp_mired = kwargs.get(ATTR_COLOR_TEMP)
+ color_temp_kelvin = kwargs.get(ATTR_COLOR_TEMP_KELVIN)
effect = kwargs.get(ATTR_EFFECT)
transition = kwargs.get(ATTR_TRANSITION)
@@ -120,10 +113,8 @@ class NanoleafLight(NanoleafEntity, LightEntity):
hue, saturation = hs_color
await self._nanoleaf.set_hue(int(hue))
await self._nanoleaf.set_saturation(int(saturation))
- elif color_temp_mired:
- await self._nanoleaf.set_color_temperature(
- mired_to_kelvin(color_temp_mired)
- )
+ elif color_temp_kelvin:
+ await self._nanoleaf.set_color_temperature(color_temp_kelvin)
if transition:
if brightness: # tune to the required brightness in n seconds
await self._nanoleaf.set_brightness(
diff --git a/homeassistant/components/nasweb/manifest.json b/homeassistant/components/nasweb/manifest.json
index e7e06419dad..8a4ecdbee84 100644
--- a/homeassistant/components/nasweb/manifest.json
+++ b/homeassistant/components/nasweb/manifest.json
@@ -5,10 +5,7 @@
"config_flow": true,
"dependencies": ["webhook"],
"documentation": "https://www.home-assistant.io/integrations/nasweb",
- "homekit": {},
"integration_type": "hub",
"iot_class": "local_push",
- "requirements": ["webio-api==0.1.8"],
- "ssdp": [],
- "zeroconf": []
+ "requirements": ["webio-api==0.1.11"]
}
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/neato/manifest.json b/homeassistant/components/neato/manifest.json
index d6eff486b05..e4b471cb5ac 100644
--- a/homeassistant/components/neato/manifest.json
+++ b/homeassistant/components/neato/manifest.json
@@ -1,7 +1,7 @@
{
"domain": "neato",
"name": "Neato Botvac",
- "codeowners": ["@Santobert"],
+ "codeowners": [],
"config_flow": true,
"dependencies": ["application_credentials"],
"documentation": "https://www.home-assistant.io/integrations/neato",
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/neato/vacuum.py b/homeassistant/components/neato/vacuum.py
index 77ca5346b10..1a9285964a2 100644
--- a/homeassistant/components/neato/vacuum.py
+++ b/homeassistant/components/neato/vacuum.py
@@ -12,15 +12,12 @@ import voluptuous as vol
from homeassistant.components.vacuum import (
ATTR_STATUS,
- STATE_CLEANING,
- STATE_DOCKED,
- STATE_ERROR,
- STATE_RETURNING,
StateVacuumEntity,
+ VacuumActivity,
VacuumEntityFeature,
)
from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import ATTR_MODE, STATE_IDLE, STATE_PAUSED
+from homeassistant.const import ATTR_MODE
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv, entity_platform
from homeassistant.helpers.device_registry import DeviceInfo
@@ -169,23 +166,23 @@ class NeatoConnectedVacuum(NeatoEntity, StateVacuumEntity):
robot_alert = None
if self._state["state"] == 1:
if self._state["details"]["isCharging"]:
- self._attr_state = STATE_DOCKED
+ self._attr_activity = VacuumActivity.DOCKED
self._status_state = "Charging"
elif (
self._state["details"]["isDocked"]
and not self._state["details"]["isCharging"]
):
- self._attr_state = STATE_DOCKED
+ self._attr_activity = VacuumActivity.DOCKED
self._status_state = "Docked"
else:
- self._attr_state = STATE_IDLE
+ self._attr_activity = VacuumActivity.IDLE
self._status_state = "Stopped"
if robot_alert is not None:
self._status_state = robot_alert
elif self._state["state"] == 2:
if robot_alert is None:
- self._attr_state = STATE_CLEANING
+ self._attr_activity = VacuumActivity.CLEANING
self._status_state = (
f"{MODE.get(self._state['cleaning']['mode'])} "
f"{ACTION.get(self._state['action'])}"
@@ -200,10 +197,10 @@ class NeatoConnectedVacuum(NeatoEntity, StateVacuumEntity):
else:
self._status_state = robot_alert
elif self._state["state"] == 3:
- self._attr_state = STATE_PAUSED
+ self._attr_activity = VacuumActivity.PAUSED
self._status_state = "Paused"
elif self._state["state"] == 4:
- self._attr_state = STATE_ERROR
+ self._attr_activity = VacuumActivity.ERROR
self._status_state = ERRORS.get(self._state["error"])
self._attr_battery_level = self._state["details"]["charge"]
@@ -326,9 +323,9 @@ class NeatoConnectedVacuum(NeatoEntity, StateVacuumEntity):
def return_to_base(self, **kwargs: Any) -> None:
"""Set the vacuum cleaner to return to the dock."""
try:
- if self._attr_state == STATE_CLEANING:
+ if self._attr_activity == VacuumActivity.CLEANING:
self.robot.pause_cleaning()
- self._attr_state = STATE_RETURNING
+ self._attr_activity = VacuumActivity.RETURNING
self.robot.send_to_base()
except NeatoRobotException as ex:
_LOGGER.error(
@@ -380,7 +377,7 @@ class NeatoConnectedVacuum(NeatoEntity, StateVacuumEntity):
"Start cleaning zone '%s' with robot %s", zone, self.entity_id
)
- self._attr_state = STATE_CLEANING
+ self._attr_activity = VacuumActivity.CLEANING
try:
self.robot.start_cleaning(mode, navigation, category, boundary_id)
except NeatoRobotException as ex:
diff --git a/homeassistant/components/nederlandse_spoorwegen/manifest.json b/homeassistant/components/nederlandse_spoorwegen/manifest.json
index aa8d0f4adf4..0ef9d8d86f3 100644
--- a/homeassistant/components/nederlandse_spoorwegen/manifest.json
+++ b/homeassistant/components/nederlandse_spoorwegen/manifest.json
@@ -4,5 +4,6 @@
"codeowners": ["@YarmoM"],
"documentation": "https://www.home-assistant.io/integrations/nederlandse_spoorwegen",
"iot_class": "cloud_polling",
- "requirements": ["nsapi==3.0.5"]
+ "quality_scale": "legacy",
+ "requirements": ["nsapi==3.1.2"]
}
diff --git a/homeassistant/components/ness_alarm/manifest.json b/homeassistant/components/ness_alarm/manifest.json
index c3bb4239048..3d97e3290e0 100644
--- a/homeassistant/components/ness_alarm/manifest.json
+++ b/homeassistant/components/ness_alarm/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/ness_alarm",
"iot_class": "local_push",
"loggers": ["nessclient"],
+ "quality_scale": "legacy",
"requirements": ["nessclient==1.1.2"]
}
diff --git a/homeassistant/components/nest/__init__.py b/homeassistant/components/nest/__init__.py
index 6b094c68cb0..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,
@@ -49,20 +48,18 @@ from homeassistant.helpers import (
config_validation as cv,
device_registry as dr,
entity_registry as er,
- issue_registry as ir,
)
from homeassistant.helpers.entity_registry import async_entries_for_device
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,
CONF_SUBSCRIPTION_NAME,
- DATA_DEVICE_MANAGER,
DATA_SDM,
- DATA_SUBSCRIBER,
DOMAIN,
)
from .events import EVENT_NAME_MAP, NEST_EVENT
@@ -73,6 +70,7 @@ from .media_source import (
async_get_media_source_devices,
async_get_transcoder,
)
+from .types import NestConfigEntry, NestData
_LOGGER = logging.getLogger(__name__)
@@ -114,25 +112,8 @@ THUMBNAIL_SIZE_PX = 175
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up Nest components with dispatch between old/new flows."""
- hass.data[DOMAIN] = {}
-
hass.http.register_view(NestEventMediaView(hass))
hass.http.register_view(NestEventMediaThumbnailView(hass))
-
- if DOMAIN in config and CONF_PROJECT_ID not in config[DOMAIN]:
- ir.async_create_issue(
- hass,
- DOMAIN,
- "legacy_nest_deprecated",
- breaks_in_ha_version="2023.8.0",
- is_fixable=False,
- severity=ir.IssueSeverity.WARNING,
- translation_key="legacy_nest_removed",
- translation_placeholders={
- "documentation_url": "https://www.home-assistant.io/integrations/nest/",
- },
- )
- return False
return True
@@ -143,12 +124,12 @@ class SignalUpdateCallback:
self,
hass: HomeAssistant,
config_reload_cb: Callable[[], Awaitable[None]],
- config_entry_id: str,
+ config_entry: NestConfigEntry,
) -> None:
"""Initialize EventCallback."""
self._hass = hass
self._config_reload_cb = config_reload_cb
- self._config_entry_id = config_entry_id
+ self._config_entry = config_entry
async def async_handle_event(self, event_message: EventMessage) -> None:
"""Process an incoming EventMessage."""
@@ -196,17 +177,17 @@ class SignalUpdateCallback:
message["zones"] = image_event.zones
self._hass.bus.async_fire(NEST_EVENT, message)
- def _supported_traits(self, device_id: str) -> list[TraitType]:
- if not (
- device_manager := self._hass.data[DOMAIN]
- .get(self._config_entry_id, {})
- .get(DATA_DEVICE_MANAGER)
- ) or not (device := device_manager.devices.get(device_id)):
+ def _supported_traits(self, device_id: str) -> list[str]:
+ if (
+ not self._config_entry.runtime_data
+ or not (device_manager := self._config_entry.runtime_data.device_manager)
+ or not (device := device_manager.devices.get(device_id))
+ ):
return []
return list(device.traits)
-async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_setup_entry(hass: HomeAssistant, entry: NestConfigEntry) -> bool:
"""Set up Nest from a config entry with dispatch between old/new flows."""
if DATA_SDM not in entry.data:
hass.async_create_task(hass.config_entries.async_remove(entry.entry_id))
@@ -230,63 +211,52 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_config_reload() -> None:
await hass.config_entries.async_reload(entry.entry_id)
- update_callback = SignalUpdateCallback(hass, async_config_reload, entry.entry_id)
+ 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)
)
- hass.data[DOMAIN][entry.entry_id] = {
- DATA_SUBSCRIBER: subscriber,
- DATA_DEVICE_MANAGER: device_manager,
- }
+ entry.async_on_unload(unsub)
+ entry.runtime_data = NestData(
+ subscriber=subscriber,
+ device_manager=device_manager,
+ )
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: NestConfigEntry) -> bool:
"""Unload a config entry."""
- if DATA_SDM not in entry.data:
- # Legacy API
- return True
- _LOGGER.debug("Stopping nest subscriber")
- subscriber = hass.data[DOMAIN][entry.entry_id][DATA_SUBSCRIBER]
- subscriber.stop_async()
- 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)
-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
@@ -296,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/camera.py b/homeassistant/components/nest/camera.py
index 2bee54df3dd..df02f17444f 100644
--- a/homeassistant/components/nest/camera.py
+++ b/homeassistant/components/nest/camera.py
@@ -2,9 +2,9 @@
from __future__ import annotations
-from abc import ABC, abstractmethod
+from abc import ABC
import asyncio
-from collections.abc import Callable
+from collections.abc import Awaitable, Callable
import datetime
import functools
import logging
@@ -17,27 +17,25 @@ from google_nest_sdm.camera_traits import (
WebRtcStream,
)
from google_nest_sdm.device import Device
-from google_nest_sdm.device_manager import DeviceManager
from google_nest_sdm.exceptions import ApiException
+from webrtc_models import RTCIceCandidateInit
from homeassistant.components.camera import (
Camera,
CameraEntityFeature,
- StreamType,
WebRTCAnswer,
WebRTCClientConfiguration,
WebRTCSendMessage,
)
from homeassistant.components.stream import CONF_EXTRA_PART_WAIT_TIME
-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 homeassistant.helpers.event import async_track_point_in_utc_time
from homeassistant.util.dt import utcnow
-from .const import DATA_DEVICE_MANAGER, DOMAIN
from .device_info import NestDeviceInfo
+from .types import NestConfigEntry
_LOGGER = logging.getLogger(__name__)
@@ -46,17 +44,19 @@ PLACEHOLDER = Path(__file__).parent / "placeholder.png"
# Used to schedule an alarm to refresh the stream before expiration
STREAM_EXPIRATION_BUFFER = datetime.timedelta(seconds=30)
+# Refresh streams with a bounded interval and backoff on failure
+MIN_REFRESH_BACKOFF_INTERVAL = datetime.timedelta(minutes=1)
+MAX_REFRESH_BACKOFF_INTERVAL = datetime.timedelta(minutes=10)
+BACKOFF_MULTIPLIER = 1.5
+
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant, entry: NestConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Set up the cameras."""
- device_manager: DeviceManager = hass.data[DOMAIN][entry.entry_id][
- DATA_DEVICE_MANAGER
- ]
entities: list[NestCameraBaseEntity] = []
- for device in device_manager.devices.values():
+ for device in entry.runtime_data.device_manager.devices.values():
if (live_stream := device.traits.get(CameraLiveStreamTrait.NAME)) is None:
continue
if StreamingProtocol.WEB_RTC in live_stream.supported_protocols:
@@ -67,6 +67,68 @@ async def async_setup_entry(
async_add_entities(entities)
+class StreamRefresh:
+ """Class that will refresh an expiring stream.
+
+ This class will schedule an alarm for the next expiration time of a stream.
+ When the alarm fires, it runs the provided `refresh_cb` to extend the
+ lifetime of the stream and return a new expiration time.
+
+ A simple backoff will be applied when the refresh callback fails.
+ """
+
+ def __init__(
+ self,
+ hass: HomeAssistant,
+ expires_at: datetime.datetime,
+ refresh_cb: Callable[[], Awaitable[datetime.datetime | None]],
+ ) -> None:
+ """Initialize StreamRefresh."""
+ self._hass = hass
+ self._unsub: Callable[[], None] | None = None
+ self._min_refresh_interval = MIN_REFRESH_BACKOFF_INTERVAL
+ self._refresh_cb = refresh_cb
+ self._schedule_stream_refresh(expires_at - STREAM_EXPIRATION_BUFFER)
+
+ def unsub(self) -> None:
+ """Invalidates the stream."""
+ if self._unsub:
+ self._unsub()
+
+ async def _handle_refresh(self, _: datetime.datetime) -> None:
+ """Alarm that fires to check if the stream should be refreshed."""
+ self._unsub = None
+ try:
+ expires_at = await self._refresh_cb()
+ except ApiException as err:
+ _LOGGER.debug("Failed to refresh stream: %s", err)
+ # Increase backoff until the max backoff interval is reached
+ self._min_refresh_interval = min(
+ self._min_refresh_interval * BACKOFF_MULTIPLIER,
+ MAX_REFRESH_BACKOFF_INTERVAL,
+ )
+ refresh_time = utcnow() + self._min_refresh_interval
+ else:
+ if expires_at is None:
+ return
+ self._min_refresh_interval = MIN_REFRESH_BACKOFF_INTERVAL # Reset backoff
+ # Defend against invalid stream expiration time in the past
+ refresh_time = max(
+ expires_at - STREAM_EXPIRATION_BUFFER,
+ utcnow() + self._min_refresh_interval,
+ )
+ self._schedule_stream_refresh(refresh_time)
+
+ def _schedule_stream_refresh(self, refresh_time: datetime.datetime) -> None:
+ """Schedules an alarm to refresh any streams before expiration."""
+ _LOGGER.debug("Scheduling stream refresh for %s", refresh_time)
+ self._unsub = async_track_point_in_utc_time(
+ self._hass,
+ self._handle_refresh,
+ refresh_time,
+ )
+
+
class NestCameraBaseEntity(Camera, ABC):
"""Devices that support cameras."""
@@ -86,41 +148,6 @@ class NestCameraBaseEntity(Camera, ABC):
self.stream_options[CONF_EXTRA_PART_WAIT_TIME] = 3
# The API "name" field is a unique device identifier.
self._attr_unique_id = f"{self._device.name}-camera"
- self._stream_refresh_unsub: Callable[[], None] | None = None
-
- @abstractmethod
- def _stream_expires_at(self) -> datetime.datetime | None:
- """Next time when a stream expires."""
-
- @abstractmethod
- async def _async_refresh_stream(self) -> None:
- """Refresh any stream to extend expiration time."""
-
- def _schedule_stream_refresh(self) -> None:
- """Schedules an alarm to refresh any streams before expiration."""
- if self._stream_refresh_unsub is not None:
- self._stream_refresh_unsub()
-
- expiration_time = self._stream_expires_at()
- if not expiration_time:
- return
- refresh_time = expiration_time - STREAM_EXPIRATION_BUFFER
- _LOGGER.debug("Scheduled next stream refresh for %s", refresh_time)
-
- self._stream_refresh_unsub = async_track_point_in_utc_time(
- self.hass,
- self._handle_stream_refresh,
- refresh_time,
- )
-
- async def _handle_stream_refresh(self, _: datetime.datetime) -> None:
- """Alarm that fires to check if the stream should be refreshed."""
- _LOGGER.debug("Examining streams to refresh")
- self._stream_refresh_unsub = None
- try:
- await self._async_refresh_stream()
- finally:
- self._schedule_stream_refresh()
async def async_added_to_hass(self) -> None:
"""Run when entity is added to register update signal handler."""
@@ -128,12 +155,6 @@ class NestCameraBaseEntity(Camera, ABC):
self._device.add_update_listener(self.async_write_ha_state)
)
- async def async_will_remove_from_hass(self) -> None:
- """Invalidates the RTSP token when unloaded."""
- await super().async_will_remove_from_hass()
- if self._stream_refresh_unsub:
- self._stream_refresh_unsub()
-
class NestRTSPEntity(NestCameraBaseEntity):
"""Nest cameras that use RTSP."""
@@ -146,6 +167,7 @@ class NestRTSPEntity(NestCameraBaseEntity):
super().__init__(device)
self._create_stream_url_lock = asyncio.Lock()
self._rtsp_live_stream_trait = device.traits[CameraLiveStreamTrait.NAME]
+ self._refresh_unsub: Callable[[], None] | None = None
@property
def use_stream_for_stills(self) -> bool:
@@ -173,20 +195,21 @@ class NestRTSPEntity(NestCameraBaseEntity):
)
except ApiException as err:
raise HomeAssistantError(f"Nest API error: {err}") from err
- self._schedule_stream_refresh()
+ refresh = StreamRefresh(
+ self.hass,
+ self._rtsp_stream.expires_at,
+ self._async_refresh_stream,
+ )
+ self._refresh_unsub = refresh.unsub
assert self._rtsp_stream
if self._rtsp_stream.expires_at < utcnow():
_LOGGER.warning("Stream already expired")
return self._rtsp_stream.rtsp_stream_url
- def _stream_expires_at(self) -> datetime.datetime | None:
- """Next time when a stream expires."""
- return self._rtsp_stream.expires_at if self._rtsp_stream else None
-
- async def _async_refresh_stream(self) -> None:
+ async def _async_refresh_stream(self) -> datetime.datetime | None:
"""Refresh stream to extend expiration time."""
if not self._rtsp_stream:
- return
+ return None
_LOGGER.debug("Extending RTSP stream")
try:
self._rtsp_stream = await self._rtsp_stream.extend_rtsp_stream()
@@ -197,14 +220,17 @@ class NestRTSPEntity(NestCameraBaseEntity):
if self.stream:
await self.stream.stop()
self.stream = None
- return
+ return None
# Update the stream worker with the latest valid url
if self.stream:
self.stream.update_source(self._rtsp_stream.rtsp_stream_url)
+ return self._rtsp_stream.expires_at
async def async_will_remove_from_hass(self) -> None:
"""Invalidates the RTSP token when unloaded."""
await super().async_will_remove_from_hass()
+ if self._refresh_unsub is not None:
+ self._refresh_unsub()
if self._rtsp_stream:
try:
await self._rtsp_stream.stop_stream()
@@ -220,37 +246,18 @@ class NestWebRTCEntity(NestCameraBaseEntity):
"""Initialize the camera."""
super().__init__(device)
self._webrtc_sessions: dict[str, WebRtcStream] = {}
+ self._refresh_unsub: dict[str, Callable[[], None]] = {}
- @property
- def frontend_stream_type(self) -> StreamType | None:
- """Return the type of stream supported by this camera."""
- return StreamType.WEB_RTC
-
- def _stream_expires_at(self) -> datetime.datetime | None:
- """Next time when a stream expires."""
- if not self._webrtc_sessions:
- return None
- return min(stream.expires_at for stream in self._webrtc_sessions.values())
-
- async def _async_refresh_stream(self) -> None:
+ async def _async_refresh_stream(self, session_id: str) -> datetime.datetime | None:
"""Refresh stream to extend expiration time."""
- now = utcnow()
- for session_id, webrtc_stream in list(self._webrtc_sessions.items()):
- if session_id not in self._webrtc_sessions:
- continue
- if now < (webrtc_stream.expires_at - STREAM_EXPIRATION_BUFFER):
- _LOGGER.debug(
- "Stream does not yet expire: %s", webrtc_stream.expires_at
- )
- continue
- _LOGGER.debug("Extending WebRTC stream %s", webrtc_stream.media_session_id)
- try:
- webrtc_stream = await webrtc_stream.extend_stream()
- except ApiException as err:
- _LOGGER.debug("Failed to extend stream: %s", err)
- else:
- if session_id in self._webrtc_sessions:
- self._webrtc_sessions[session_id] = webrtc_stream
+ if not (webrtc_stream := self._webrtc_sessions.get(session_id)):
+ return None
+ _LOGGER.debug("Extending WebRTC stream %s", webrtc_stream.media_session_id)
+ webrtc_stream = await webrtc_stream.extend_stream()
+ if session_id in self._webrtc_sessions:
+ self._webrtc_sessions[session_id] = webrtc_stream
+ return webrtc_stream.expires_at
+ return None
async def async_camera_image(
self, width: int | None = None, height: int | None = None
@@ -278,7 +285,18 @@ class NestWebRTCEntity(NestCameraBaseEntity):
)
self._webrtc_sessions[session_id] = stream
send_message(WebRTCAnswer(stream.answer_sdp))
- self._schedule_stream_refresh()
+ refresh = StreamRefresh(
+ self.hass,
+ stream.expires_at,
+ functools.partial(self._async_refresh_stream, session_id),
+ )
+ self._refresh_unsub[session_id] = refresh.unsub
+
+ async def async_on_webrtc_candidate(
+ self, session_id: str, candidate: RTCIceCandidateInit
+ ) -> None:
+ """Ignore WebRTC candidates for Nest cloud based cameras."""
+ return
@callback
def close_webrtc_session(self, session_id: str) -> None:
@@ -287,6 +305,8 @@ class NestWebRTCEntity(NestCameraBaseEntity):
_LOGGER.debug(
"Closing WebRTC session %s, %s", session_id, stream.media_session_id
)
+ unsub = self._refresh_unsub.pop(session_id)
+ unsub()
async def stop_stream() -> None:
try:
diff --git a/homeassistant/components/nest/climate.py b/homeassistant/components/nest/climate.py
index 03fb641d0e5..3193d592120 100644
--- a/homeassistant/components/nest/climate.py
+++ b/homeassistant/components/nest/climate.py
@@ -5,8 +5,7 @@ from __future__ import annotations
from typing import Any, cast
from google_nest_sdm.device import Device
-from google_nest_sdm.device_manager import DeviceManager
-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,
@@ -28,14 +27,13 @@ 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.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from .const import DATA_DEVICE_MANAGER, DOMAIN
from .device_info import NestDeviceInfo
+from .types import NestConfigEntry
# Mapping for sdm.devices.traits.ThermostatMode mode field
THERMOSTAT_MODE_MAP: dict[str, HVACMode] = {
@@ -78,17 +76,13 @@ MIN_TEMP_RANGE = 1.66667
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant, entry: NestConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Set up the client entities."""
- device_manager: DeviceManager = hass.data[DOMAIN][entry.entry_id][
- DATA_DEVICE_MANAGER
- ]
-
async_add_entities(
ThermostatEntity(device)
- for device in device_manager.devices.values()
+ for device in entry.runtime_data.device_manager.devices.values()
if ThermostatHvacTrait.NAME in device.traits
)
@@ -101,7 +95,6 @@ class ThermostatEntity(ClimateEntity):
_attr_has_entity_name = True
_attr_should_poll = False
_attr_name = None
- _enable_turn_on_off_backwards_compatibility = False
def __init__(self, device: Device) -> None:
"""Initialize ThermostatEntity."""
@@ -140,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/const.py b/homeassistant/components/nest/const.py
index 0a828dcbf78..9950d1d5c2a 100644
--- a/homeassistant/components/nest/const.py
+++ b/homeassistant/components/nest/const.py
@@ -2,8 +2,6 @@
DOMAIN = "nest"
DATA_SDM = "sdm"
-DATA_SUBSCRIBER = "subscriber"
-DATA_DEVICE_MANAGER = "device_manager"
WEB_AUTH_DOMAIN = DOMAIN
INSTALLED_AUTH_DOMAIN = f"{DOMAIN}.installed"
diff --git a/homeassistant/components/nest/device_info.py b/homeassistant/components/nest/device_info.py
index 33793fe836b..facd429b139 100644
--- a/homeassistant/components/nest/device_info.py
+++ b/homeassistant/components/nest/device_info.py
@@ -7,11 +7,12 @@ from collections.abc import Mapping
from google_nest_sdm.device import Device
from google_nest_sdm.device_traits import ConnectivityTrait, InfoTrait
+from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
-from .const import CONNECTIVITY_TRAIT_OFFLINE, DATA_DEVICE_MANAGER, DOMAIN
+from .const import CONNECTIVITY_TRAIT_OFFLINE, DOMAIN
DEVICE_TYPE_MAP: dict[str, str] = {
"sdm.devices.types.CAMERA": "Camera",
@@ -81,14 +82,12 @@ class NestDeviceInfo:
@callback
def async_nest_devices(hass: HomeAssistant) -> Mapping[str, Device]:
"""Return a mapping of all nest devices for all config entries."""
- devices = {}
- for entry_id in hass.data[DOMAIN]:
- if not (device_manager := hass.data[DOMAIN][entry_id].get(DATA_DEVICE_MANAGER)):
- continue
- devices.update(
- {device.name: device for device in device_manager.devices.values()}
- )
- return devices
+ return {
+ device.name: device
+ for config_entry in hass.config_entries.async_entries(DOMAIN)
+ if config_entry.state == ConfigEntryState.LOADED
+ for device in config_entry.runtime_data.device_manager.devices.values()
+ }
@callback
diff --git a/homeassistant/components/nest/diagnostics.py b/homeassistant/components/nest/diagnostics.py
index 57ce4291cc6..345e15b0593 100644
--- a/homeassistant/components/nest/diagnostics.py
+++ b/homeassistant/components/nest/diagnostics.py
@@ -5,46 +5,26 @@ from __future__ import annotations
from typing import Any
from google_nest_sdm import diagnostics
-from google_nest_sdm.device import Device
-from google_nest_sdm.device_manager import DeviceManager
from google_nest_sdm.device_traits import InfoTrait
from homeassistant.components.camera import diagnostics as camera_diagnostics
-from homeassistant.config_entries import ConfigEntry
-from homeassistant.core import HomeAssistant, callback
+from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntry
-from .const import DATA_DEVICE_MANAGER, DATA_SDM, DOMAIN
+from .types import NestConfigEntry
REDACT_DEVICE_TRAITS = {InfoTrait.NAME}
-@callback
-def _async_get_nest_devices(
- hass: HomeAssistant, config_entry: ConfigEntry
-) -> dict[str, Device]:
- """Return dict of available devices."""
- if DATA_SDM not in config_entry.data:
- return {}
-
- if (
- config_entry.entry_id not in hass.data[DOMAIN]
- or DATA_DEVICE_MANAGER not in hass.data[DOMAIN][config_entry.entry_id]
- ):
- return {}
-
- device_manager: DeviceManager = hass.data[DOMAIN][config_entry.entry_id][
- DATA_DEVICE_MANAGER
- ]
- return device_manager.devices
-
-
async def async_get_config_entry_diagnostics(
- hass: HomeAssistant, config_entry: ConfigEntry
+ hass: HomeAssistant, config_entry: NestConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
- nest_devices = _async_get_nest_devices(hass, config_entry)
- if not nest_devices:
+ if (
+ not hasattr(config_entry, "runtime_data")
+ or not config_entry.runtime_data
+ or not (nest_devices := config_entry.runtime_data.device_manager.devices)
+ ):
return {}
data: dict[str, Any] = {
**diagnostics.get_diagnostics(),
@@ -62,11 +42,11 @@ async def async_get_config_entry_diagnostics(
async def async_get_device_diagnostics(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ config_entry: NestConfigEntry,
device: DeviceEntry,
) -> dict[str, Any]:
"""Return diagnostics for a device."""
- nest_devices = _async_get_nest_devices(hass, config_entry)
+ nest_devices = config_entry.runtime_data.device_manager.devices
nest_device_id = next(iter(device.identifiers))[1]
nest_device = nest_devices.get(nest_device_id)
return nest_device.get_diagnostics() if nest_device else {}
diff --git a/homeassistant/components/nest/event.py b/homeassistant/components/nest/event.py
index a6d70fe86d5..1a2c0317496 100644
--- a/homeassistant/components/nest/event.py
+++ b/homeassistant/components/nest/event.py
@@ -4,7 +4,6 @@ from dataclasses import dataclass
import logging
from google_nest_sdm.device import Device
-from google_nest_sdm.device_manager import DeviceManager
from google_nest_sdm.event import EventMessage, EventType
from google_nest_sdm.traits import TraitType
@@ -13,11 +12,9 @@ from homeassistant.components.event import (
EventEntity,
EventEntityDescription,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from .const import DATA_DEVICE_MANAGER, DOMAIN
from .device_info import NestDeviceInfo
from .events import (
EVENT_CAMERA_MOTION,
@@ -26,6 +23,7 @@ from .events import (
EVENT_DOORBELL_CHIME,
EVENT_NAME_MAP,
)
+from .types import NestConfigEntry
_LOGGER = logging.getLogger(__name__)
@@ -68,16 +66,12 @@ ENTITY_DESCRIPTIONS = [
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant, entry: NestConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Set up the sensors."""
-
- device_manager: DeviceManager = hass.data[DOMAIN][entry.entry_id][
- DATA_DEVICE_MANAGER
- ]
async_add_entities(
NestTraitEventEntity(desc, device)
- for device in device_manager.devices.values()
+ for device in entry.runtime_data.device_manager.devices.values()
for desc in ENTITY_DESCRIPTIONS
if any(trait in device.traits for trait in desc.trait_types)
)
diff --git a/homeassistant/components/nest/manifest.json b/homeassistant/components/nest/manifest.json
index 581113f0c96..e14474dc309 100644
--- a/homeassistant/components/nest/manifest.json
+++ b/homeassistant/components/nest/manifest.json
@@ -19,6 +19,5 @@
"documentation": "https://www.home-assistant.io/integrations/nest",
"iot_class": "cloud_push",
"loggers": ["google_nest_sdm"],
- "quality_scale": "platinum",
- "requirements": ["google-nest-sdm==6.1.4"]
+ "requirements": ["google-nest-sdm==7.0.0"]
}
diff --git a/homeassistant/components/nest/quality_scale.yaml b/homeassistant/components/nest/quality_scale.yaml
new file mode 100644
index 00000000000..969ee66059d
--- /dev/null
+++ b/homeassistant/components/nest/quality_scale.yaml
@@ -0,0 +1,86 @@
+rules:
+ # Bronze
+ config-flow:
+ status: todo
+ comment: Some fields are missing a data_description
+ brands: done
+ dependency-transparency: done
+ common-modules:
+ status: exempt
+ comment: The integration does not have a base entity or coordinator.
+ has-entity-name: done
+ action-setup:
+ status: exempt
+ comment: The integration does not register actions.
+ appropriate-polling:
+ status: exempt
+ comment: The integration does not poll.
+ test-before-configure:
+ status: todo
+ comment: |
+ The integration does a connection test in the configuration flow, however
+ it does not fail if the user has ipv6 misconfigured.
+ entity-event-setup: done
+ unique-config-entry: done
+ entity-unique-id: done
+ docs-installation-instructions: done
+ docs-removal-instructions: todo
+ test-before-setup:
+ status: todo
+ comment: |
+ The integration does tests on setup, however the most common issues
+ observed are related to ipv6 misconfigurations and the error messages
+ are not self explanatory and can be improved.
+ docs-high-level-description: done
+ config-flow-test-coverage:
+ status: todo
+ comment: |
+ The integration has full test coverage however it does not yet assert the specific contents of the
+ unique id of the created entry. Additional tests coverage for combinations of features like
+ `test_dhcp_discovery_with_creds` would also be useful.
+ Tests can be improved so that all end in either CREATE_ENTRY or ABORT.
+ docs-actions: done
+ runtime-data: done
+
+ # Silver
+ log-when-unavailable: todo
+ config-entry-unloading: todo
+ reauthentication-flow:
+ status: todo
+ comment: |
+ Supports reauthentication, however can be improved to ensure the user does not change accounts
+ action-exceptions: todo
+ docs-installation-parameters: todo
+ integration-owner: todo
+ parallel-updates: todo
+ test-coverage: todo
+ docs-configuration-parameters: todo
+ entity-unavailable: todo
+
+ # Gold
+ docs-examples: todo
+ discovery-update-info: todo
+ entity-device-class: todo
+ entity-translations: todo
+ docs-data-update: todo
+ entity-disabled-by-default: todo
+ discovery: todo
+ exception-translations: todo
+ devices: todo
+ docs-supported-devices: todo
+ icon-translations: todo
+ docs-known-limitations: todo
+ stale-devices: todo
+ docs-supported-functions: todo
+ repair-issues: todo
+ reconfiguration-flow: todo
+ entity-category: todo
+ dynamic-devices: todo
+ docs-troubleshooting: todo
+ diagnostics: todo
+ docs-use-cases: todo
+
+ # Platinum
+ async-dependency: todo
+ strict-typing: todo
+ inject-websession: todo
diff --git a/homeassistant/components/nest/sensor.py b/homeassistant/components/nest/sensor.py
index edd359619fd..02a0e305813 100644
--- a/homeassistant/components/nest/sensor.py
+++ b/homeassistant/components/nest/sensor.py
@@ -5,7 +5,6 @@ from __future__ import annotations
import logging
from google_nest_sdm.device import Device
-from google_nest_sdm.device_manager import DeviceManager
from google_nest_sdm.device_traits import HumidityTrait, TemperatureTrait
from homeassistant.components.sensor import (
@@ -13,13 +12,12 @@ from homeassistant.components.sensor import (
SensorEntity,
SensorStateClass,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import PERCENTAGE, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from .const import DATA_DEVICE_MANAGER, DOMAIN
from .device_info import NestDeviceInfo
+from .types import NestConfigEntry
_LOGGER = logging.getLogger(__name__)
@@ -33,15 +31,12 @@ DEVICE_TYPE_MAP = {
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant, entry: NestConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Set up the sensors."""
- device_manager: DeviceManager = hass.data[DOMAIN][entry.entry_id][
- DATA_DEVICE_MANAGER
- ]
entities: list[SensorEntity] = []
- for device in device_manager.devices.values():
+ for device in entry.runtime_data.device_manager.devices.values():
if TemperatureTrait.NAME in device.traits:
entities.append(TemperatureSensor(device))
if HumidityTrait.NAME in device.traits:
diff --git a/homeassistant/components/nest/strings.json b/homeassistant/components/nest/strings.json
index f6a64dd66e6..a31a2856544 100644
--- a/homeassistant/components/nest/strings.json
+++ b/homeassistant/components/nest/strings.json
@@ -84,12 +84,6 @@
"doorbell_chime": "Doorbell pressed"
}
},
- "issues": {
- "legacy_nest_removed": {
- "title": "Legacy Works With Nest has been removed",
- "description": "Legacy Works With Nest has been removed from Home Assistant, and the API shuts down as of September 2023.\n\nYou must take action to use the SDM API. Remove all `nest` configuration from `configuration.yaml` and restart Home Assistant, then see the Nest [integration instructions]({documentation_url}) for set up instructions and supported devices."
- }
- },
"entity": {
"event": {
"chime": {
diff --git a/homeassistant/components/nest/types.py b/homeassistant/components/nest/types.py
new file mode 100644
index 00000000000..bd6cd5cd887
--- /dev/null
+++ b/homeassistant/components/nest/types.py
@@ -0,0 +1,19 @@
+"""Type definitions for Nest."""
+
+from dataclasses import dataclass
+
+from google_nest_sdm.device_manager import DeviceManager
+from google_nest_sdm.google_nest_subscriber import GoogleNestSubscriber
+
+from homeassistant.config_entries import ConfigEntry
+
+
+@dataclass
+class NestData:
+ """Data for the Nest integration."""
+
+ subscriber: GoogleNestSubscriber
+ device_manager: DeviceManager
+
+
+type NestConfigEntry = ConfigEntry[NestData]
diff --git a/homeassistant/components/netatmo/climate.py b/homeassistant/components/netatmo/climate.py
index 752dee5a952..02c955beac3 100644
--- a/homeassistant/components/netatmo/climate.py
+++ b/homeassistant/components/netatmo/climate.py
@@ -192,7 +192,6 @@ class NetatmoThermostat(NetatmoRoomEntity, ClimateEntity):
_attr_name = None
_away: bool | None = None
_connected: bool | None = None
- _enable_turn_on_off_backwards_compatibility = False
_away_temperature: float | None = None
_hg_temperature: float | None = None
diff --git a/homeassistant/components/netatmo/fan.py b/homeassistant/components/netatmo/fan.py
index 8610882a453..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(
@@ -51,7 +51,6 @@ class NetatmoFan(NetatmoModuleEntity, FanEntity):
_attr_configuration_url = CONF_URL_CONTROL
_attr_name = None
device: NaModules.Fan
- _enable_turn_on_off_backwards_compatibility = False
def __init__(self, netatmo_device: NetatmoDevice) -> None:
"""Initialize of Netatmo fan."""
diff --git a/homeassistant/components/netdata/manifest.json b/homeassistant/components/netdata/manifest.json
index 99410ce033d..8901a271de2 100644
--- a/homeassistant/components/netdata/manifest.json
+++ b/homeassistant/components/netdata/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/netdata",
"iot_class": "local_polling",
"loggers": ["netdata"],
- "requirements": ["netdata==1.1.0"]
+ "quality_scale": "legacy",
+ "requirements": ["netdata==1.3.0"]
}
diff --git a/homeassistant/components/netdata/sensor.py b/homeassistant/components/netdata/sensor.py
index b77a4392ef4..f33349c56ce 100644
--- a/homeassistant/components/netdata/sensor.py
+++ b/homeassistant/components/netdata/sensor.py
@@ -24,6 +24,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import PlatformNotReady
import homeassistant.helpers.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
_LOGGER = logging.getLogger(__name__)
@@ -70,7 +71,9 @@ async def async_setup_platform(
port = config[CONF_PORT]
resources = config[CONF_RESOURCES]
- netdata = NetdataData(Netdata(host, port=port, timeout=20.0))
+ netdata = NetdataData(
+ Netdata(host, port=port, timeout=20.0, httpx_client=get_async_client(hass))
+ )
await netdata.async_update()
if netdata.api.metrics is None:
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/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/manifest.json b/homeassistant/components/netio/manifest.json
index 683df22e1ff..f2914b17dec 100644
--- a/homeassistant/components/netio/manifest.json
+++ b/homeassistant/components/netio/manifest.json
@@ -5,5 +5,6 @@
"dependencies": ["http"],
"documentation": "https://www.home-assistant.io/integrations/netio",
"iot_class": "local_polling",
+ "quality_scale": "legacy",
"requirements": ["pynetio==0.1.9.1"]
}
diff --git a/homeassistant/components/neurio_energy/manifest.json b/homeassistant/components/neurio_energy/manifest.json
index 467825da012..3a524ac4b5f 100644
--- a/homeassistant/components/neurio_energy/manifest.json
+++ b/homeassistant/components/neurio_energy/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/neurio_energy",
"iot_class": "cloud_polling",
"loggers": ["neurio"],
+ "quality_scale": "legacy",
"requirements": ["neurio==0.3.1"]
}
diff --git a/homeassistant/components/nexia/climate.py b/homeassistant/components/nexia/climate.py
index 9b22607d5a8..becd664756b 100644
--- a/homeassistant/components/nexia/climate.py
+++ b/homeassistant/components/nexia/climate.py
@@ -155,7 +155,6 @@ class NexiaZone(NexiaThermostatZoneEntity, ClimateEntity):
"""Provides Nexia Climate support."""
_attr_name = None
- _enable_turn_on_off_backwards_compatibility = False
def __init__(
self, coordinator: NexiaDataUpdateCoordinator, zone: NexiaThermostatZone
diff --git a/homeassistant/components/nexia/strings.json b/homeassistant/components/nexia/strings.json
index aec145b8806..d88ce0b898d 100644
--- a/homeassistant/components/nexia/strings.json
+++ b/homeassistant/components/nexia/strings.json
@@ -64,7 +64,7 @@
"services": {
"set_aircleaner_mode": {
"name": "Set air cleaner mode",
- "description": "The air cleaner mode.",
+ "description": "Sets the air cleaner mode.",
"fields": {
"aircleaner_mode": {
"name": "Air cleaner mode",
@@ -74,17 +74,17 @@
},
"set_humidify_setpoint": {
"name": "Set humidify set point",
- "description": "The humidification set point.",
+ "description": "Sets the target humidity.",
"fields": {
"humidity": {
- "name": "Humidify",
+ "name": "Humidity",
"description": "The humidification setpoint."
}
}
},
"set_hvac_run_mode": {
"name": "Set hvac run mode",
- "description": "The HVAC run mode.",
+ "description": "Sets the HVAC operation mode.",
"fields": {
"run_mode": {
"name": "Run mode",
diff --git a/homeassistant/components/nextdns/manifest.json b/homeassistant/components/nextdns/manifest.json
index f3ed62a2f0c..d10a1728a94 100644
--- a/homeassistant/components/nextdns/manifest.json
+++ b/homeassistant/components/nextdns/manifest.json
@@ -7,6 +7,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["nextdns"],
- "quality_scale": "platinum",
- "requirements": ["nextdns==3.3.0"]
+ "requirements": ["nextdns==4.0.0"]
}
diff --git a/homeassistant/components/nextdns/sensor.py b/homeassistant/components/nextdns/sensor.py
index b390ac93e06..ef2b5140fa1 100644
--- a/homeassistant/components/nextdns/sensor.py
+++ b/homeassistant/components/nextdns/sensor.py
@@ -54,7 +54,6 @@ SENSORS: tuple[NextDnsSensorEntityDescription, ...] = (
coordinator_type=ATTR_STATUS,
entity_category=EntityCategory.DIAGNOSTIC,
translation_key="all_queries",
- native_unit_of_measurement="queries",
state_class=SensorStateClass.TOTAL,
value=lambda data: data.all_queries,
),
@@ -63,7 +62,6 @@ SENSORS: tuple[NextDnsSensorEntityDescription, ...] = (
coordinator_type=ATTR_STATUS,
entity_category=EntityCategory.DIAGNOSTIC,
translation_key="blocked_queries",
- native_unit_of_measurement="queries",
state_class=SensorStateClass.TOTAL,
value=lambda data: data.blocked_queries,
),
@@ -72,7 +70,6 @@ SENSORS: tuple[NextDnsSensorEntityDescription, ...] = (
coordinator_type=ATTR_STATUS,
entity_category=EntityCategory.DIAGNOSTIC,
translation_key="relayed_queries",
- native_unit_of_measurement="queries",
state_class=SensorStateClass.TOTAL,
value=lambda data: data.relayed_queries,
),
@@ -91,7 +88,6 @@ SENSORS: tuple[NextDnsSensorEntityDescription, ...] = (
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
translation_key="doh_queries",
- native_unit_of_measurement="queries",
state_class=SensorStateClass.TOTAL,
value=lambda data: data.doh_queries,
),
@@ -101,7 +97,6 @@ SENSORS: tuple[NextDnsSensorEntityDescription, ...] = (
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
translation_key="doh3_queries",
- native_unit_of_measurement="queries",
state_class=SensorStateClass.TOTAL,
value=lambda data: data.doh3_queries,
),
@@ -111,7 +106,6 @@ SENSORS: tuple[NextDnsSensorEntityDescription, ...] = (
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
translation_key="dot_queries",
- native_unit_of_measurement="queries",
state_class=SensorStateClass.TOTAL,
value=lambda data: data.dot_queries,
),
@@ -121,7 +115,6 @@ SENSORS: tuple[NextDnsSensorEntityDescription, ...] = (
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
translation_key="doq_queries",
- native_unit_of_measurement="queries",
state_class=SensorStateClass.TOTAL,
value=lambda data: data.doq_queries,
),
@@ -131,7 +124,6 @@ SENSORS: tuple[NextDnsSensorEntityDescription, ...] = (
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
translation_key="tcp_queries",
- native_unit_of_measurement="queries",
state_class=SensorStateClass.TOTAL,
value=lambda data: data.tcp_queries,
),
@@ -141,7 +133,6 @@ SENSORS: tuple[NextDnsSensorEntityDescription, ...] = (
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
translation_key="udp_queries",
- native_unit_of_measurement="queries",
state_class=SensorStateClass.TOTAL,
value=lambda data: data.udp_queries,
),
@@ -211,7 +202,6 @@ SENSORS: tuple[NextDnsSensorEntityDescription, ...] = (
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
translation_key="encrypted_queries",
- native_unit_of_measurement="queries",
state_class=SensorStateClass.TOTAL,
value=lambda data: data.encrypted_queries,
),
@@ -221,7 +211,6 @@ SENSORS: tuple[NextDnsSensorEntityDescription, ...] = (
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
translation_key="unencrypted_queries",
- native_unit_of_measurement="queries",
state_class=SensorStateClass.TOTAL,
value=lambda data: data.unencrypted_queries,
),
@@ -241,7 +230,6 @@ SENSORS: tuple[NextDnsSensorEntityDescription, ...] = (
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
translation_key="ipv4_queries",
- native_unit_of_measurement="queries",
state_class=SensorStateClass.TOTAL,
value=lambda data: data.ipv4_queries,
),
@@ -251,7 +239,6 @@ SENSORS: tuple[NextDnsSensorEntityDescription, ...] = (
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
translation_key="ipv6_queries",
- native_unit_of_measurement="queries",
state_class=SensorStateClass.TOTAL,
value=lambda data: data.ipv6_queries,
),
@@ -271,7 +258,6 @@ SENSORS: tuple[NextDnsSensorEntityDescription, ...] = (
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
translation_key="validated_queries",
- native_unit_of_measurement="queries",
state_class=SensorStateClass.TOTAL,
value=lambda data: data.validated_queries,
),
@@ -281,7 +267,6 @@ SENSORS: tuple[NextDnsSensorEntityDescription, ...] = (
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
translation_key="not_validated_queries",
- native_unit_of_measurement="queries",
state_class=SensorStateClass.TOTAL,
value=lambda data: data.not_validated_queries,
),
diff --git a/homeassistant/components/nextdns/strings.json b/homeassistant/components/nextdns/strings.json
index 9dbc8061849..f2a5fa2816d 100644
--- a/homeassistant/components/nextdns/strings.json
+++ b/homeassistant/components/nextdns/strings.json
@@ -48,76 +48,91 @@
},
"sensor": {
"all_queries": {
- "name": "DNS queries"
+ "name": "DNS queries",
+ "unit_of_measurement": "queries"
},
"blocked_queries": {
- "name": "DNS queries blocked"
+ "name": "DNS queries blocked",
+ "unit_of_measurement": "[%key:component::nextdns::entity::sensor::all_queries::unit_of_measurement%]"
},
"blocked_queries_ratio": {
"name": "DNS queries blocked ratio"
},
"doh3_queries": {
- "name": "DNS-over-HTTP/3 queries"
+ "name": "DNS-over-HTTP/3 queries",
+ "unit_of_measurement": "[%key:component::nextdns::entity::sensor::all_queries::unit_of_measurement%]"
},
"doh3_queries_ratio": {
"name": "DNS-over-HTTP/3 queries ratio"
},
"doh_queries": {
- "name": "DNS-over-HTTPS queries"
+ "name": "DNS-over-HTTPS queries",
+ "unit_of_measurement": "[%key:component::nextdns::entity::sensor::all_queries::unit_of_measurement%]"
},
"doh_queries_ratio": {
"name": "DNS-over-HTTPS queries ratio"
},
"doq_queries": {
- "name": "DNS-over-QUIC queries"
+ "name": "DNS-over-QUIC queries",
+ "unit_of_measurement": "[%key:component::nextdns::entity::sensor::all_queries::unit_of_measurement%]"
},
"doq_queries_ratio": {
"name": "DNS-over-QUIC queries ratio"
},
"dot_queries": {
- "name": "DNS-over-TLS queries"
+ "name": "DNS-over-TLS queries",
+ "unit_of_measurement": "[%key:component::nextdns::entity::sensor::all_queries::unit_of_measurement%]"
},
"dot_queries_ratio": {
"name": "DNS-over-TLS queries ratio"
},
"encrypted_queries": {
- "name": "Encrypted queries"
+ "name": "Encrypted queries",
+ "unit_of_measurement": "[%key:component::nextdns::entity::sensor::all_queries::unit_of_measurement%]"
},
"encrypted_queries_ratio": {
"name": "Encrypted queries ratio"
},
"ipv4_queries": {
- "name": "IPv4 queries"
+ "name": "IPv4 queries",
+ "unit_of_measurement": "[%key:component::nextdns::entity::sensor::all_queries::unit_of_measurement%]"
},
"ipv6_queries": {
- "name": "IPv6 queries"
+ "name": "IPv6 queries",
+ "unit_of_measurement": "[%key:component::nextdns::entity::sensor::all_queries::unit_of_measurement%]"
},
"ipv6_queries_ratio": {
"name": "IPv6 queries ratio"
},
"not_validated_queries": {
- "name": "DNSSEC not validated queries"
+ "name": "DNSSEC not validated queries",
+ "unit_of_measurement": "[%key:component::nextdns::entity::sensor::all_queries::unit_of_measurement%]"
},
"relayed_queries": {
- "name": "DNS queries relayed"
+ "name": "DNS queries relayed",
+ "unit_of_measurement": "[%key:component::nextdns::entity::sensor::all_queries::unit_of_measurement%]"
},
"tcp_queries": {
- "name": "TCP queries"
+ "name": "TCP queries",
+ "unit_of_measurement": "[%key:component::nextdns::entity::sensor::all_queries::unit_of_measurement%]"
},
"tcp_queries_ratio": {
"name": "TCP queries ratio"
},
"udp_queries": {
- "name": "UDP queries"
+ "name": "UDP queries",
+ "unit_of_measurement": "[%key:component::nextdns::entity::sensor::all_queries::unit_of_measurement%]"
},
"udp_queries_ratio": {
"name": "UDP queries ratio"
},
"unencrypted_queries": {
- "name": "Unencrypted queries"
+ "name": "Unencrypted queries",
+ "unit_of_measurement": "[%key:component::nextdns::entity::sensor::all_queries::unit_of_measurement%]"
},
"validated_queries": {
- "name": "DNSSEC validated queries"
+ "name": "DNSSEC validated queries",
+ "unit_of_measurement": "[%key:component::nextdns::entity::sensor::all_queries::unit_of_measurement%]"
},
"validated_queries_ratio": {
"name": "DNSSEC validated queries ratio"
diff --git a/homeassistant/components/nibe_heatpump/climate.py b/homeassistant/components/nibe_heatpump/climate.py
index f89d6ec29a9..94db90e7f58 100644
--- a/homeassistant/components/nibe_heatpump/climate.py
+++ b/homeassistant/components/nibe_heatpump/climate.py
@@ -74,7 +74,6 @@ class NibeClimateEntity(CoordinatorEntity[CoilCoordinator], ClimateEntity):
_attr_target_temperature_step = 0.5
_attr_max_temp = 35.0
_attr_min_temp = 5.0
- _enable_turn_on_off_backwards_compatibility = False
def __init__(
self,
diff --git a/homeassistant/components/nibe_heatpump/manifest.json b/homeassistant/components/nibe_heatpump/manifest.json
index b3e5597da73..049ba905f04 100644
--- a/homeassistant/components/nibe_heatpump/manifest.json
+++ b/homeassistant/components/nibe_heatpump/manifest.json
@@ -5,5 +5,5 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/nibe_heatpump",
"iot_class": "local_polling",
- "requirements": ["nibe==2.11.0"]
+ "requirements": ["nibe==2.14.0"]
}
diff --git a/homeassistant/components/nice_go/const.py b/homeassistant/components/nice_go/const.py
index a6635368f7b..c02bcb3c234 100644
--- a/homeassistant/components/nice_go/const.py
+++ b/homeassistant/components/nice_go/const.py
@@ -15,8 +15,8 @@ CONF_REFRESH_TOKEN_CREATION_TIME = "refresh_token_creation_time"
REFRESH_TOKEN_EXPIRY_TIME = timedelta(days=30)
SUPPORTED_DEVICE_TYPES = {
- Platform.LIGHT: ["WallStation"],
- Platform.SWITCH: ["WallStation"],
+ Platform.LIGHT: ["WallStation", "WallStation_ESP32"],
+ Platform.SWITCH: ["WallStation", "WallStation_ESP32"],
}
KNOWN_UNSUPPORTED_DEVICE_TYPES = {
Platform.LIGHT: ["Mms100"],
diff --git a/homeassistant/components/nice_go/coordinator.py b/homeassistant/components/nice_go/coordinator.py
index 29c0d8233fe..07b20bbbf10 100644
--- a/homeassistant/components/nice_go/coordinator.py
+++ b/homeassistant/components/nice_go/coordinator.py
@@ -239,7 +239,6 @@ class NiceGOUpdateCoordinator(DataUpdateCoordinator[dict[str, NiceGODevice]]):
].type, # Device type is not sent in device state update, and it can't change, so we just reuse the existing one
BarrierState(
deviceId=raw_data["deviceId"],
- desired=json.loads(raw_data["desired"]),
reported=json.loads(raw_data["reported"]),
connectionState=ConnectionState(
connected=raw_data["connectionState"]["connected"],
diff --git a/homeassistant/components/nice_go/cover.py b/homeassistant/components/nice_go/cover.py
index a823e931804..6360e398b96 100644
--- a/homeassistant/components/nice_go/cover.py
+++ b/homeassistant/components/nice_go/cover.py
@@ -21,6 +21,7 @@ from .entity import NiceGOEntity
DEVICE_CLASSES = {
"WallStation": CoverDeviceClass.GARAGE,
"Mms100": CoverDeviceClass.GATE,
+ "WallStation_ESP32": CoverDeviceClass.GARAGE,
}
PARALLEL_UPDATES = 1
diff --git a/homeassistant/components/nice_go/manifest.json b/homeassistant/components/nice_go/manifest.json
index 817d7ef9bc9..1af23ec4d9b 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==0.3.10"]
+ "requirements": ["nice-go==1.0.0"]
}
diff --git a/homeassistant/components/nice_go/strings.json b/homeassistant/components/nice_go/strings.json
index 07dabf7d39f..224996e6408 100644
--- a/homeassistant/components/nice_go/strings.json
+++ b/homeassistant/components/nice_go/strings.json
@@ -6,12 +6,20 @@
"data": {
"email": "[%key:common::config_flow::data::email%]",
"password": "[%key:common::config_flow::data::password%]"
+ },
+ "data_description": {
+ "email": "[%key:component::nice_go::config::step::user::data_description::email%]",
+ "password": "[%key:component::nice_go::config::step::user::data_description::password%]"
}
},
"user": {
"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 the Nice G.O. app",
+ "password": "The password used to log in to the Nice G.O. app"
}
}
},
diff --git a/homeassistant/components/nightscout/manifest.json b/homeassistant/components/nightscout/manifest.json
index 3551b29ee0b..9b075a6df87 100644
--- a/homeassistant/components/nightscout/manifest.json
+++ b/homeassistant/components/nightscout/manifest.json
@@ -6,6 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/nightscout",
"iot_class": "cloud_polling",
"loggers": ["py_nightscout"],
- "quality_scale": "platinum",
"requirements": ["py-nightscout==1.2.2"]
}
diff --git a/homeassistant/components/nightscout/sensor.py b/homeassistant/components/nightscout/sensor.py
index 92291bdc4f9..620349ec3c3 100644
--- a/homeassistant/components/nightscout/sensor.py
+++ b/homeassistant/components/nightscout/sensor.py
@@ -9,9 +9,9 @@ from typing import Any
from aiohttp import ClientError
from py_nightscout import Api as NightscoutAPI
-from homeassistant.components.sensor import SensorEntity
+from homeassistant.components.sensor import SensorDeviceClass, SensorEntity
from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import ATTR_DATE
+from homeassistant.const import ATTR_DATE, UnitOfBloodGlucoseConcentration
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
@@ -37,7 +37,10 @@ async def async_setup_entry(
class NightscoutSensor(SensorEntity):
"""Implementation of a Nightscout sensor."""
- _attr_native_unit_of_measurement = "mg/dL"
+ _attr_device_class = SensorDeviceClass.BLOOD_GLUCOSE_CONCENTRATION
+ _attr_native_unit_of_measurement = (
+ UnitOfBloodGlucoseConcentration.MILLIGRAMS_PER_DECILITER
+ )
_attr_icon = "mdi:cloud-question"
def __init__(self, api: NightscoutAPI, name: str, unique_id: str | None) -> None:
diff --git a/homeassistant/components/niko_home_control/__init__.py b/homeassistant/components/niko_home_control/__init__.py
index 2cb5c70d1dd..ae4e8986816 100644
--- a/homeassistant/components/niko_home_control/__init__.py
+++ b/homeassistant/components/niko_home_control/__init__.py
@@ -1 +1,76 @@
-"""The niko_home_control component."""
+"""The Niko home control integration."""
+
+from __future__ import annotations
+
+from nclib.errors import NetcatError
+from nhc.controller import NHCController
+
+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 entity_registry as er
+
+from .const import _LOGGER
+
+PLATFORMS: list[Platform] = [Platform.COVER, Platform.LIGHT]
+
+type NikoHomeControlConfigEntry = ConfigEntry[NHCController]
+
+
+async def async_setup_entry(
+ hass: HomeAssistant, entry: NikoHomeControlConfigEntry
+) -> bool:
+ """Set Niko Home Control from a config entry."""
+ controller = NHCController(entry.data[CONF_HOST])
+ try:
+ await controller.connect()
+ except NetcatError as err:
+ raise ConfigEntryNotReady("cannot connect to controller.") from err
+ except OSError as err:
+ raise ConfigEntryNotReady(
+ "unknown error while connecting to controller."
+ ) from err
+
+ entry.runtime_data = controller
+ await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
+ return True
+
+
+async def async_migrate_entry(
+ hass: HomeAssistant, config_entry: NikoHomeControlConfigEntry
+) -> bool:
+ """Migrate old entry."""
+ _LOGGER.debug(
+ "Migrating configuration from version %s.%s",
+ config_entry.version,
+ config_entry.minor_version,
+ )
+
+ if config_entry.minor_version < 2:
+ registry = er.async_get(hass)
+ entries = er.async_entries_for_config_entry(registry, config_entry.entry_id)
+
+ for entry in entries:
+ if entry.unique_id.startswith("light-"):
+ action_id = entry.unique_id.split("-")[-1]
+ new_unique_id = f"{config_entry.entry_id}-{action_id}"
+ registry.async_update_entity(
+ entry.entity_id, new_unique_id=new_unique_id
+ )
+
+ hass.config_entries.async_update_entry(config_entry, minor_version=2)
+
+ _LOGGER.debug(
+ "Migration to configuration version %s.%s successful",
+ config_entry.version,
+ config_entry.minor_version,
+ )
+ return True
+
+
+async def async_unload_entry(
+ hass: HomeAssistant, entry: NikoHomeControlConfigEntry
+) -> bool:
+ """Unload a config entry."""
+ return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
diff --git a/homeassistant/components/niko_home_control/config_flow.py b/homeassistant/components/niko_home_control/config_flow.py
new file mode 100644
index 00000000000..f37e5e9248a
--- /dev/null
+++ b/homeassistant/components/niko_home_control/config_flow.py
@@ -0,0 +1,68 @@
+"""Config flow for the Niko home control integration."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from nhc.controller import NHCController
+import voluptuous as vol
+
+from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
+from homeassistant.const import CONF_HOST
+
+from .const import DOMAIN
+
+DATA_SCHEMA = vol.Schema(
+ {
+ vol.Required(CONF_HOST): str,
+ }
+)
+
+
+async def test_connection(host: str) -> str | None:
+ """Test if we can connect to the Niko Home Control controller."""
+
+ controller = NHCController(host, 8000)
+ try:
+ await controller.connect()
+ except Exception: # noqa: BLE001
+ return "cannot_connect"
+ return None
+
+
+class NikoHomeControlConfigFlow(ConfigFlow, domain=DOMAIN):
+ """Handle a config flow for Niko Home Control."""
+
+ MINOR_VERSION = 2
+
+ async def async_step_user(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Handle the initial step."""
+ errors = {}
+
+ if user_input is not None:
+ self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]})
+ error = await test_connection(user_input[CONF_HOST])
+ if not error:
+ return self.async_create_entry(
+ title="Niko Home Control",
+ data=user_input,
+ )
+ errors["base"] = error
+
+ return self.async_show_form(
+ step_id="user", data_schema=DATA_SCHEMA, errors=errors
+ )
+
+ async def async_step_import(self, import_info: dict[str, Any]) -> ConfigFlowResult:
+ """Import a config entry."""
+ self._async_abort_entries_match({CONF_HOST: import_info[CONF_HOST]})
+ error = await test_connection(import_info[CONF_HOST])
+
+ if not error:
+ return self.async_create_entry(
+ title="Niko Home Control",
+ data={CONF_HOST: import_info[CONF_HOST]},
+ )
+ return self.async_abort(reason=error)
diff --git a/homeassistant/components/niko_home_control/const.py b/homeassistant/components/niko_home_control/const.py
new file mode 100644
index 00000000000..82b7ce7ed38
--- /dev/null
+++ b/homeassistant/components/niko_home_control/const.py
@@ -0,0 +1,6 @@
+"""Constants for niko_home_control integration."""
+
+import logging
+
+DOMAIN = "niko_home_control"
+_LOGGER = logging.getLogger(__name__)
diff --git a/homeassistant/components/niko_home_control/cover.py b/homeassistant/components/niko_home_control/cover.py
new file mode 100644
index 00000000000..51e2a8a702d
--- /dev/null
+++ b/homeassistant/components/niko_home_control/cover.py
@@ -0,0 +1,54 @@
+"""Cover Platform for Niko Home Control."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from nhc.cover import NHCCover
+
+from homeassistant.components.cover import CoverEntity, CoverEntityFeature
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+
+from . import NikoHomeControlConfigEntry
+from .entity import NikoHomeControlEntity
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: NikoHomeControlConfigEntry,
+ async_add_entities: AddEntitiesCallback,
+) -> None:
+ """Set up the Niko Home Control cover entry."""
+ controller = entry.runtime_data
+
+ async_add_entities(
+ NikoHomeControlCover(cover, controller, entry.entry_id)
+ for cover in controller.covers
+ )
+
+
+class NikoHomeControlCover(NikoHomeControlEntity, CoverEntity):
+ """Representation of a Niko Cover."""
+
+ _attr_name = None
+ _attr_supported_features: CoverEntityFeature = (
+ CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE | CoverEntityFeature.STOP
+ )
+ _action: NHCCover
+
+ def open_cover(self, **kwargs: Any) -> None:
+ """Open the cover."""
+ self._action.open()
+
+ def close_cover(self, **kwargs: Any) -> None:
+ """Close the cover."""
+ self._action.close()
+
+ def stop_cover(self, **kwargs: Any) -> None:
+ """Stop the cover."""
+ self._action.stop()
+
+ def update_state(self):
+ """Update HA state."""
+ self._attr_is_closed = self._action.state == 0
diff --git a/homeassistant/components/niko_home_control/entity.py b/homeassistant/components/niko_home_control/entity.py
new file mode 100644
index 00000000000..fe14e09d957
--- /dev/null
+++ b/homeassistant/components/niko_home_control/entity.py
@@ -0,0 +1,50 @@
+"""Base class for Niko Home Control entities."""
+
+from abc import abstractmethod
+
+from nhc.action import NHCAction
+from nhc.controller import NHCController
+
+from homeassistant.helpers.device_registry import DeviceInfo
+from homeassistant.helpers.entity import Entity
+
+from .const import DOMAIN
+
+
+class NikoHomeControlEntity(Entity):
+ """Base class for Niko Home Control entities."""
+
+ _attr_has_entity_name = True
+ _attr_should_poll = False
+
+ def __init__(
+ self, action: NHCAction, controller: NHCController, unique_id: str
+ ) -> None:
+ """Set up the Niko Home Control entity."""
+ self._controller = controller
+ self._action = action
+ self._attr_unique_id = unique_id = f"{unique_id}-{action.id}"
+ self._attr_device_info = DeviceInfo(
+ identifiers={(DOMAIN, unique_id)},
+ manufacturer="Niko",
+ name=action.name,
+ suggested_area=action.suggested_area,
+ )
+ self.update_state()
+
+ async def async_added_to_hass(self) -> None:
+ """Subscribe to updates."""
+ self.async_on_remove(
+ self._controller.register_callback(
+ self._action.id, self.async_update_callback
+ )
+ )
+
+ async def async_update_callback(self, state: int) -> None:
+ """Handle updates from the controller."""
+ self.update_state()
+ self.async_write_ha_state()
+
+ @abstractmethod
+ def update_state(self) -> None:
+ """Update the state of the entity."""
diff --git a/homeassistant/components/niko_home_control/light.py b/homeassistant/components/niko_home_control/light.py
index b2d41f3a41e..69d4e71c755 100644
--- a/homeassistant/components/niko_home_control/light.py
+++ b/homeassistant/components/niko_home_control/light.py
@@ -1,12 +1,10 @@
-"""Support for Niko Home Control."""
+"""Light platform Niko Home Control."""
from __future__ import annotations
-from datetime import timedelta
-import logging
from typing import Any
-import nikohomecontrol
+from nhc.light import NHCLight
import voluptuous as vol
from homeassistant.components.light import (
@@ -16,18 +14,20 @@ from homeassistant.components.light import (
LightEntity,
brightness_supported,
)
+from homeassistant.config_entries import SOURCE_IMPORT
from homeassistant.const import CONF_HOST
-from homeassistant.core import HomeAssistant
-from homeassistant.exceptions import PlatformNotReady
+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.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
-from homeassistant.util import Throttle
-_LOGGER = logging.getLogger(__name__)
-MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=1)
-SCAN_INTERVAL = timedelta(seconds=30)
+from . import NHCController, NikoHomeControlConfigEntry
+from .const import DOMAIN
+from .entity import NikoHomeControlEntity
+# delete after 2025.7.0
PLATFORM_SCHEMA = LIGHT_PLATFORM_SCHEMA.extend({vol.Required(CONF_HOST): cv.string})
@@ -38,86 +38,89 @@ async def async_setup_platform(
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up the Niko Home Control light platform."""
- host = config[CONF_HOST]
-
- try:
- nhc = nikohomecontrol.NikoHomeControl(
- {"ip": host, "port": 8000, "timeout": 20000}
+ # Start import flow
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_IMPORT}, data=config
+ )
+ if (
+ result.get("type") == FlowResultType.ABORT
+ and result.get("reason") != "already_configured"
+ ):
+ ir.async_create_issue(
+ hass,
+ DOMAIN,
+ f"deprecated_yaml_import_issue_{result['reason']}",
+ breaks_in_ha_version="2025.7.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": "Niko Home Control",
+ },
)
- niko_data = NikoHomeControlData(hass, nhc)
- await niko_data.async_update()
- except OSError as err:
- _LOGGER.error("Unable to access %s (%s)", host, err)
- raise PlatformNotReady from err
+ return
- async_add_entities(
- [NikoHomeControlLight(light, niko_data) for light in nhc.list_actions()], True
+ ir.async_create_issue(
+ hass,
+ HOMEASSISTANT_DOMAIN,
+ f"deprecated_yaml_{DOMAIN}",
+ breaks_in_ha_version="2025.7.0",
+ is_fixable=False,
+ issue_domain=DOMAIN,
+ severity=ir.IssueSeverity.WARNING,
+ translation_key="deprecated_yaml",
+ translation_placeholders={
+ "domain": DOMAIN,
+ "integration_title": "Niko Home Control",
+ },
)
-class NikoHomeControlLight(LightEntity):
- """Representation of an Niko Light."""
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: NikoHomeControlConfigEntry,
+ async_add_entities: AddEntitiesCallback,
+) -> None:
+ """Set up the Niko Home Control light entry."""
+ controller = entry.runtime_data
- def __init__(self, light, data):
+ async_add_entities(
+ NikoHomeControlLight(light, controller, entry.entry_id)
+ for light in controller.lights
+ )
+
+
+class NikoHomeControlLight(NikoHomeControlEntity, LightEntity):
+ """Representation of a Niko Light."""
+
+ _attr_name = None
+ _action: NHCLight
+
+ def __init__(
+ self, action: NHCLight, controller: NHCController, unique_id: str
+ ) -> None:
"""Set up the Niko Home Control light platform."""
- self._data = data
- self._light = light
- self._attr_unique_id = f"light-{light.id}"
- self._attr_name = light.name
- self._attr_is_on = light.is_on
+ super().__init__(action, controller, unique_id)
self._attr_color_mode = ColorMode.ONOFF
self._attr_supported_color_modes = {ColorMode.ONOFF}
- if light._state["type"] == 2: # noqa: SLF001
+ if action.is_dimmable:
self._attr_color_mode = ColorMode.BRIGHTNESS
self._attr_supported_color_modes = {ColorMode.BRIGHTNESS}
+ self._attr_brightness = round(action.state * 2.55)
def turn_on(self, **kwargs: Any) -> None:
"""Instruct the light to turn on."""
- _LOGGER.debug("Turn on: %s", self.name)
- self._light.turn_on(kwargs.get(ATTR_BRIGHTNESS, 255) / 2.55)
+ self._action.turn_on(kwargs.get(ATTR_BRIGHTNESS, 255) / 2.55)
def turn_off(self, **kwargs: Any) -> None:
"""Instruct the light to turn off."""
- _LOGGER.debug("Turn off: %s", self.name)
- self._light.turn_off()
+ self._action.turn_off()
- async def async_update(self) -> None:
- """Get the latest data from NikoHomeControl API."""
- await self._data.async_update()
- state = self._data.get_state(self._light.id)
- self._attr_is_on = state != 0
+ def update_state(self) -> None:
+ """Handle updates from the controller."""
+ state = self._action.state
+ self._attr_is_on = state > 0
if brightness_supported(self.supported_color_modes):
- self._attr_brightness = state * 2.55
-
-
-class NikoHomeControlData:
- """The class for handling data retrieval."""
-
- def __init__(self, hass, nhc):
- """Set up Niko Home Control Data object."""
- self._nhc = nhc
- self.hass = hass
- self.available = True
- self.data = {}
- self._system_info = None
-
- @Throttle(MIN_TIME_BETWEEN_UPDATES)
- async def async_update(self):
- """Get the latest data from the NikoHomeControl API."""
- _LOGGER.debug("Fetching async state in bulk")
- try:
- self.data = await self.hass.async_add_executor_job(
- self._nhc.list_actions_raw
- )
- self.available = True
- except OSError as ex:
- _LOGGER.error("Unable to retrieve data from Niko, %s", str(ex))
- self.available = False
-
- def get_state(self, aid):
- """Find and filter state based on action id."""
- for state in self.data:
- if state["id"] == aid:
- return state["value1"]
- _LOGGER.error("Failed to retrieve state off unknown light")
- return None
+ self._attr_brightness = round(state * 2.55)
diff --git a/homeassistant/components/niko_home_control/manifest.json b/homeassistant/components/niko_home_control/manifest.json
index 72f9dd2f6b3..d252a11b38e 100644
--- a/homeassistant/components/niko_home_control/manifest.json
+++ b/homeassistant/components/niko_home_control/manifest.json
@@ -1,9 +1,10 @@
{
"domain": "niko_home_control",
"name": "Niko Home Control",
- "codeowners": [],
+ "codeowners": ["@VandeurenGlenn"],
+ "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/niko_home_control",
- "iot_class": "local_polling",
+ "iot_class": "local_push",
"loggers": ["nikohomecontrol"],
- "requirements": ["niko-home-control==0.2.1"]
+ "requirements": ["nhc==0.3.2"]
}
diff --git a/homeassistant/components/niko_home_control/strings.json b/homeassistant/components/niko_home_control/strings.json
new file mode 100644
index 00000000000..495dca94c0c
--- /dev/null
+++ b/homeassistant/components/niko_home_control/strings.json
@@ -0,0 +1,27 @@
+{
+ "config": {
+ "step": {
+ "user": {
+ "description": "Set up your Niko Home Control instance.",
+ "data": {
+ "host": "[%key:common::config_flow::data::host%]"
+ },
+ "data_description": {
+ "host": "The hostname or IP address of the Niko Home Control controller."
+ }
+ }
+ },
+ "error": {
+ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
+ },
+ "abort": {
+ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
+ }
+ },
+ "issues": {
+ "deprecated_yaml_import_issue_cannot_connect": {
+ "title": "YAML import failed due to a connection error",
+ "description": "Configuring {integration_title} using YAML is being removed but there was a connect 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."
+ }
+ }
+}
diff --git a/homeassistant/components/nilu/manifest.json b/homeassistant/components/nilu/manifest.json
index 1eabf9e726e..d99a918ef4f 100644
--- a/homeassistant/components/nilu/manifest.json
+++ b/homeassistant/components/nilu/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/nilu",
"iot_class": "cloud_polling",
"loggers": ["niluclient"],
+ "quality_scale": "legacy",
"requirements": ["niluclient==0.1.2"]
}
diff --git a/homeassistant/components/nina/binary_sensor.py b/homeassistant/components/nina/binary_sensor.py
index 397ced0f5d3..10d3008fd82 100644
--- a/homeassistant/components/nina/binary_sensor.py
+++ b/homeassistant/components/nina/binary_sensor.py
@@ -25,6 +25,7 @@ from .const import (
ATTR_SENT,
ATTR_SEVERITY,
ATTR_START,
+ ATTR_WEB,
CONF_MESSAGE_SLOTS,
CONF_REGIONS,
DOMAIN,
@@ -103,6 +104,7 @@ class NINAMessage(CoordinatorEntity[NINADataUpdateCoordinator], BinarySensorEnti
ATTR_SEVERITY: data.severity,
ATTR_RECOMMENDED_ACTIONS: data.recommended_actions,
ATTR_AFFECTED_AREAS: data.affected_areas,
+ ATTR_WEB: data.web,
ATTR_ID: data.id,
ATTR_SENT: data.sent,
ATTR_START: data.start,
diff --git a/homeassistant/components/nina/const.py b/homeassistant/components/nina/const.py
index 1e755056079..47194c4c2de 100644
--- a/homeassistant/components/nina/const.py
+++ b/homeassistant/components/nina/const.py
@@ -27,6 +27,7 @@ ATTR_SENDER: str = "sender"
ATTR_SEVERITY: str = "severity"
ATTR_RECOMMENDED_ACTIONS: str = "recommended_actions"
ATTR_AFFECTED_AREAS: str = "affected_areas"
+ATTR_WEB: str = "web"
ATTR_ID: str = "id"
ATTR_SENT: str = "sent"
ATTR_START: str = "start"
diff --git a/homeassistant/components/nina/coordinator.py b/homeassistant/components/nina/coordinator.py
index c731c7a62d7..2d9548f3d12 100644
--- a/homeassistant/components/nina/coordinator.py
+++ b/homeassistant/components/nina/coordinator.py
@@ -27,6 +27,7 @@ class NinaWarningData:
severity: str
recommended_actions: str
affected_areas: str
+ web: str
sent: str
start: str
expires: str
@@ -127,6 +128,7 @@ class NINADataUpdateCoordinator(
raw_warn.severity,
" ".join([str(action) for action in raw_warn.recommended_actions]),
affected_areas_string,
+ raw_warn.web or "",
raw_warn.sent or "",
raw_warn.start or "",
raw_warn.expires or "",
diff --git a/homeassistant/components/nina/manifest.json b/homeassistant/components/nina/manifest.json
index 53a54f26dcf..45212c0220b 100644
--- a/homeassistant/components/nina/manifest.json
+++ b/homeassistant/components/nina/manifest.json
@@ -6,6 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/nina",
"iot_class": "cloud_polling",
"loggers": ["pynina"],
- "requirements": ["PyNINA==0.3.3"],
+ "requirements": ["PyNINA==0.3.4"],
"single_config_entry": true
}
diff --git a/homeassistant/components/nina/strings.json b/homeassistant/components/nina/strings.json
index 9747feaddb7..98ea88d8798 100644
--- a/homeassistant/components/nina/strings.json
+++ b/homeassistant/components/nina/strings.json
@@ -38,12 +38,10 @@
}
}
},
- "abort": {
- "unknown": "[%key:common::config_flow::error::unknown%]"
- },
"error": {
"no_selection": "[%key:component::nina::config::error::no_selection%]",
- "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
+ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
+ "unknown": "[%key:common::config_flow::error::unknown%]"
}
}
}
diff --git a/homeassistant/components/nissan_leaf/manifest.json b/homeassistant/components/nissan_leaf/manifest.json
index 9c3df39c69f..9ad8773ee44 100644
--- a/homeassistant/components/nissan_leaf/manifest.json
+++ b/homeassistant/components/nissan_leaf/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/nissan_leaf",
"iot_class": "cloud_polling",
"loggers": ["pycarwings2"],
+ "quality_scale": "legacy",
"requirements": ["pycarwings2==2.14"]
}
diff --git a/homeassistant/components/nmbs/manifest.json b/homeassistant/components/nmbs/manifest.json
index 24aadb6b4f0..e17d1227bed 100644
--- a/homeassistant/components/nmbs/manifest.json
+++ b/homeassistant/components/nmbs/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/nmbs",
"iot_class": "cloud_polling",
"loggers": ["pyrail"],
+ "quality_scale": "legacy",
"requirements": ["pyrail==0.0.3"]
}
diff --git a/homeassistant/components/no_ip/manifest.json b/homeassistant/components/no_ip/manifest.json
index cf995e34b47..8e1e247143e 100644
--- a/homeassistant/components/no_ip/manifest.json
+++ b/homeassistant/components/no_ip/manifest.json
@@ -3,5 +3,6 @@
"name": "No-IP.com",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/no_ip",
- "iot_class": "cloud_polling"
+ "iot_class": "cloud_polling",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/noaa_tides/manifest.json b/homeassistant/components/noaa_tides/manifest.json
index 85c6fbcb788..8cc81857770 100644
--- a/homeassistant/components/noaa_tides/manifest.json
+++ b/homeassistant/components/noaa_tides/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/noaa_tides",
"iot_class": "cloud_polling",
"loggers": ["noaa_coops"],
+ "quality_scale": "legacy",
"requirements": ["noaa-coops==0.1.9"]
}
diff --git a/homeassistant/components/nobo_hub/climate.py b/homeassistant/components/nobo_hub/climate.py
index f1e2f4a78f0..a089209cde5 100644
--- a/homeassistant/components/nobo_hub/climate.py
+++ b/homeassistant/components/nobo_hub/climate.py
@@ -82,7 +82,6 @@ class NoboZone(ClimateEntity):
_attr_temperature_unit = UnitOfTemperature.CELSIUS
_attr_target_temperature_step = 1
# Need to poll to get preset change when in HVACMode.AUTO, so can't set _attr_should_poll = False
- _enable_turn_on_off_backwards_compatibility = False
def __init__(self, zone_id, hub: nobo, override_type) -> None:
"""Initialize the climate device."""
diff --git a/homeassistant/components/nordpool/__init__.py b/homeassistant/components/nordpool/__init__.py
index b688bf74a37..77f4b263b54 100644
--- a/homeassistant/components/nordpool/__init__.py
+++ b/homeassistant/components/nordpool/__init__.py
@@ -4,26 +4,69 @@ from __future__ import annotations
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import ConfigEntryNotReady
+from homeassistant.helpers import config_validation as cv, device_registry as dr
+from homeassistant.helpers.typing import ConfigType
from homeassistant.util import dt as dt_util
-from .const import PLATFORMS
+from .const import CONF_AREAS, DOMAIN, LOGGER, PLATFORMS
from .coordinator import NordPoolDataUpdateCoordinator
+from .services import async_setup_services
type NordPoolConfigEntry = ConfigEntry[NordPoolDataUpdateCoordinator]
+CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
-async def async_setup_entry(hass: HomeAssistant, entry: NordPoolConfigEntry) -> bool:
+
+async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
+ """Set up the Nord Pool service."""
+
+ async_setup_services(hass)
+ return True
+
+
+async def async_setup_entry(
+ hass: HomeAssistant, config_entry: NordPoolConfigEntry
+) -> bool:
"""Set up Nord Pool from a config entry."""
- coordinator = NordPoolDataUpdateCoordinator(hass, entry)
- await coordinator.fetch_data(dt_util.utcnow())
- entry.runtime_data = coordinator
+ await cleanup_device(hass, config_entry)
- await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
+ coordinator = NordPoolDataUpdateCoordinator(hass, config_entry)
+ await coordinator.fetch_data(dt_util.utcnow())
+ if not coordinator.last_update_success:
+ raise ConfigEntryNotReady(
+ translation_domain=DOMAIN,
+ translation_key="initial_update_failed",
+ translation_placeholders={"error": str(coordinator.last_exception)},
+ )
+ config_entry.runtime_data = coordinator
+
+ await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
return True
-async def async_unload_entry(hass: HomeAssistant, entry: NordPoolConfigEntry) -> bool:
+async def async_unload_entry(
+ hass: HomeAssistant, config_entry: NordPoolConfigEntry
+) -> bool:
"""Unload Nord Pool config entry."""
- return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
+ return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS)
+
+
+async def cleanup_device(
+ hass: HomeAssistant, config_entry: NordPoolConfigEntry
+) -> None:
+ """Cleanup device and entities."""
+ device_reg = dr.async_get(hass)
+
+ entries = dr.async_entries_for_config_entry(device_reg, config_entry.entry_id)
+ for area in config_entry.data[CONF_AREAS]:
+ for entry in entries:
+ if entry.identifiers == {(DOMAIN, area)}:
+ continue
+
+ LOGGER.debug("Removing device %s", entry.name)
+ device_reg.async_update_device(
+ entry.id, remove_config_entry_id=config_entry.entry_id
+ )
diff --git a/homeassistant/components/nordpool/config_flow.py b/homeassistant/components/nordpool/config_flow.py
index a9a834d8225..b3b807badad 100644
--- a/homeassistant/components/nordpool/config_flow.py
+++ b/homeassistant/components/nordpool/config_flow.py
@@ -4,7 +4,12 @@ from __future__ import annotations
from typing import Any
-from pynordpool import Currency, NordPoolClient, NordPoolError
+from pynordpool import (
+ Currency,
+ NordPoolClient,
+ NordPoolEmptyResponseError,
+ NordPoolError,
+)
from pynordpool.const import AREAS
import voluptuous as vol
@@ -53,17 +58,16 @@ async def test_api(hass: HomeAssistant, user_input: dict[str, Any]) -> dict[str,
"""Test fetch data from Nord Pool."""
client = NordPoolClient(async_get_clientsession(hass))
try:
- data = await client.async_get_delivery_period(
+ await client.async_get_delivery_period(
dt_util.now(),
Currency(user_input[CONF_CURRENCY]),
user_input[CONF_AREAS],
)
+ except NordPoolEmptyResponseError:
+ return {"base": "no_data"}
except NordPoolError:
return {"base": "cannot_connect"}
- if not data.raw:
- return {"base": "no_data"}
-
return {}
@@ -95,10 +99,10 @@ class NordpoolConfigFlow(ConfigFlow, domain=DOMAIN):
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the reconfiguration step."""
+ reconfigure_entry = self._get_reconfigure_entry()
errors: dict[str, str] = {}
if user_input:
errors = await test_api(self.hass, user_input)
- reconfigure_entry = self._get_reconfigure_entry()
if not errors:
return self.async_update_reload_and_abort(
reconfigure_entry, data_updates=user_input
@@ -106,6 +110,8 @@ class NordpoolConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_show_form(
step_id="reconfigure",
- data_schema=DATA_SCHEMA,
+ data_schema=self.add_suggested_values_to_schema(
+ DATA_SCHEMA, user_input or reconfigure_entry.data
+ ),
errors=errors,
)
diff --git a/homeassistant/components/nordpool/coordinator.py b/homeassistant/components/nordpool/coordinator.py
index 27016ae2b4b..a6cfd40c323 100644
--- a/homeassistant/components/nordpool/coordinator.py
+++ b/homeassistant/components/nordpool/coordinator.py
@@ -9,8 +9,10 @@ from typing import TYPE_CHECKING
from pynordpool import (
Currency,
DeliveryPeriodData,
- NordPoolAuthenticationError,
+ DeliveryPeriodEntry,
+ DeliveryPeriodsData,
NordPoolClient,
+ NordPoolEmptyResponseError,
NordPoolError,
NordPoolResponseError,
)
@@ -19,7 +21,7 @@ from homeassistant.const import CONF_CURRENCY
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.event import async_track_point_in_utc_time
-from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
+from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from homeassistant.util import dt as dt_util
from .const import CONF_AREAS, DOMAIN, LOGGER
@@ -28,7 +30,7 @@ if TYPE_CHECKING:
from . import NordPoolConfigEntry
-class NordPoolDataUpdateCoordinator(DataUpdateCoordinator[DeliveryPeriodData]):
+class NordPoolDataUpdateCoordinator(DataUpdateCoordinator[DeliveryPeriodsData]):
"""A Nord Pool Data Update Coordinator."""
config_entry: NordPoolConfigEntry
@@ -69,27 +71,53 @@ class NordPoolDataUpdateCoordinator(DataUpdateCoordinator[DeliveryPeriodData]):
self.unsub = async_track_point_in_utc_time(
self.hass, self.fetch_data, self.get_next_interval(dt_util.utcnow())
)
+ data = await self.api_call()
+ if data and data.entries:
+ self.async_set_updated_data(data)
+
+ async def api_call(self, retry: int = 3) -> DeliveryPeriodsData | None:
+ """Make api call to retrieve data with retry if failure."""
+ data = None
try:
- data = await self.client.async_get_delivery_period(
- dt_util.now(),
+ data = await self.client.async_get_delivery_periods(
+ [
+ dt_util.now() - timedelta(days=1),
+ dt_util.now(),
+ dt_util.now() + timedelta(days=1),
+ ],
Currency(self.config_entry.data[CONF_CURRENCY]),
self.config_entry.data[CONF_AREAS],
)
- except NordPoolAuthenticationError as error:
- LOGGER.error("Authentication error: %s", error)
- self.async_set_update_error(error)
- return
- except NordPoolResponseError as error:
- LOGGER.debug("Response error: %s", error)
- self.async_set_update_error(error)
- return
- except NordPoolError as error:
+ except (
+ NordPoolResponseError,
+ NordPoolError,
+ ) as error:
LOGGER.debug("Connection error: %s", error)
self.async_set_update_error(error)
- return
- if not data.raw:
- self.async_set_update_error(UpdateFailed("No data"))
- return
+ if data:
+ current_day = dt_util.utcnow().strftime("%Y-%m-%d")
+ for entry in data.entries:
+ if entry.requested_date == current_day:
+ LOGGER.debug("Data for current day found")
+ return data
- self.async_set_updated_data(data)
+ self.async_set_update_error(NordPoolEmptyResponseError("No current day data"))
+ return data
+
+ def merge_price_entries(self) -> list[DeliveryPeriodEntry]:
+ """Return the merged price entries."""
+ merged_entries: list[DeliveryPeriodEntry] = []
+ for del_period in self.data.entries:
+ merged_entries.extend(del_period.entries)
+ return merged_entries
+
+ def get_data_current_day(self) -> DeliveryPeriodData:
+ """Return the current day data."""
+ current_day = dt_util.utcnow().strftime("%Y-%m-%d")
+ delivery_period: DeliveryPeriodData = self.data.entries[0]
+ for del_period in self.data.entries:
+ if del_period.requested_date == current_day:
+ delivery_period = del_period
+ break
+ return delivery_period
diff --git a/homeassistant/components/nordpool/diagnostics.py b/homeassistant/components/nordpool/diagnostics.py
new file mode 100644
index 00000000000..3160c2bfa6d
--- /dev/null
+++ b/homeassistant/components/nordpool/diagnostics.py
@@ -0,0 +1,16 @@
+"""Diagnostics support for Nord Pool."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from homeassistant.core import HomeAssistant
+
+from . import NordPoolConfigEntry
+
+
+async def async_get_config_entry_diagnostics(
+ hass: HomeAssistant, entry: NordPoolConfigEntry
+) -> dict[str, Any]:
+ """Return diagnostics for Nord Pool config entry."""
+ return {"raw": entry.runtime_data.data.raw}
diff --git a/homeassistant/components/nordpool/entity.py b/homeassistant/components/nordpool/entity.py
index 32240aad12c..ec3264cd2e3 100644
--- a/homeassistant/components/nordpool/entity.py
+++ b/homeassistant/components/nordpool/entity.py
@@ -2,7 +2,7 @@
from __future__ import annotations
-from homeassistant.helpers.device_registry import DeviceInfo
+from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -29,4 +29,5 @@ class NordpoolBaseEntity(CoordinatorEntity[NordPoolDataUpdateCoordinator]):
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, area)},
name=f"Nord Pool {area}",
+ entry_type=DeviceEntryType.SERVICE,
)
diff --git a/homeassistant/components/nordpool/icons.json b/homeassistant/components/nordpool/icons.json
index 85434a2d09b..5a1a3df3d92 100644
--- a/homeassistant/components/nordpool/icons.json
+++ b/homeassistant/components/nordpool/icons.json
@@ -38,5 +38,10 @@
"default": "mdi:cash-multiple"
}
}
+ },
+ "services": {
+ "get_prices_for_date": {
+ "service": "mdi:cash-multiple"
+ }
}
}
diff --git a/homeassistant/components/nordpool/manifest.json b/homeassistant/components/nordpool/manifest.json
index ba435c38b5e..b096d2bd506 100644
--- a/homeassistant/components/nordpool/manifest.json
+++ b/homeassistant/components/nordpool/manifest.json
@@ -7,6 +7,7 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["pynordpool"],
- "requirements": ["pynordpool==0.2.1"],
+ "quality_scale": "platinum",
+ "requirements": ["pynordpool==0.2.4"],
"single_config_entry": true
}
diff --git a/homeassistant/components/nordpool/quality_scale.yaml b/homeassistant/components/nordpool/quality_scale.yaml
new file mode 100644
index 00000000000..9c5160d0ccb
--- /dev/null
+++ b/homeassistant/components/nordpool/quality_scale.yaml
@@ -0,0 +1,86 @@
+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: done
+ 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:
+ status: exempt
+ comment: |
+ This integration does not require authentication.
+ parallel-updates: done
+ test-coverage: done
+ integration-owner: done
+ docs-installation-parameters: done
+ 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:
+ status: exempt
+ comment: |
+ No discovery, cloud service
+ stale-devices:
+ status: exempt
+ comment: |
+ This integration devices (services) will be removed with config entry if needed.
+ diagnostics: done
+ exception-translations: done
+ icon-translations: done
+ reconfiguration-flow: done
+ dynamic-devices:
+ status: exempt
+ comment: |
+ This integration has fixed devices.
+ discovery-update-info:
+ status: exempt
+ comment: |
+ No discovery
+ 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:
+ status: exempt
+ comment: |
+ Only service, no device
+ 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/nordpool/sensor.py b/homeassistant/components/nordpool/sensor.py
index e7e655a6657..30910f8e5f6 100644
--- a/homeassistant/components/nordpool/sensor.py
+++ b/homeassistant/components/nordpool/sensor.py
@@ -6,8 +6,6 @@ from collections.abc import Callable
from dataclasses import dataclass
from datetime import datetime, timedelta
-from pynordpool import DeliveryPeriodData
-
from homeassistant.components.sensor import (
EntityCategory,
SensorDeviceClass,
@@ -27,40 +25,89 @@ from .entity import NordpoolBaseEntity
PARALLEL_UPDATES = 0
-def get_prices(data: DeliveryPeriodData) -> dict[str, tuple[float, float, float]]:
+def validate_prices(
+ func: Callable[
+ [NordpoolPriceSensor], dict[str, tuple[float | None, float, float | None]]
+ ],
+ entity: NordpoolPriceSensor,
+ area: str,
+ index: int,
+) -> float | None:
+ """Validate and return."""
+ if result := func(entity)[area][index]:
+ return result / 1000
+ return None
+
+
+def get_prices(
+ entity: NordpoolPriceSensor,
+) -> dict[str, tuple[float | None, float, float | None]]:
"""Return previous, current and next prices.
Output: {"SE3": (10.0, 10.5, 12.1)}
"""
+ data = entity.coordinator.merge_price_entries()
last_price_entries: dict[str, float] = {}
current_price_entries: dict[str, float] = {}
next_price_entries: dict[str, float] = {}
current_time = dt_util.utcnow()
previous_time = current_time - timedelta(hours=1)
next_time = current_time + timedelta(hours=1)
- price_data = data.entries
- for entry in price_data:
+ LOGGER.debug("Price data: %s", data)
+ for entry in data:
if entry.start <= current_time <= entry.end:
current_price_entries = entry.entry
if entry.start <= previous_time <= entry.end:
last_price_entries = entry.entry
if entry.start <= next_time <= entry.end:
next_price_entries = entry.entry
+ LOGGER.debug(
+ "Last price %s, current price %s, next price %s",
+ last_price_entries,
+ current_price_entries,
+ next_price_entries,
+ )
result = {}
for area, price in current_price_entries.items():
- result[area] = (last_price_entries[area], price, next_price_entries[area])
+ result[area] = (
+ last_price_entries.get(area),
+ price,
+ next_price_entries.get(area),
+ )
LOGGER.debug("Prices: %s", result)
return result
+def get_min_max_price(
+ entity: NordpoolPriceSensor,
+ func: Callable[[float, float], float],
+) -> tuple[float, datetime, datetime]:
+ """Get the lowest price from the data."""
+ data = entity.coordinator.get_data_current_day()
+ area = entity.area
+ price_data = data.entries
+ price: float = price_data[0].entry[area]
+ start: datetime = price_data[0].start
+ end: datetime = price_data[0].end
+ for entry in price_data:
+ for _area, _price in entry.entry.items():
+ if _area == area and _price == func(price, _price):
+ price = _price
+ start = entry.start
+ end = entry.end
+
+ return (price, start, end)
+
+
def get_blockprices(
- data: DeliveryPeriodData,
+ entity: NordpoolBlockPriceSensor,
) -> dict[str, dict[str, tuple[datetime, datetime, float, float, float]]]:
"""Return average, min and max for block prices.
Output: {"SE3": {"Off-peak 1": (_datetime_, _datetime_, 9.3, 10.5, 12.1)}}
"""
+ data = entity.coordinator.get_data_current_day()
result: dict[str, dict[str, tuple[datetime, datetime, float, float, float]]] = {}
block_prices = data.block_prices
for entry in block_prices:
@@ -83,14 +130,15 @@ def get_blockprices(
class NordpoolDefaultSensorEntityDescription(SensorEntityDescription):
"""Describes Nord Pool default sensor entity."""
- value_fn: Callable[[DeliveryPeriodData], str | float | datetime | None]
+ value_fn: Callable[[NordpoolSensor], str | float | datetime | None]
@dataclass(frozen=True, kw_only=True)
class NordpoolPricesSensorEntityDescription(SensorEntityDescription):
"""Describes Nord Pool prices sensor entity."""
- value_fn: Callable[[tuple[float, float, float]], float | None]
+ value_fn: Callable[[NordpoolPriceSensor], float | None]
+ extra_fn: Callable[[NordpoolPriceSensor], dict[str, str] | None]
@dataclass(frozen=True, kw_only=True)
@@ -107,19 +155,19 @@ DEFAULT_SENSOR_TYPES: tuple[NordpoolDefaultSensorEntityDescription, ...] = (
key="updated_at",
translation_key="updated_at",
device_class=SensorDeviceClass.TIMESTAMP,
- value_fn=lambda data: data.updated_at,
+ value_fn=lambda entity: entity.coordinator.get_data_current_day().updated_at,
entity_category=EntityCategory.DIAGNOSTIC,
),
NordpoolDefaultSensorEntityDescription(
key="currency",
translation_key="currency",
- value_fn=lambda data: data.currency,
+ value_fn=lambda entity: entity.coordinator.get_data_current_day().currency,
entity_category=EntityCategory.DIAGNOSTIC,
),
NordpoolDefaultSensorEntityDescription(
key="exchange_rate",
translation_key="exchange_rate",
- value_fn=lambda data: data.exchange_rate,
+ value_fn=lambda entity: entity.coordinator.get_data_current_day().exchange_rate,
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
entity_category=EntityCategory.DIAGNOSTIC,
@@ -129,20 +177,43 @@ PRICES_SENSOR_TYPES: tuple[NordpoolPricesSensorEntityDescription, ...] = (
NordpoolPricesSensorEntityDescription(
key="current_price",
translation_key="current_price",
- value_fn=lambda data: data[1] / 1000,
+ value_fn=lambda entity: validate_prices(get_prices, entity, entity.area, 1),
+ extra_fn=lambda entity: None,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=2,
),
NordpoolPricesSensorEntityDescription(
key="last_price",
translation_key="last_price",
- value_fn=lambda data: data[0] / 1000,
+ value_fn=lambda entity: validate_prices(get_prices, entity, entity.area, 0),
+ extra_fn=lambda entity: None,
suggested_display_precision=2,
),
NordpoolPricesSensorEntityDescription(
key="next_price",
translation_key="next_price",
- value_fn=lambda data: data[2] / 1000,
+ value_fn=lambda entity: validate_prices(get_prices, entity, entity.area, 2),
+ extra_fn=lambda entity: None,
+ suggested_display_precision=2,
+ ),
+ NordpoolPricesSensorEntityDescription(
+ key="lowest_price",
+ translation_key="lowest_price",
+ value_fn=lambda entity: get_min_max_price(entity, min)[0] / 1000,
+ extra_fn=lambda entity: {
+ "start": get_min_max_price(entity, min)[1].isoformat(),
+ "end": get_min_max_price(entity, min)[2].isoformat(),
+ },
+ suggested_display_precision=2,
+ ),
+ NordpoolPricesSensorEntityDescription(
+ key="highest_price",
+ translation_key="highest_price",
+ value_fn=lambda entity: get_min_max_price(entity, max)[0] / 1000,
+ extra_fn=lambda entity: {
+ "start": get_min_max_price(entity, max)[1].isoformat(),
+ "end": get_min_max_price(entity, max)[2].isoformat(),
+ },
suggested_display_precision=2,
),
)
@@ -205,11 +276,12 @@ async def async_setup_entry(
"""Set up Nord Pool sensor platform."""
coordinator = entry.runtime_data
+ current_day_data = entry.runtime_data.get_data_current_day()
entities: list[NordpoolBaseEntity] = []
- currency = entry.runtime_data.data.currency
+ currency = current_day_data.currency
- for area in get_prices(entry.runtime_data.data):
+ for area in current_day_data.area_average:
LOGGER.debug("Setting up base sensors for area %s", area)
entities.extend(
NordpoolSensor(coordinator, description, area)
@@ -226,16 +298,16 @@ async def async_setup_entry(
NordpoolDailyAveragePriceSensor(coordinator, description, area, currency)
for description in DAILY_AVERAGE_PRICES_SENSOR_TYPES
)
- for block_name in get_blockprices(coordinator.data)[area]:
+ for block_prices in entry.runtime_data.get_data_current_day().block_prices:
LOGGER.debug(
"Setting up block price sensors for area %s with currency %s in block %s",
area,
currency,
- block_name,
+ block_prices.name,
)
entities.extend(
NordpoolBlockPriceSensor(
- coordinator, description, area, currency, block_name
+ coordinator, description, area, currency, block_prices.name
)
for description in BLOCK_PRICES_SENSOR_TYPES
)
@@ -250,7 +322,7 @@ class NordpoolSensor(NordpoolBaseEntity, SensorEntity):
@property
def native_value(self) -> str | float | datetime | None:
"""Return value of sensor."""
- return self.entity_description.value_fn(self.coordinator.data)
+ return self.entity_description.value_fn(self)
class NordpoolPriceSensor(NordpoolBaseEntity, SensorEntity):
@@ -272,9 +344,12 @@ class NordpoolPriceSensor(NordpoolBaseEntity, SensorEntity):
@property
def native_value(self) -> float | None:
"""Return value of sensor."""
- return self.entity_description.value_fn(
- get_prices(self.coordinator.data)[self.area]
- )
+ return self.entity_description.value_fn(self)
+
+ @property
+ def extra_state_attributes(self) -> dict[str, str] | None:
+ """Return the extra state attributes."""
+ return self.entity_description.extra_fn(self)
class NordpoolBlockPriceSensor(NordpoolBaseEntity, SensorEntity):
@@ -302,7 +377,7 @@ class NordpoolBlockPriceSensor(NordpoolBaseEntity, SensorEntity):
def native_value(self) -> float | datetime | None:
"""Return value of sensor."""
return self.entity_description.value_fn(
- get_blockprices(self.coordinator.data)[self.area][self.block_name]
+ get_blockprices(self)[self.area][self.block_name]
)
@@ -325,4 +400,5 @@ class NordpoolDailyAveragePriceSensor(NordpoolBaseEntity, SensorEntity):
@property
def native_value(self) -> float | None:
"""Return value of sensor."""
- return self.coordinator.data.area_average[self.area] / 1000
+ data = self.coordinator.get_data_current_day()
+ return data.area_average[self.area] / 1000
diff --git a/homeassistant/components/nordpool/services.py b/homeassistant/components/nordpool/services.py
new file mode 100644
index 00000000000..872bd5b1e6b
--- /dev/null
+++ b/homeassistant/components/nordpool/services.py
@@ -0,0 +1,129 @@
+"""Services for Nord Pool integration."""
+
+from __future__ import annotations
+
+from datetime import date, datetime
+import logging
+from typing import TYPE_CHECKING
+
+from pynordpool import (
+ AREAS,
+ Currency,
+ NordPoolAuthenticationError,
+ NordPoolEmptyResponseError,
+ NordPoolError,
+)
+import voluptuous as vol
+
+from homeassistant.config_entries import ConfigEntryState
+from homeassistant.const import ATTR_DATE
+from homeassistant.core import (
+ HomeAssistant,
+ ServiceCall,
+ ServiceResponse,
+ SupportsResponse,
+)
+from homeassistant.exceptions import ServiceValidationError
+from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers.selector import ConfigEntrySelector
+from homeassistant.util import dt as dt_util
+from homeassistant.util.json import JsonValueType
+
+if TYPE_CHECKING:
+ from . import NordPoolConfigEntry
+from .const import DOMAIN
+
+_LOGGER = logging.getLogger(__name__)
+ATTR_CONFIG_ENTRY = "config_entry"
+ATTR_AREAS = "areas"
+ATTR_CURRENCY = "currency"
+
+SERVICE_GET_PRICES_FOR_DATE = "get_prices_for_date"
+SERVICE_GET_PRICES_SCHEMA = vol.Schema(
+ {
+ vol.Required(ATTR_CONFIG_ENTRY): ConfigEntrySelector(),
+ vol.Required(ATTR_DATE): cv.date,
+ vol.Optional(ATTR_AREAS): vol.All(vol.In(list(AREAS)), cv.ensure_list, [str]),
+ vol.Optional(ATTR_CURRENCY): vol.All(
+ cv.string, vol.In([currency.value for currency in Currency])
+ ),
+ }
+)
+
+
+def get_config_entry(hass: HomeAssistant, entry_id: str) -> NordPoolConfigEntry:
+ """Return config entry."""
+ if not (entry := hass.config_entries.async_get_entry(entry_id)):
+ raise ServiceValidationError(
+ translation_domain=DOMAIN,
+ translation_key="entry_not_found",
+ )
+ if entry.state is not ConfigEntryState.LOADED:
+ raise ServiceValidationError(
+ translation_domain=DOMAIN,
+ translation_key="entry_not_loaded",
+ )
+ return entry
+
+
+def async_setup_services(hass: HomeAssistant) -> None:
+ """Set up services for Nord Pool integration."""
+
+ async def get_prices_for_date(call: ServiceCall) -> ServiceResponse:
+ """Get price service."""
+ entry = get_config_entry(hass, call.data[ATTR_CONFIG_ENTRY])
+ asked_date: date = call.data[ATTR_DATE]
+ client = entry.runtime_data.client
+
+ areas: list[str] = entry.data[ATTR_AREAS]
+ if _areas := call.data.get(ATTR_AREAS):
+ areas = _areas
+
+ currency: str = entry.data[ATTR_CURRENCY]
+ if _currency := call.data.get(ATTR_CURRENCY):
+ currency = _currency
+
+ areas = [area.upper() for area in areas]
+ currency = currency.upper()
+
+ try:
+ price_data = await client.async_get_delivery_period(
+ datetime.combine(asked_date, dt_util.utcnow().time()),
+ Currency(currency),
+ areas,
+ )
+ except NordPoolAuthenticationError as error:
+ raise ServiceValidationError(
+ translation_domain=DOMAIN,
+ translation_key="authentication_error",
+ ) from error
+ except NordPoolEmptyResponseError as error:
+ raise ServiceValidationError(
+ translation_domain=DOMAIN,
+ translation_key="empty_response",
+ ) from error
+ except NordPoolError as error:
+ raise ServiceValidationError(
+ translation_domain=DOMAIN,
+ translation_key="connection_error",
+ ) from error
+
+ result: dict[str, JsonValueType] = {}
+ for area in areas:
+ result[area] = [
+ {
+ "start": price_entry.start.isoformat(),
+ "end": price_entry.end.isoformat(),
+ "price": price_entry.entry[area],
+ }
+ for price_entry in price_data.entries
+ ]
+ return result
+
+ hass.services.async_register(
+ DOMAIN,
+ SERVICE_GET_PRICES_FOR_DATE,
+ get_prices_for_date,
+ schema=SERVICE_GET_PRICES_SCHEMA,
+ supports_response=SupportsResponse.ONLY,
+ )
diff --git a/homeassistant/components/nordpool/services.yaml b/homeassistant/components/nordpool/services.yaml
new file mode 100644
index 00000000000..dded8482c6f
--- /dev/null
+++ b/homeassistant/components/nordpool/services.yaml
@@ -0,0 +1,48 @@
+get_prices_for_date:
+ fields:
+ config_entry:
+ required: true
+ selector:
+ config_entry:
+ integration: nordpool
+ date:
+ required: true
+ selector:
+ date:
+ areas:
+ selector:
+ select:
+ options:
+ - "EE"
+ - "LT"
+ - "LV"
+ - "AT"
+ - "BE"
+ - "FR"
+ - "GER"
+ - "NL"
+ - "PL"
+ - "DK1"
+ - "DK2"
+ - "FI"
+ - "NO1"
+ - "NO2"
+ - "NO3"
+ - "NO4"
+ - "NO5"
+ - "SE1"
+ - "SE2"
+ - "SE3"
+ - "SE4"
+ - "SYS"
+ mode: dropdown
+ currency:
+ selector:
+ select:
+ options:
+ - "DKK"
+ - "EUR"
+ - "NOK"
+ - "PLN"
+ - "SEK"
+ mode: dropdown
diff --git a/homeassistant/components/nordpool/strings.json b/homeassistant/components/nordpool/strings.json
index 59ba009eb90..cc10a1a0640 100644
--- a/homeassistant/components/nordpool/strings.json
+++ b/homeassistant/components/nordpool/strings.json
@@ -12,12 +12,20 @@
"data": {
"currency": "Currency",
"areas": "Areas"
+ },
+ "data_description": {
+ "currency": "Select currency to display prices in, EUR is the base currency.",
+ "areas": "Areas to display prices for according to Nordpool market areas."
}
},
"reconfigure": {
"data": {
"currency": "[%key:component::nordpool::config::step::user::data::currency%]",
"areas": "[%key:component::nordpool::config::step::user::data::areas%]"
+ },
+ "data_description": {
+ "currency": "[%key:component::nordpool::config::step::user::data_description::currency%]",
+ "areas": "[%key:component::nordpool::config::step::user::data_description::areas%]"
}
}
}
@@ -42,6 +50,28 @@
"next_price": {
"name": "Next price"
},
+ "lowest_price": {
+ "name": "Lowest price",
+ "state_attributes": {
+ "start": {
+ "name": "Start time"
+ },
+ "end": {
+ "name": "End time"
+ }
+ }
+ },
+ "highest_price": {
+ "name": "Highest price",
+ "state_attributes": {
+ "start": {
+ "name": "[%key:component::nordpool::entity::sensor::lowest_price::state_attributes::start::name%]"
+ },
+ "end": {
+ "name": "[%key:component::nordpool::entity::sensor::lowest_price::state_attributes::end::name%]"
+ }
+ }
+ },
"block_average": {
"name": "{block} average"
},
@@ -61,5 +91,49 @@
"name": "Daily average"
}
}
+ },
+ "services": {
+ "get_prices_for_date": {
+ "name": "Get prices for date",
+ "description": "Retrieve the prices for a specific date.",
+ "fields": {
+ "config_entry": {
+ "name": "Select Nord Pool configuration entry",
+ "description": "Choose the configuration entry."
+ },
+ "date": {
+ "name": "Date",
+ "description": "Only dates two months in the past and one day in the future is allowed."
+ },
+ "areas": {
+ "name": "Areas",
+ "description": "One or multiple areas to get prices for. If left empty it will use the areas already configured."
+ },
+ "currency": {
+ "name": "Currency",
+ "description": "Currency to get prices in. If left empty it will use the currency already configured."
+ }
+ }
+ }
+ },
+ "exceptions": {
+ "initial_update_failed": {
+ "message": "Initial update failed on startup with error {error}"
+ },
+ "entry_not_found": {
+ "message": "The Nord Pool integration is not configured in Home Assistant."
+ },
+ "entry_not_loaded": {
+ "message": "The Nord Pool integration is currently not loaded or disabled in Home Assistant."
+ },
+ "authentication_error": {
+ "message": "There was an authentication error as you tried to retrieve data too far in the past."
+ },
+ "empty_response": {
+ "message": "Nord Pool has not posted market prices for the provided date."
+ },
+ "connection_error": {
+ "message": "There was a connection error connecting to the API. Try again later."
+ }
}
}
diff --git a/homeassistant/components/norway_air/manifest.json b/homeassistant/components/norway_air/manifest.json
index 0c8f15b9b78..5ce6efd944c 100644
--- a/homeassistant/components/norway_air/manifest.json
+++ b/homeassistant/components/norway_air/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/norway_air",
"iot_class": "cloud_polling",
"loggers": ["metno"],
+ "quality_scale": "legacy",
"requirements": ["PyMetno==0.13.0"]
}
diff --git a/homeassistant/components/notify/strings.json b/homeassistant/components/notify/strings.json
index b7d4ec1ad25..e832bfc248a 100644
--- a/homeassistant/components/notify/strings.json
+++ b/homeassistant/components/notify/strings.json
@@ -67,7 +67,7 @@
"fix_flow": {
"step": {
"confirm": {
- "description": "The {integration_title} `notify` actions(s) are migrated. A new `notify` entity is available now to replace each legacy `notify` action.\n\nUpdate any automations to use the new `notify.send_message` action exposed with this new entity. When this is done, fix this issue and restart Home Assistant.",
+ "description": "The {integration_title} `notify` action(s) are migrated. A new `notify` entity is available now to replace each legacy `notify` action.\n\nUpdate any automations to use the new `notify.send_message` action exposed with this new entity. When this is done, fix this issue and restart Home Assistant.",
"title": "Migrate legacy {integration_title} notify action for domain `{domain}`"
}
}
diff --git a/homeassistant/components/notify_events/manifest.json b/homeassistant/components/notify_events/manifest.json
index a2c01e1d718..e154ab85cae 100644
--- a/homeassistant/components/notify_events/manifest.json
+++ b/homeassistant/components/notify_events/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/notify_events",
"iot_class": "cloud_push",
"loggers": ["notify_events"],
+ "quality_scale": "legacy",
"requirements": ["notify-events==1.0.4"]
}
diff --git a/homeassistant/components/nsw_fuel_station/manifest.json b/homeassistant/components/nsw_fuel_station/manifest.json
index 5c105fd0281..3fccab39189 100644
--- a/homeassistant/components/nsw_fuel_station/manifest.json
+++ b/homeassistant/components/nsw_fuel_station/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/nsw_fuel_station",
"iot_class": "cloud_polling",
"loggers": ["nsw_fuel"],
+ "quality_scale": "legacy",
"requirements": ["nsw-fuel-api-client==1.1.0"]
}
diff --git a/homeassistant/components/nsw_rural_fire_service_feed/manifest.json b/homeassistant/components/nsw_rural_fire_service_feed/manifest.json
index 9d1f60e33d1..802f4c89b72 100644
--- a/homeassistant/components/nsw_rural_fire_service_feed/manifest.json
+++ b/homeassistant/components/nsw_rural_fire_service_feed/manifest.json
@@ -6,5 +6,6 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["aio_geojson_nsw_rfs_incidents"],
+ "quality_scale": "legacy",
"requirements": ["aio-geojson-nsw-rfs-incidents==0.7"]
}
diff --git a/homeassistant/components/nuheat/climate.py b/homeassistant/components/nuheat/climate.py
index db85827fc9b..8248c1b9b82 100644
--- a/homeassistant/components/nuheat/climate.py
+++ b/homeassistant/components/nuheat/climate.py
@@ -79,7 +79,6 @@ class NuHeatThermostat(CoordinatorEntity, ClimateEntity):
_attr_has_entity_name = True
_attr_name = None
_attr_preset_modes = PRESET_MODES
- _enable_turn_on_off_backwards_compatibility = False
def __init__(self, coordinator, thermostat, temperature_unit):
"""Initialize the thermostat."""
diff --git a/homeassistant/components/numato/manifest.json b/homeassistant/components/numato/manifest.json
index f7bcf0527c2..81f3793fa6c 100644
--- a/homeassistant/components/numato/manifest.json
+++ b/homeassistant/components/numato/manifest.json
@@ -6,5 +6,6 @@
"integration_type": "hub",
"iot_class": "local_push",
"loggers": ["numato_gpio"],
+ "quality_scale": "legacy",
"requirements": ["numato-gpio==0.13.0"]
}
diff --git a/homeassistant/components/number/__init__.py b/homeassistant/components/number/__init__.py
index dc169fcb348..9f4aef08aa9 100644
--- a/homeassistant/components/number/__init__.py
+++ b/homeassistant/components/number/__init__.py
@@ -384,6 +384,18 @@ class NumberEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
):
return self.hass.config.units.temperature_unit
+ if (translation_key := self._unit_of_measurement_translation_key) and (
+ unit_of_measurement
+ := self.platform.default_language_platform_translations.get(translation_key)
+ ):
+ if native_unit_of_measurement is not None:
+ raise ValueError(
+ f"Number entity {type(self)} from integration '{self.platform.platform_name}' "
+ f"has a translation key for unit_of_measurement '{unit_of_measurement}', "
+ f"but also has a native_unit_of_measurement '{native_unit_of_measurement}'"
+ )
+ return unit_of_measurement
+
return native_unit_of_measurement
@cached_property
diff --git a/homeassistant/components/number/const.py b/homeassistant/components/number/const.py
index 5eea525fb6a..91a9d6adfe4 100644
--- a/homeassistant/components/number/const.py
+++ b/homeassistant/components/number/const.py
@@ -3,7 +3,6 @@
from __future__ import annotations
from enum import StrEnum
-from functools import partial
from typing import Final
import voluptuous as vol
@@ -17,6 +16,8 @@ from homeassistant.const import (
SIGNAL_STRENGTH_DECIBELS,
SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
UnitOfApparentPower,
+ UnitOfArea,
+ UnitOfBloodGlucoseConcentration,
UnitOfConductivity,
UnitOfDataRate,
UnitOfElectricCurrent,
@@ -39,12 +40,6 @@ from homeassistant.const import (
UnitOfVolumeFlowRate,
UnitOfVolumetricFlux,
)
-from homeassistant.helpers.deprecation import (
- DeprecatedConstantEnum,
- all_with_deprecated_constants,
- check_if_deprecated_constant,
- dir_with_deprecated_constants,
-)
from homeassistant.util.unit_conversion import (
BaseUnitConverter,
TemperatureConverter,
@@ -74,12 +69,6 @@ class NumberMode(StrEnum):
SLIDER = "slider"
-# MODE_* are deprecated as of 2021.12, use the NumberMode enum instead.
-_DEPRECATED_MODE_AUTO: Final = DeprecatedConstantEnum(NumberMode.AUTO, "2025.1")
-_DEPRECATED_MODE_BOX: Final = DeprecatedConstantEnum(NumberMode.BOX, "2025.1")
-_DEPRECATED_MODE_SLIDER: Final = DeprecatedConstantEnum(NumberMode.SLIDER, "2025.1")
-
-
class NumberDeviceClass(StrEnum):
"""Device class for numbers."""
@@ -97,6 +86,12 @@ class NumberDeviceClass(StrEnum):
Unit of measurement: `None`
"""
+ AREA = "area"
+ """Area
+
+ Unit of measurement: `UnitOfArea` units
+ """
+
ATMOSPHERIC_PRESSURE = "atmospheric_pressure"
"""Atmospheric pressure.
@@ -109,6 +104,12 @@ class NumberDeviceClass(StrEnum):
Unit of measurement: `%`
"""
+ BLOOD_GLUCOSE_CONCENTRATION = "blood_glucose_concentration"
+ """Blood glucose concentration.
+
+ Unit of measurement: `mg/dL`, `mmol/L`
+ """
+
CO = "carbon_monoxide"
"""Carbon Monoxide gas concentration.
@@ -162,7 +163,7 @@ class NumberDeviceClass(StrEnum):
ENERGY = "energy"
"""Energy.
- Unit of measurement: `Wh`, `kWh`, `MWh`, `GWh`, `TWh`, `MJ`, `GJ`
+ Unit of measurement: `J`, `kJ`, `MJ`, `GJ`, `mWh`, `Wh`, `kWh`, `MWh`, `GWh`, `TWh`, `cal`, `kcal`, `Mcal`, `Gcal`
"""
ENERGY_STORAGE = "energy_storage"
@@ -171,7 +172,7 @@ class NumberDeviceClass(StrEnum):
Use this device class for sensors measuring stored energy, for example the amount
of electric energy currently stored in a battery or the capacity of a battery.
- Unit of measurement: `Wh`, `kWh`, `MWh`, `GWh`, `TWh`, `MJ`, `GJ`
+ Unit of measurement: `J`, `kJ`, `MJ`, `GJ`, `mWh`, `Wh`, `kWh`, `MWh`, `GWh`, `TWh`, `cal`, `kcal`, `Mcal`, `Gcal`
"""
FREQUENCY = "frequency"
@@ -279,7 +280,7 @@ class NumberDeviceClass(StrEnum):
POWER = "power"
"""Power.
- Unit of measurement: `W`, `kW`, `MW`, `GW`, `TW`
+ Unit of measurement: `mW`, `W`, `kW`, `MW`, `GW`, `TW`, `BTU/h`
"""
PRECIPITATION = "precipitation"
@@ -362,7 +363,7 @@ class NumberDeviceClass(StrEnum):
VOLTAGE = "voltage"
"""Voltage.
- Unit of measurement: `V`, `mV`
+ Unit of measurement: `V`, `mV`, `µV`
"""
VOLUME = "volume"
@@ -390,7 +391,7 @@ class NumberDeviceClass(StrEnum):
"""Generic flow rate
Unit of measurement: UnitOfVolumeFlowRate
- - SI / metric: `m³/h`, `L/min`
+ - SI / metric: `m³/h`, `L/min`, `mL/s`
- USCS / imperial: `ft³/min`, `gal/min`
"""
@@ -427,8 +428,10 @@ DEVICE_CLASSES_SCHEMA: Final = vol.All(vol.Lower, vol.Coerce(NumberDeviceClass))
DEVICE_CLASS_UNITS: dict[NumberDeviceClass, set[type[StrEnum] | str | None]] = {
NumberDeviceClass.APPARENT_POWER: set(UnitOfApparentPower),
NumberDeviceClass.AQI: {None},
+ NumberDeviceClass.AREA: set(UnitOfArea),
NumberDeviceClass.ATMOSPHERIC_PRESSURE: set(UnitOfPressure),
NumberDeviceClass.BATTERY: {PERCENTAGE},
+ NumberDeviceClass.BLOOD_GLUCOSE_CONCENTRATION: set(UnitOfBloodGlucoseConcentration),
NumberDeviceClass.CO: {CONCENTRATION_PARTS_PER_MILLION},
NumberDeviceClass.CO2: {CONCENTRATION_PARTS_PER_MILLION},
NumberDeviceClass.CONDUCTIVITY: set(UnitOfConductivity),
@@ -464,7 +467,13 @@ DEVICE_CLASS_UNITS: dict[NumberDeviceClass, set[type[StrEnum] | str | None]] = {
NumberDeviceClass.PM10: {CONCENTRATION_MICROGRAMS_PER_CUBIC_METER},
NumberDeviceClass.PM25: {CONCENTRATION_MICROGRAMS_PER_CUBIC_METER},
NumberDeviceClass.POWER_FACTOR: {PERCENTAGE, None},
- NumberDeviceClass.POWER: {UnitOfPower.WATT, UnitOfPower.KILO_WATT},
+ NumberDeviceClass.POWER: {
+ UnitOfPower.WATT,
+ UnitOfPower.KILO_WATT,
+ UnitOfPower.MEGA_WATT,
+ UnitOfPower.GIGA_WATT,
+ UnitOfPower.TERA_WATT,
+ },
NumberDeviceClass.PRECIPITATION: set(UnitOfPrecipitationDepth),
NumberDeviceClass.PRECIPITATION_INTENSITY: set(UnitOfVolumetricFlux),
NumberDeviceClass.PRESSURE: set(UnitOfPressure),
@@ -503,10 +512,3 @@ UNIT_CONVERTERS: dict[NumberDeviceClass, type[BaseUnitConverter]] = {
NumberDeviceClass.TEMPERATURE: TemperatureConverter,
NumberDeviceClass.VOLUME_FLOW_RATE: VolumeFlowRateConverter,
}
-
-# 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/number/icons.json b/homeassistant/components/number/icons.json
index a122aaecb09..636fa0a7751 100644
--- a/homeassistant/components/number/icons.json
+++ b/homeassistant/components/number/icons.json
@@ -9,12 +9,18 @@
"aqi": {
"default": "mdi:air-filter"
},
+ "area": {
+ "default": "mdi:texture-box"
+ },
"atmospheric_pressure": {
"default": "mdi:thermometer-lines"
},
"battery": {
"default": "mdi:battery"
},
+ "blood_glucose_concentration": {
+ "default": "mdi:spoon-sugar"
+ },
"carbon_dioxide": {
"default": "mdi:molecule-co2"
},
diff --git a/homeassistant/components/number/strings.json b/homeassistant/components/number/strings.json
index 580385172e3..cc77d224d72 100644
--- a/homeassistant/components/number/strings.json
+++ b/homeassistant/components/number/strings.json
@@ -37,12 +37,18 @@
"aqi": {
"name": "[%key:component::sensor::entity_component::aqi::name%]"
},
+ "area": {
+ "name": "[%key:component::sensor::entity_component::area::name%]"
+ },
"atmospheric_pressure": {
"name": "[%key:component::sensor::entity_component::atmospheric_pressure::name%]"
},
"battery": {
"name": "[%key:component::sensor::entity_component::battery::name%]"
},
+ "blood_glucose_concentration": {
+ "name": "[%key:component::sensor::entity_component::blood_glucose_concentration::name%]"
+ },
"carbon_dioxide": {
"name": "[%key:component::sensor::entity_component::carbon_dioxide::name%]"
},
diff --git a/homeassistant/components/nws/manifest.json b/homeassistant/components/nws/manifest.json
index d11a0e62bcf..0e02e652b49 100644
--- a/homeassistant/components/nws/manifest.json
+++ b/homeassistant/components/nws/manifest.json
@@ -6,6 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/nws",
"iot_class": "cloud_polling",
"loggers": ["metar", "pynws"],
- "quality_scale": "platinum",
"requirements": ["pynws[retry]==1.8.2"]
}
diff --git a/homeassistant/components/nx584/manifest.json b/homeassistant/components/nx584/manifest.json
index 84ead05d083..9ac469224d0 100644
--- a/homeassistant/components/nx584/manifest.json
+++ b/homeassistant/components/nx584/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/nx584",
"iot_class": "local_push",
"loggers": ["nx584"],
+ "quality_scale": "legacy",
"requirements": ["pynx584==0.8.2"]
}
diff --git a/homeassistant/components/nyt_games/quality_scale.yaml b/homeassistant/components/nyt_games/quality_scale.yaml
new file mode 100644
index 00000000000..9f455bd4e2c
--- /dev/null
+++ b/homeassistant/components/nyt_games/quality_scale.yaml
@@ -0,0 +1,92 @@
+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: todo
+ docs-installation-instructions: todo
+ docs-removal-instructions: todo
+ 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:
+ status: exempt
+ comment: |
+ This integration does not provide additional actions.
+ config-entry-unloading: done
+ docs-configuration-parameters: todo
+ docs-installation-parameters: todo
+ entity-unavailable:
+ status: exempt
+ comment: |
+ This is handled by the coordinator.
+ integration-owner: done
+ log-when-unavailable:
+ status: done
+ comment: |
+ This is handled by the coordinator.
+ parallel-updates: todo
+ reauthentication-flow: todo
+ test-coverage: done
+ # Gold
+ devices: done
+ diagnostics: todo
+ discovery-update-info:
+ status: exempt
+ comment: |
+ This integration is a service and not discoverable.
+ discovery:
+ status: exempt
+ comment: |
+ This integration is a service and not discoverable.
+ 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:
+ status: done
+ comment: |
+ The entities are categorized well by using default category.
+ 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: |
+ This integration doesn't have any cases where raising an issue is needed.
+ stale-devices:
+ status: exempt
+ comment: |
+ Games can't be "unplayed".
+
+ # Platinum
+ async-dependency: done
+ inject-websession: done
+ strict-typing: todo
diff --git a/homeassistant/components/oasa_telematics/manifest.json b/homeassistant/components/oasa_telematics/manifest.json
index d3dbaad98e3..7365081a959 100644
--- a/homeassistant/components/oasa_telematics/manifest.json
+++ b/homeassistant/components/oasa_telematics/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/oasa_telematics",
"iot_class": "cloud_polling",
"loggers": ["oasatelematics"],
+ "quality_scale": "legacy",
"requirements": ["oasatelematics==0.3"]
}
diff --git a/homeassistant/components/oem/climate.py b/homeassistant/components/oem/climate.py
index cf16f1ba87e..4cecb9ff195 100644
--- a/homeassistant/components/oem/climate.py
+++ b/homeassistant/components/oem/climate.py
@@ -73,7 +73,6 @@ class ThermostatDevice(ClimateEntity):
| ClimateEntityFeature.TURN_ON
)
_attr_temperature_unit = UnitOfTemperature.CELSIUS
- _enable_turn_on_off_backwards_compatibility = False
def __init__(self, thermostat, name):
"""Initialize the device."""
diff --git a/homeassistant/components/oem/manifest.json b/homeassistant/components/oem/manifest.json
index a8ce99b9372..f7ab34adbd9 100644
--- a/homeassistant/components/oem/manifest.json
+++ b/homeassistant/components/oem/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/oem",
"iot_class": "local_polling",
"loggers": ["oemthermostat"],
+ "quality_scale": "legacy",
"requirements": ["oemthermostat==1.1.1"]
}
diff --git a/homeassistant/components/ohmconnect/manifest.json b/homeassistant/components/ohmconnect/manifest.json
index 74754485ea0..e2f02add22d 100644
--- a/homeassistant/components/ohmconnect/manifest.json
+++ b/homeassistant/components/ohmconnect/manifest.json
@@ -4,5 +4,6 @@
"codeowners": ["@robbiet480"],
"documentation": "https://www.home-assistant.io/integrations/ohmconnect",
"iot_class": "cloud_polling",
+ "quality_scale": "legacy",
"requirements": ["defusedxml==0.7.1"]
}
diff --git a/homeassistant/components/ohme/__init__.py b/homeassistant/components/ohme/__init__.py
new file mode 100644
index 00000000000..8518e55c0a3
--- /dev/null
+++ b/homeassistant/components/ohme/__init__.py
@@ -0,0 +1,83 @@
+"""Set up ohme integration."""
+
+from dataclasses import dataclass
+
+from ohme import ApiException, AuthException, OhmeApiClient
+
+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,
+ OhmeDeviceInfoCoordinator,
+)
+from .services import async_setup_services
+
+CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
+
+type OhmeConfigEntry = ConfigEntry[OhmeRuntimeData]
+
+
+@dataclass()
+class OhmeRuntimeData:
+ """Dataclass to hold ohme coordinators."""
+
+ 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:
+ """Set up Ohme from a config entry."""
+
+ client = OhmeApiClient(entry.data[CONF_EMAIL], entry.data[CONF_PASSWORD])
+
+ try:
+ await client.async_login()
+
+ if not await client.async_update_device_info():
+ raise ConfigEntryNotReady(
+ translation_key="device_info_failed", translation_domain=DOMAIN
+ )
+ except AuthException as e:
+ raise ConfigEntryAuthFailed(
+ translation_key="auth_failed", translation_domain=DOMAIN
+ ) from e
+ except ApiException as e:
+ raise ConfigEntryNotReady(
+ translation_key="api_failed", translation_domain=DOMAIN
+ ) from e
+
+ coordinators = (
+ OhmeChargeSessionCoordinator(hass, client),
+ OhmeAdvancedSettingsCoordinator(hass, client),
+ OhmeDeviceInfoCoordinator(hass, client),
+ )
+
+ for coordinator in coordinators:
+ await coordinator.async_config_entry_first_refresh()
+
+ entry.runtime_data = OhmeRuntimeData(*coordinators)
+
+ await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
+
+ return True
+
+
+async def async_unload_entry(hass: HomeAssistant, entry: OhmeConfigEntry) -> bool:
+ """Unload a config entry."""
+
+ return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
diff --git a/homeassistant/components/ohme/button.py b/homeassistant/components/ohme/button.py
new file mode 100644
index 00000000000..0b0590428ce
--- /dev/null
+++ b/homeassistant/components/ohme/button.py
@@ -0,0 +1,68 @@
+"""Platform for button."""
+
+from __future__ import annotations
+
+from collections.abc import Awaitable, Callable
+from dataclasses import dataclass
+
+from ohme import ApiException, ChargerStatus, OhmeApiClient
+
+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 . import OhmeConfigEntry
+from .const import DOMAIN
+from .entity import OhmeEntity, OhmeEntityDescription
+
+PARALLEL_UPDATES = 1
+
+
+@dataclass(frozen=True, kw_only=True)
+class OhmeButtonDescription(OhmeEntityDescription, ButtonEntityDescription):
+ """Class describing Ohme button entities."""
+
+ press_fn: Callable[[OhmeApiClient], Awaitable[None]]
+
+
+BUTTON_DESCRIPTIONS = [
+ OhmeButtonDescription(
+ key="approve",
+ translation_key="approve",
+ press_fn=lambda client: client.async_approve_charge(),
+ is_supported_fn=lambda client: client.is_capable("pluginsRequireApprovalMode"),
+ available_fn=lambda client: client.status is ChargerStatus.PENDING_APPROVAL,
+ ),
+]
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ config_entry: OhmeConfigEntry,
+ async_add_entities: AddEntitiesCallback,
+) -> None:
+ """Set up buttons."""
+ coordinator = config_entry.runtime_data.charge_session_coordinator
+
+ async_add_entities(
+ OhmeButton(coordinator, description)
+ for description in BUTTON_DESCRIPTIONS
+ if description.is_supported_fn(coordinator.client)
+ )
+
+
+class OhmeButton(OhmeEntity, ButtonEntity):
+ """Generic button for Ohme."""
+
+ entity_description: OhmeButtonDescription
+
+ async def async_press(self) -> None:
+ """Handle the button press."""
+ try:
+ await self.entity_description.press_fn(self.coordinator.client)
+ 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/config_flow.py b/homeassistant/components/ohme/config_flow.py
new file mode 100644
index 00000000000..748ea558983
--- /dev/null
+++ b/homeassistant/components/ohme/config_flow.py
@@ -0,0 +1,116 @@
+"""Config flow for ohme integration."""
+
+from collections.abc import Mapping
+from typing import Any
+
+from ohme import ApiException, AuthException, OhmeApiClient
+import voluptuous as vol
+
+from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
+from homeassistant.const import CONF_EMAIL, CONF_PASSWORD
+from homeassistant.helpers.selector import (
+ TextSelector,
+ TextSelectorConfig,
+ TextSelectorType,
+)
+
+from .const import DOMAIN
+
+USER_SCHEMA = vol.Schema(
+ {
+ vol.Required(CONF_EMAIL): TextSelector(
+ TextSelectorConfig(
+ type=TextSelectorType.EMAIL,
+ autocomplete="email",
+ ),
+ ),
+ vol.Required(CONF_PASSWORD): TextSelector(
+ TextSelectorConfig(
+ type=TextSelectorType.PASSWORD,
+ autocomplete="current-password",
+ ),
+ ),
+ }
+)
+
+REAUTH_SCHEMA = vol.Schema(
+ {
+ vol.Required(CONF_PASSWORD): TextSelector(
+ TextSelectorConfig(
+ type=TextSelectorType.PASSWORD,
+ autocomplete="current-password",
+ ),
+ ),
+ }
+)
+
+
+class OhmeConfigFlow(ConfigFlow, domain=DOMAIN):
+ """Config flow."""
+
+ async def async_step_user(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """First config step."""
+
+ errors: dict[str, str] = {}
+
+ if user_input is not None:
+ self._async_abort_entries_match({CONF_EMAIL: user_input[CONF_EMAIL]})
+
+ errors = await self._validate_account(
+ user_input[CONF_EMAIL], user_input[CONF_PASSWORD]
+ )
+ if not errors:
+ return self.async_create_entry(
+ title=user_input[CONF_EMAIL], data=user_input
+ )
+
+ return self.async_show_form(
+ step_id="user", data_schema=USER_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 confirmation."""
+ errors: dict[str, str] = {}
+ reauth_entry = self._get_reauth_entry()
+ if user_input is not None:
+ errors = await self._validate_account(
+ reauth_entry.data[CONF_EMAIL],
+ user_input[CONF_PASSWORD],
+ )
+ if not errors:
+ return self.async_update_reload_and_abort(
+ reauth_entry,
+ data_updates=user_input,
+ )
+ return self.async_show_form(
+ step_id="reauth_confirm",
+ data_schema=REAUTH_SCHEMA,
+ description_placeholders={"email": reauth_entry.data[CONF_EMAIL]},
+ errors=errors,
+ )
+
+ async def _validate_account(self, email: str, password: str) -> dict[str, str]:
+ """Validate Ohme account and return dict of errors."""
+ errors: dict[str, str] = {}
+ client = OhmeApiClient(
+ email,
+ password,
+ )
+ try:
+ await client.async_login()
+ except AuthException:
+ errors["base"] = "invalid_auth"
+ except ApiException:
+ errors["base"] = "unknown"
+
+ return errors
diff --git a/homeassistant/components/ohme/const.py b/homeassistant/components/ohme/const.py
new file mode 100644
index 00000000000..770d18e823a
--- /dev/null
+++ b/homeassistant/components/ohme/const.py
@@ -0,0 +1,6 @@
+"""Component constants."""
+
+from homeassistant.const import Platform
+
+DOMAIN = "ohme"
+PLATFORMS = [Platform.BUTTON, Platform.SENSOR, Platform.SWITCH]
diff --git a/homeassistant/components/ohme/coordinator.py b/homeassistant/components/ohme/coordinator.py
new file mode 100644
index 00000000000..199eb7380a7
--- /dev/null
+++ b/homeassistant/components/ohme/coordinator.py
@@ -0,0 +1,79 @@
+"""Ohme coordinators."""
+
+from abc import abstractmethod
+from datetime import timedelta
+import logging
+
+from ohme import ApiException, OhmeApiClient
+
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
+
+from .const import DOMAIN
+
+_LOGGER = logging.getLogger(__name__)
+
+
+class OhmeBaseCoordinator(DataUpdateCoordinator[None]):
+ """Base for all Ohme coordinators."""
+
+ client: OhmeApiClient
+ _default_update_interval: timedelta | None = timedelta(minutes=1)
+ coordinator_name: str = ""
+
+ def __init__(self, hass: HomeAssistant, client: OhmeApiClient) -> None:
+ """Initialise coordinator."""
+ super().__init__(
+ hass,
+ _LOGGER,
+ name="",
+ update_interval=self._default_update_interval,
+ )
+
+ self.name = f"Ohme {self.coordinator_name}"
+ self.client = client
+
+ async def _async_update_data(self) -> None:
+ """Fetch data from API endpoint."""
+ try:
+ await self._internal_update_data()
+ except ApiException as e:
+ raise UpdateFailed(
+ translation_key="api_failed", translation_domain=DOMAIN
+ ) from e
+
+ @abstractmethod
+ async def _internal_update_data(self) -> None:
+ """Update coordinator data."""
+
+
+class OhmeChargeSessionCoordinator(OhmeBaseCoordinator):
+ """Coordinator to pull all updates from the API."""
+
+ coordinator_name = "Charge Sessions"
+ _default_update_interval = timedelta(seconds=30)
+
+ async def _internal_update_data(self) -> None:
+ """Fetch data from API endpoint."""
+ await self.client.async_get_charge_session()
+
+
+class OhmeAdvancedSettingsCoordinator(OhmeBaseCoordinator):
+ """Coordinator to pull settings and charger state from the API."""
+
+ coordinator_name = "Advanced Settings"
+
+ 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
new file mode 100644
index 00000000000..38e281975a0
--- /dev/null
+++ b/homeassistant/components/ohme/entity.py
@@ -0,0 +1,60 @@
+"""Base class for entities."""
+
+from collections.abc import Callable
+from dataclasses import dataclass
+
+from ohme import OhmeApiClient
+
+from homeassistant.helpers.device_registry import DeviceInfo
+from homeassistant.helpers.entity import EntityDescription
+from homeassistant.helpers.update_coordinator import CoordinatorEntity
+
+from .const import DOMAIN
+from .coordinator import OhmeBaseCoordinator
+
+
+@dataclass(frozen=True)
+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: OhmeEntityDescription,
+ ) -> None:
+ """Initialize the entity."""
+ super().__init__(coordinator)
+
+ self.entity_description = entity_description
+
+ client = coordinator.client
+ self._attr_unique_id = f"{client.serial}_{entity_description.key}"
+
+ device_info = client.device_info
+ self._attr_device_info = DeviceInfo(
+ identifiers={(DOMAIN, client.serial)},
+ name=device_info["name"],
+ manufacturer="Ohme",
+ model=device_info["model"],
+ sw_version=device_info["sw_version"],
+ serial_number=client.serial,
+ )
+
+ @property
+ def available(self) -> bool:
+ """Return if charger reporting as online."""
+ 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
new file mode 100644
index 00000000000..6fa7925aa02
--- /dev/null
+++ b/homeassistant/components/ohme/icons.json
@@ -0,0 +1,46 @@
+{
+ "entity": {
+ "button": {
+ "approve": {
+ "default": "mdi:check-decagram"
+ }
+ },
+ "sensor": {
+ "status": {
+ "default": "mdi:car",
+ "state": {
+ "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"
+ }
+ }
+ }
+ },
+ "services": {
+ "list_charge_slots": {
+ "service": "mdi:clock-start"
+ }
+ }
+}
diff --git a/homeassistant/components/ohme/manifest.json b/homeassistant/components/ohme/manifest.json
new file mode 100644
index 00000000000..935975502d0
--- /dev/null
+++ b/homeassistant/components/ohme/manifest.json
@@ -0,0 +1,11 @@
+{
+ "domain": "ohme",
+ "name": "Ohme",
+ "codeowners": ["@dan-r"],
+ "config_flow": true,
+ "documentation": "https://www.home-assistant.io/integrations/ohme/",
+ "integration_type": "device",
+ "iot_class": "cloud_polling",
+ "quality_scale": "silver",
+ "requirements": ["ohme==1.2.3"]
+}
diff --git a/homeassistant/components/ohme/quality_scale.yaml b/homeassistant/components/ohme/quality_scale.yaml
new file mode 100644
index 00000000000..497d5ad32e5
--- /dev/null
+++ b/homeassistant/components/ohme/quality_scale.yaml
@@ -0,0 +1,74 @@
+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: |
+ This integration has no explicit subscriptions 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: |
+ This integration has no options flow.
+ 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:
+ status: exempt
+ comment: |
+ All supported devices are cloud connected over mobile data. Discovery is not possible.
+ discovery-update-info:
+ status: exempt
+ comment: |
+ All supported devices are cloud connected over mobile data. Discovery is not possible.
+ docs-data-update: todo
+ 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: done
+ entity-disabled-by-default: todo
+ entity-translations: done
+ exception-translations: done
+ icon-translations: done
+ reconfiguration-flow: todo
+ repair-issues:
+ status: exempt
+ comment: |
+ This integration currently has no repairs.
+ stale-devices: todo
+ # Platinum
+ async-dependency: todo
+ inject-websession: todo
+ strict-typing: todo
diff --git a/homeassistant/components/ohme/sensor.py b/homeassistant/components/ohme/sensor.py
new file mode 100644
index 00000000000..230314cba83
--- /dev/null
+++ b/homeassistant/components/ohme/sensor.py
@@ -0,0 +1,119 @@
+"""Platform for sensor."""
+
+from __future__ import annotations
+
+from collections.abc import Callable
+from dataclasses import dataclass
+
+from ohme import ChargerStatus, OhmeApiClient
+
+from homeassistant.components.sensor import (
+ SensorDeviceClass,
+ SensorEntity,
+ SensorEntityDescription,
+ SensorStateClass,
+)
+from homeassistant.const import (
+ PERCENTAGE,
+ UnitOfElectricCurrent,
+ UnitOfEnergy,
+ UnitOfPower,
+)
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+
+from . import OhmeConfigEntry
+from .entity import OhmeEntity, OhmeEntityDescription
+
+PARALLEL_UPDATES = 0
+
+
+@dataclass(frozen=True, kw_only=True)
+class OhmeSensorDescription(OhmeEntityDescription, SensorEntityDescription):
+ """Class describing Ohme sensor entities."""
+
+ value_fn: Callable[[OhmeApiClient], str | int | float]
+
+
+SENSOR_CHARGE_SESSION = [
+ OhmeSensorDescription(
+ key="status",
+ translation_key="status",
+ device_class=SensorDeviceClass.ENUM,
+ options=[e.value for e in ChargerStatus],
+ value_fn=lambda client: client.status.value,
+ ),
+ OhmeSensorDescription(
+ key="current",
+ device_class=SensorDeviceClass.CURRENT,
+ native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
+ value_fn=lambda client: client.power.amps,
+ ),
+ OhmeSensorDescription(
+ key="power",
+ device_class=SensorDeviceClass.POWER,
+ native_unit_of_measurement=UnitOfPower.WATT,
+ suggested_unit_of_measurement=UnitOfPower.KILO_WATT,
+ suggested_display_precision=1,
+ value_fn=lambda client: client.power.watts,
+ ),
+ OhmeSensorDescription(
+ key="energy",
+ device_class=SensorDeviceClass.ENERGY,
+ native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
+ suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
+ suggested_display_precision=1,
+ 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 = [
+ OhmeSensorDescription(
+ key="ct_current",
+ translation_key="ct_current",
+ device_class=SensorDeviceClass.CURRENT,
+ native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
+ value_fn=lambda client: client.power.ct_amps,
+ is_supported_fn=lambda client: client.ct_connected,
+ ),
+]
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ config_entry: OhmeConfigEntry,
+ async_add_entities: AddEntitiesCallback,
+) -> None:
+ """Set up sensors."""
+ coordinators = config_entry.runtime_data
+ coordinator_map = [
+ (SENSOR_CHARGE_SESSION, coordinators.charge_session_coordinator),
+ (SENSOR_ADVANCED_SETTINGS, coordinators.advanced_settings_coordinator),
+ ]
+
+ async_add_entities(
+ OhmeSensor(coordinator, description)
+ for entities, coordinator in coordinator_map
+ for description in entities
+ if description.is_supported_fn(coordinator.client)
+ )
+
+
+class OhmeSensor(OhmeEntity, SensorEntity):
+ """Generic sensor for Ohme."""
+
+ entity_description: OhmeSensorDescription
+
+ @property
+ def native_value(self) -> str | int | float:
+ """Return the state of the sensor."""
+ return self.entity_description.value_fn(self.coordinator.client)
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
new file mode 100644
index 00000000000..4c45f8eca8c
--- /dev/null
+++ b/homeassistant/components/ohme/strings.json
@@ -0,0 +1,100 @@
+{
+ "config": {
+ "step": {
+ "user": {
+ "description": "Configure your Ohme account. If you signed up to Ohme with a third party account like Google, please reset your password via Ohme before configuring this integration.",
+ "data": {
+ "email": "[%key:common::config_flow::data::email%]",
+ "password": "[%key:common::config_flow::data::password%]"
+ },
+ "data_description": {
+ "email": "Enter the email address associated with your Ohme account.",
+ "password": "Enter the password for your Ohme account"
+ }
+ },
+ "reauth_confirm": {
+ "description": "Please update your password for {email}",
+ "title": "[%key:common::config_flow::title::reauth%]",
+ "data": {
+ "password": "[%key:common::config_flow::data::password%]"
+ },
+ "data_description": {
+ "password": "Enter the password for your Ohme account"
+ }
+ }
+ },
+ "error": {
+ "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
+ "unknown": "[%key:common::config_flow::error::unknown%]"
+ },
+ "abort": {
+ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
+ "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
+ }
+ },
+ "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"
+ }
+ },
+ "sensor": {
+ "status": {
+ "name": "Status",
+ "state": {
+ "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"
+ }
+ }
+ },
+ "exceptions": {
+ "auth_failed": {
+ "message": "Unable to login to Ohme"
+ },
+ "device_info_failed": {
+ "message": "Unable to get Ohme device information"
+ },
+ "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/ollama/manifest.json b/homeassistant/components/ollama/manifest.json
index dca4c2dd6be..dbecbf87e4e 100644
--- a/homeassistant/components/ollama/manifest.json
+++ b/homeassistant/components/ollama/manifest.json
@@ -8,5 +8,5 @@
"documentation": "https://www.home-assistant.io/integrations/ollama",
"integration_type": "service",
"iot_class": "local_polling",
- "requirements": ["ollama==0.3.3"]
+ "requirements": ["ollama==0.4.5"]
}
diff --git a/homeassistant/components/ombi/manifest.json b/homeassistant/components/ombi/manifest.json
index d9da13d2381..1afc385a5a7 100644
--- a/homeassistant/components/ombi/manifest.json
+++ b/homeassistant/components/ombi/manifest.json
@@ -4,5 +4,6 @@
"codeowners": ["@larssont"],
"documentation": "https://www.home-assistant.io/integrations/ombi",
"iot_class": "local_polling",
+ "quality_scale": "legacy",
"requirements": ["pyombi==0.1.10"]
}
diff --git a/homeassistant/components/onewire/__init__.py b/homeassistant/components/onewire/__init__.py
index 3c4aac2cd7d..b144e12795e 100644
--- a/homeassistant/components/onewire/__init__.py
+++ b/homeassistant/components/onewire/__init__.py
@@ -4,16 +4,14 @@ import logging
from pyownet import protocol
-from homeassistant.config_entries import ConfigEntry
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 .onewirehub import CannotConnect, OneWireConfigEntry, OneWireHub
_LOGGER = logging.getLogger(__name__)
-type OneWireConfigEntry = ConfigEntry[OneWireHub]
async def async_setup_entry(hass: HomeAssistant, entry: OneWireConfigEntry) -> bool:
diff --git a/homeassistant/components/onewire/binary_sensor.py b/homeassistant/components/onewire/binary_sensor.py
index 5607fd7ed1d..5d3c71b5eae 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 (
@@ -14,10 +15,12 @@ from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
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 OneWireConfigEntry, OneWireHub
+
+PARALLEL_UPDATES = 1
+SCAN_INTERVAL = timedelta(seconds=30)
@dataclass(frozen=True)
diff --git a/homeassistant/components/onewire/config_flow.py b/homeassistant/components/onewire/config_flow.py
index abb4c884974..31c0d35ee4b 100644
--- a/homeassistant/components/onewire/config_flow.py
+++ b/homeassistant/components/onewire/config_flow.py
@@ -7,12 +7,7 @@ from typing import Any
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
@@ -29,7 +24,7 @@ from .const import (
OPTION_ENTRY_SENSOR_PRECISION,
PRECISION_MAPPING_FAMILY_28,
)
-from .onewirehub import CannotConnect, OneWireHub
+from .onewirehub import CannotConnect, OneWireConfigEntry, OneWireHub
DATA_SCHEMA = vol.Schema(
{
@@ -39,21 +34,16 @@ 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.
- """
+async def validate_input(
+ hass: HomeAssistant, data: dict[str, Any], errors: dict[str, str]
+) -> None:
+ """Validate the user input allows us to connect."""
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}
+ try:
+ await hub.connect(data[CONF_HOST], data[CONF_PORT])
+ except CannotConnect:
+ errors["base"] = "cannot_connect"
class OneWireFlowHandler(ConfigFlow, domain=DOMAIN):
@@ -61,48 +51,58 @@ class OneWireFlowHandler(ConfigFlow, domain=DOMAIN):
VERSION = 1
- def __init__(self) -> None:
- """Initialize 1-Wire config flow."""
- self.onewire_config: 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,
)
@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 +126,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))
@@ -144,7 +144,7 @@ class OnewireOptionsFlowHandler(OptionsFlow):
}
if not self.configurable_devices:
- return self.async_abort(reason="No configurable devices found.")
+ return self.async_abort(reason="no_configurable_devices")
return await self.async_step_device_selection(user_input=None)
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..c8ad87fa34e 100644
--- a/homeassistant/components/onewire/entity.py
+++ b/homeassistant/components/onewire/entity.py
@@ -84,4 +84,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 32a08223075..4f3cb5d04ab 100644
--- a/homeassistant/components/onewire/manifest.json
+++ b/homeassistant/components/onewire/manifest.json
@@ -7,6 +7,5 @@
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["pyownet"],
- "quality_scale": "gold",
"requirements": ["pyownet==0.10.0.post1"]
}
diff --git a/homeassistant/components/onewire/onewirehub.py b/homeassistant/components/onewire/onewirehub.py
index 2dc617ba039..3bf4de006f5 100644
--- a/homeassistant/components/onewire/onewirehub.py
+++ b/homeassistant/components/onewire/onewirehub.py
@@ -44,6 +44,8 @@ DEVICE_MANUFACTURER = {
_LOGGER = logging.getLogger(__name__)
+type OneWireConfigEntry = ConfigEntry[OneWireHub]
+
def _is_known_device(device_family: str, device_type: str | None) -> bool:
"""Check if device family/type is known to the library."""
@@ -70,7 +72,7 @@ class OneWireHub:
except protocol.ConnError as exc:
raise CannotConnect from exc
- async def initialize(self, config_entry: ConfigEntry) -> None:
+ async def initialize(self, config_entry: OneWireConfigEntry) -> None:
"""Initialize a config entry."""
host = config_entry.data[CONF_HOST]
port = config_entry.data[CONF_PORT]
diff --git a/homeassistant/components/onewire/quality_scale.yaml b/homeassistant/components/onewire/quality_scale.yaml
new file mode 100644
index 00000000000..a262f9cd714
--- /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: todo
+ comment: mDNS should be possible - https://owfs.org/index_php_page_avahi-discovery.html
+ 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: todo
+ comment: Not yet implemented
+ 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/sensor.py b/homeassistant/components/onewire/sensor.py
index c9030cab8ea..e345550c265 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
@@ -28,7 +29,6 @@ from homeassistant.core import HomeAssistant
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 +39,10 @@ from .const import (
READ_MODE_INT,
)
from .entity import OneWireEntity, OneWireEntityDescription
-from .onewirehub import OneWireHub
+from .onewirehub import OneWireConfigEntry, OneWireHub
+
+PARALLEL_UPDATES = 1
+SCAN_INTERVAL = timedelta(seconds=30)
@dataclasses.dataclass(frozen=True)
@@ -233,7 +236,6 @@ DEVICE_SENSORS: dict[str, tuple[OneWireSensorEntityDescription, ...]] = {
"1D": tuple(
OneWireSensorEntityDescription(
key=f"counter.{device_key}",
- native_unit_of_measurement="count",
read_mode=READ_MODE_INT,
state_class=SensorStateClass.TOTAL_INCREASING,
translation_key="counter_id",
diff --git a/homeassistant/components/onewire/strings.json b/homeassistant/components/onewire/strings.json
index 8dbcbdf8978..cd8615dc5aa 100644
--- a/homeassistant/components/onewire/strings.json
+++ b/homeassistant/components/onewire/strings.json
@@ -1,21 +1,34 @@
{
"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": {
+ "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"
}
}
},
@@ -94,6 +107,9 @@
}
},
"options": {
+ "abort": {
+ "no_configurable_devices": "No configurable devices found"
+ },
"error": {
"device_not_selected": "Select devices to configure"
},
diff --git a/homeassistant/components/onewire/switch.py b/homeassistant/components/onewire/switch.py
index ec0bc44e03f..57f4f41924e 100644
--- a/homeassistant/components/onewire/switch.py
+++ b/homeassistant/components/onewire/switch.py
@@ -3,6 +3,7 @@
from __future__ import annotations
from dataclasses import dataclass
+from datetime import timedelta
import os
from typing import Any
@@ -11,10 +12,12 @@ from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
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 OneWireConfigEntry, OneWireHub
+
+PARALLEL_UPDATES = 1
+SCAN_INTERVAL = timedelta(seconds=30)
@dataclass(frozen=True)
diff --git a/homeassistant/components/onkyo/config_flow.py b/homeassistant/components/onkyo/config_flow.py
index a8ced6fae64..a484b3aaa04 100644
--- a/homeassistant/components/onkyo/config_flow.py
+++ b/homeassistant/components/onkyo/config_flow.py
@@ -4,7 +4,9 @@ import logging
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,
@@ -165,6 +167,49 @@ class OnkyoConfigFlow(ConfigFlow, domain=DOMAIN):
),
)
+ async def async_step_ssdp(
+ self, discovery_info: ssdp.SsdpServiceInfo
+ ) -> ConfigFlowResult:
+ """Handle flow initialized by SSDP discovery."""
+ _LOGGER.debug("Config flow start ssdp: %s", discovery_info)
+
+ if udn := discovery_info.ssdp_udn:
+ udn_parts = udn.split(":")
+ if len(udn_parts) == 2:
+ uuid = udn_parts[1]
+ last_uuid_section = uuid.split("-")[-1].upper()
+ await self.async_set_unique_id(last_uuid_section)
+ self._abort_if_unique_id_configured()
+
+ if discovery_info.ssdp_location is None:
+ _LOGGER.error("SSDP location is None")
+ return self.async_abort(reason="unknown")
+
+ host = URL(discovery_info.ssdp_location).host
+
+ if host is None:
+ _LOGGER.error("SSDP host is None")
+ return self.async_abort(reason="unknown")
+
+ try:
+ info = await async_interview(host)
+ except OSError:
+ _LOGGER.exception("Unexpected exception interviewing host %s", host)
+ return self.async_abort(reason="unknown")
+
+ if info is None:
+ _LOGGER.debug("SSDP eiscp is None: %s", host)
+ return self.async_abort(reason="cannot_connect")
+
+ await self.async_set_unique_id(info.identifier)
+ self._abort_if_unique_id_configured(updates={CONF_HOST: info.host})
+
+ self._receiver_info = info
+
+ title_string = f"{info.model_name} ({info.host})"
+ self.context["title_placeholders"] = {"name": title_string}
+ return await self.async_step_configure_receiver()
+
async def async_step_configure_receiver(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
diff --git a/homeassistant/components/onkyo/manifest.json b/homeassistant/components/onkyo/manifest.json
index 0e75404b3eb..6f37fb61b44 100644
--- a/homeassistant/components/onkyo/manifest.json
+++ b/homeassistant/components/onkyo/manifest.json
@@ -1,11 +1,49 @@
{
"domain": "onkyo",
"name": "Onkyo",
- "codeowners": ["@arturpragacz"],
+ "codeowners": ["@arturpragacz", "@eclair4151"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/onkyo",
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["pyeiscp"],
- "requirements": ["pyeiscp==0.0.7"]
+ "requirements": ["pyeiscp==0.0.7"],
+ "ssdp": [
+ {
+ "manufacturer": "ONKYO",
+ "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1"
+ },
+ {
+ "manufacturer": "ONKYO",
+ "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:2"
+ },
+ {
+ "manufacturer": "ONKYO",
+ "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:3"
+ },
+ {
+ "manufacturer": "Onkyo & Pioneer Corporation",
+ "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1"
+ },
+ {
+ "manufacturer": "Onkyo & Pioneer Corporation",
+ "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:2"
+ },
+ {
+ "manufacturer": "Onkyo & Pioneer Corporation",
+ "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:3"
+ },
+ {
+ "manufacturer": "Pioneer",
+ "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1"
+ },
+ {
+ "manufacturer": "Pioneer",
+ "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:2"
+ },
+ {
+ "manufacturer": "Pioneer",
+ "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:3"
+ }
+ ]
}
diff --git a/homeassistant/components/onkyo/media_player.py b/homeassistant/components/onkyo/media_player.py
index 41e36a7f237..97a82fc8a1a 100644
--- a/homeassistant/components/onkyo/media_player.py
+++ b/homeassistant/components/onkyo/media_player.py
@@ -3,6 +3,7 @@
from __future__ import annotations
import asyncio
+from functools import cache
import logging
from typing import Any, Literal
@@ -19,6 +20,7 @@ from homeassistant.config_entries import SOURCE_IMPORT
from homeassistant.const import CONF_HOST, CONF_NAME
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback
from homeassistant.data_entry_flow import FlowResultType
+from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import config_validation as cv, entity_registry as er
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
@@ -111,6 +113,7 @@ AUDIO_INFORMATION_MAPPING = [
"precision_quartz_lock_system",
"auto_phase_control_delay",
"auto_phase_control_phase",
+ "upmix_mode",
]
VIDEO_INFORMATION_MAPPING = [
@@ -123,13 +126,21 @@ VIDEO_INFORMATION_MAPPING = [
"output_color_schema",
"output_color_depth",
"picture_mode",
+ "input_hdr",
]
ISSUE_URL_PLACEHOLDER = "/config/integrations/dashboard/add?domain=onkyo"
-type InputLibValue = str | tuple[str, ...]
+type LibValue = str | tuple[str, ...]
-def _input_lib_cmds(zone: str) -> dict[InputSource, InputLibValue]:
+def _get_single_lib_value(value: LibValue) -> str:
+ if isinstance(value, str):
+ return value
+ return value[0]
+
+
+@cache
+def _input_source_lib_mappings(zone: str) -> dict[InputSource, LibValue]:
match zone:
case "main":
cmds = PYEISCP_COMMANDS["main"]["SLI"]
@@ -140,7 +151,7 @@ def _input_lib_cmds(zone: str) -> dict[InputSource, InputLibValue]:
case "zone4":
cmds = PYEISCP_COMMANDS["zone4"]["SL4"]
- result: dict[InputSource, InputLibValue] = {}
+ result: dict[InputSource, LibValue] = {}
for k, v in cmds["values"].items():
try:
source = InputSource(k)
@@ -151,6 +162,11 @@ def _input_lib_cmds(zone: str) -> dict[InputSource, InputLibValue]:
return result
+@cache
+def _rev_input_source_lib_mappings(zone: str) -> dict[LibValue, InputSource]:
+ return {value: key for key, value in _input_source_lib_mappings(zone).items()}
+
+
async def async_setup_platform(
hass: HomeAssistant,
config: ConfigType,
@@ -162,7 +178,7 @@ async def async_setup_platform(
source_mapping: dict[str, InputSource] = {}
for zone in ZONES:
- for source, source_lib in _input_lib_cmds(zone).items():
+ for source, source_lib in _input_source_lib_mappings(zone).items():
if isinstance(source_lib, str):
source_mapping.setdefault(source_lib, source)
else:
@@ -351,14 +367,18 @@ class OnkyoMediaPlayer(MediaPlayerEntity):
self._volume_resolution = volume_resolution
self._max_volume = max_volume
- self._name_mapping = sources
- self._reverse_name_mapping = {value: key for key, value in sources.items()}
- self._lib_mapping = _input_lib_cmds(zone)
- self._reverse_lib_mapping = {
- value: key for key, value in self._lib_mapping.items()
+ self._source_lib_mapping = _input_source_lib_mappings(zone)
+ self._rev_source_lib_mapping = _rev_input_source_lib_mappings(zone)
+ self._source_mapping = {
+ key: value
+ for key, value in sources.items()
+ if key in self._source_lib_mapping
+ }
+ self._rev_source_mapping = {
+ value: key for key, value in self._source_mapping.items()
}
- self._attr_source_list = list(sources.values())
+ self._attr_source_list = list(self._rev_source_mapping)
self._attr_extra_state_attributes = {}
async def async_added_to_hass(self) -> None:
@@ -407,7 +427,7 @@ class OnkyoMediaPlayer(MediaPlayerEntity):
"""
# HA_VOL * (MAX VOL / 100) * VOL_RESOLUTION
self._update_receiver(
- "volume", int(volume * (self._max_volume / 100) * self._volume_resolution)
+ "volume", round(volume * (self._max_volume / 100) * self._volume_resolution)
)
async def async_volume_up(self) -> None:
@@ -427,12 +447,18 @@ class OnkyoMediaPlayer(MediaPlayerEntity):
async def async_select_source(self, source: str) -> None:
"""Select input source."""
- if self.source_list and source in self.source_list:
- source_lib = self._lib_mapping[self._reverse_name_mapping[source]]
- if isinstance(source_lib, str):
- source_lib_single = source_lib
- else:
- source_lib_single = source_lib[0]
+ if not self.source_list or source not in self.source_list:
+ raise ServiceValidationError(
+ translation_domain=DOMAIN,
+ translation_key="invalid_source",
+ translation_placeholders={
+ "invalid_source": source,
+ "entity_id": self.entity_id,
+ },
+ )
+
+ source_lib = self._source_lib_mapping[self._rev_source_mapping[source]]
+ source_lib_single = _get_single_lib_value(source_lib)
self._update_receiver(
"input-selector" if self._zone == "main" else "selector", source_lib_single
)
@@ -446,7 +472,7 @@ class OnkyoMediaPlayer(MediaPlayerEntity):
) -> None:
"""Play radio station by preset number."""
if self.source is not None:
- source = self._reverse_name_mapping[self.source]
+ source = self._rev_source_mapping[self.source]
if media_type.lower() == "radio" and source in DEFAULT_PLAYABLE_SOURCES:
self._update_receiver("preset", media_id)
@@ -518,15 +544,17 @@ class OnkyoMediaPlayer(MediaPlayerEntity):
self.async_write_ha_state()
@callback
- def _parse_source(self, source_lib: InputLibValue) -> None:
- source = self._reverse_lib_mapping[source_lib]
- if source in self._name_mapping:
- self._attr_source = self._name_mapping[source]
+ def _parse_source(self, source_lib: LibValue) -> None:
+ source = self._rev_source_lib_mapping[source_lib]
+ if source in self._source_mapping:
+ self._attr_source = self._source_mapping[source]
return
source_meaning = source.value_meaning
_LOGGER.error(
- 'Input source "%s" not in source list: %s', source_meaning, self.entity_id
+ 'Input source "%s" is invalid for entity: %s',
+ source_meaning,
+ self.entity_id,
)
self._attr_source = source_meaning
diff --git a/homeassistant/components/onkyo/quality_scale.yaml b/homeassistant/components/onkyo/quality_scale.yaml
new file mode 100644
index 00000000000..cdcf88e72d7
--- /dev/null
+++ b/homeassistant/components/onkyo/quality_scale.yaml
@@ -0,0 +1,83 @@
+rules:
+ # Bronze
+ action-setup: done
+ appropriate-polling:
+ status: exempt
+ comment: |
+ This integration uses a push API. No polling required.
+ brands: done
+ common-modules: done
+ config-flow: done
+ config-flow-test-coverage:
+ status: todo
+ comment: |
+ Coverage is 100%, but the tests need to be improved.
+ dependency-transparency: done
+ docs-actions: done
+ docs-high-level-description: done
+ docs-installation-instructions: done
+ docs-removal-instructions: todo
+ entity-event-setup:
+ status: done
+ comment: |
+ Currently we store created entities in hass.data. That should be removed in the future.
+ entity-unique-id: done
+ has-entity-name: todo
+ 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: done
+ docs-installation-parameters: done
+ 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: todo
+ discovery-update-info: todo
+ docs-data-update: todo
+ docs-examples: todo
+ docs-known-limitations: todo
+ docs-supported-devices: done
+ docs-supported-functions: todo
+ docs-troubleshooting: todo
+ docs-use-cases: todo
+ dynamic-devices:
+ status: exempt
+ comment: |
+ This integration has a fixed single device.
+ entity-category: done
+ entity-device-class: todo
+ entity-disabled-by-default: done
+ entity-translations: todo
+ exception-translations: todo
+ icon-translations: todo
+ reconfiguration-flow: done
+ repair-issues: done
+ stale-devices:
+ status: exempt
+ comment: |
+ This integration has a fixed single device.
+
+ # Platinum
+ async-dependency: done
+ inject-websession:
+ status: exempt
+ comment: |
+ This integration is not making any HTTP requests.
+ strict-typing:
+ status: todo
+ comment: |
+ The library is not fully typed yet.
diff --git a/homeassistant/components/onkyo/strings.json b/homeassistant/components/onkyo/strings.json
index 1b0eadcc45e..849171c7161 100644
--- a/homeassistant/components/onkyo/strings.json
+++ b/homeassistant/components/onkyo/strings.json
@@ -10,18 +10,28 @@
"manual": {
"data": {
"host": "[%key:common::config_flow::data::host%]"
+ },
+ "data_description": {
+ "host": "Hostname or IP address of the receiver."
}
},
"eiscp_discovery": {
"data": {
"device": "[%key:common::config_flow::data::device%]"
+ },
+ "data_description": {
+ "device": "Select the receiver to configure."
}
},
"configure_receiver": {
"description": "Configure {name}",
"data": {
- "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"
+ "volume_resolution": "Volume resolution",
+ "input_sources": "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."
}
}
},
@@ -43,6 +53,9 @@
"init": {
"data": {
"max_volume": "Maximum volume limit (%)"
+ },
+ "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."
}
}
}
@@ -56,5 +69,10 @@
"title": "The Onkyo YAML configuration import failed",
"description": "Configuring Onkyo using YAML is being removed but there was a connection error when importing your YAML configuration for host {host}.\n\nEnsure the connection to the receiver works and restart Home Assistant to try again or remove the Onkyo YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually."
}
+ },
+ "exceptions": {
+ "invalid_source": {
+ "message": "Cannot select input source \"{invalid_source}\" for entity: {entity_id}."
+ }
}
}
diff --git a/homeassistant/components/onvif/manifest.json b/homeassistant/components/onvif/manifest.json
index d03073dcfd3..02ef16b6787 100644
--- a/homeassistant/components/onvif/manifest.json
+++ b/homeassistant/components/onvif/manifest.json
@@ -8,5 +8,5 @@
"documentation": "https://www.home-assistant.io/integrations/onvif",
"iot_class": "local_push",
"loggers": ["onvif", "wsdiscovery", "zeep"],
- "requirements": ["onvif-zeep-async==3.1.12", "WSDiscovery==2.0.0"]
+ "requirements": ["onvif-zeep-async==3.1.13", "WSDiscovery==2.0.0"]
}
diff --git a/homeassistant/components/onvif/parsers.py b/homeassistant/components/onvif/parsers.py
index 57bd8a974db..d7bbaa4fb3f 100644
--- a/homeassistant/components/onvif/parsers.py
+++ b/homeassistant/components/onvif/parsers.py
@@ -370,6 +370,63 @@ async def async_parse_vehicle_detector(uid: str, msg) -> Event | None:
return None
+@PARSERS.register("tns1:RuleEngine/TPSmartEventDetector/TPSmartEvent")
+@PARSERS.register("tns1:RuleEngine/PeopleDetector/People")
+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/PeopleDetector/People
+ """
+ video_source = ""
+ video_analytics = ""
+ rule = ""
+ topic = ""
+ vehicle = False
+ person = False
+ enabled = False
+ try:
+ topic, payload = extract_message(msg)
+ for source in payload.Source.SimpleItem:
+ if source.Name == "VideoSourceConfigurationToken":
+ video_source = _normalize_video_source(source.Value)
+ if source.Name == "VideoAnalyticsConfigurationToken":
+ video_analytics = source.Value
+ if source.Name == "Rule":
+ 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"
+ 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
+
+
@PARSERS.register("tns1:RuleEngine/MyRuleDetector/PeopleDetect")
async def async_parse_person_detector(uid: str, msg) -> Event | None:
"""Handle parsing event message.
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/manifest.json b/homeassistant/components/open_meteo/manifest.json
index abdb59a48d0..a2f2a724ad5 100644
--- a/homeassistant/components/open_meteo/manifest.json
+++ b/homeassistant/components/open_meteo/manifest.json
@@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/open_meteo",
"integration_type": "service",
"iot_class": "cloud_polling",
- "requirements": ["open-meteo==0.3.1"]
+ "requirements": ["open-meteo==0.3.2"]
}
diff --git a/homeassistant/components/open_meteo/weather.py b/homeassistant/components/open_meteo/weather.py
index a2be81f0928..51ee91de083 100644
--- a/homeassistant/components/open_meteo/weather.py
+++ b/homeassistant/components/open_meteo/weather.py
@@ -2,6 +2,8 @@
from __future__ import annotations
+from datetime import datetime, time
+
from open_meteo import Forecast as OpenMeteoForecast
from homeassistant.components.weather import (
@@ -15,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
@@ -24,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)])
@@ -53,7 +55,7 @@ class OpenMeteoWeatherEntity(
def __init__(
self,
*,
- entry: ConfigEntry,
+ entry: OpenMeteoConfigEntry,
coordinator: DataUpdateCoordinator[OpenMeteoForecast],
) -> None:
"""Initialize Open-Meteo weather entity."""
@@ -107,8 +109,9 @@ class OpenMeteoWeatherEntity(
daily = self.coordinator.data.daily
for index, date in enumerate(self.coordinator.data.daily.time):
+ _datetime = datetime.combine(date=date, time=time(0), tzinfo=dt_util.UTC)
forecast = Forecast(
- datetime=date.isoformat(),
+ datetime=_datetime.isoformat(),
)
if daily.weathercode is not None:
@@ -155,12 +158,14 @@ class OpenMeteoWeatherEntity(
today = dt_util.utcnow()
hourly = self.coordinator.data.hourly
- for index, datetime in enumerate(self.coordinator.data.hourly.time):
- if dt_util.as_utc(datetime) < today:
+ for index, _datetime in enumerate(self.coordinator.data.hourly.time):
+ if _datetime.tzinfo is None:
+ _datetime = _datetime.replace(tzinfo=dt_util.UTC)
+ if _datetime < today:
continue
forecast = Forecast(
- datetime=datetime.isoformat(),
+ datetime=_datetime.isoformat(),
)
if hourly.weather_code is not None:
diff --git a/homeassistant/components/openai_conversation/conversation.py b/homeassistant/components/openai_conversation/conversation.py
index 9c73766c8d4..b3f31ae9b47 100644
--- a/homeassistant/components/openai_conversation/conversation.py
+++ b/homeassistant/components/openai_conversation/conversation.py
@@ -1,6 +1,7 @@
"""Conversation support for OpenAI."""
from collections.abc import Callable
+from dataclasses import dataclass, field
import json
from typing import Any, Literal
@@ -73,6 +74,14 @@ def _format_tool(
return ChatCompletionToolParam(type="function", function=tool_spec)
+@dataclass
+class ChatHistory:
+ """Class holding the chat history."""
+
+ extra_system_prompt: str | None = None
+ messages: list[ChatCompletionMessageParam] = field(default_factory=list)
+
+
class OpenAIConversationEntity(
conversation.ConversationEntity, conversation.AbstractConversationAgent
):
@@ -84,7 +93,7 @@ class OpenAIConversationEntity(
def __init__(self, entry: OpenAIConfigEntry) -> None:
"""Initialize the agent."""
self.entry = entry
- self.history: dict[str, list[ChatCompletionMessageParam]] = {}
+ self.history: dict[str, ChatHistory] = {}
self._attr_unique_id = entry.entry_id
self._attr_device_info = dr.DeviceInfo(
identifiers={(DOMAIN, entry.entry_id)},
@@ -157,13 +166,14 @@ class OpenAIConversationEntity(
_format_tool(tool, llm_api.custom_serializer) for tool in llm_api.tools
]
+ history: ChatHistory | None = None
+
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]
+ history = self.history.get(conversation_id)
else:
# Conversation IDs are ULIDs. We generate a new one if not provided.
@@ -176,7 +186,8 @@ class OpenAIConversationEntity(
except ValueError:
conversation_id = user_input.conversation_id
- messages = []
+ if history is None:
+ history = ChatHistory(user_input.extra_system_prompt)
if (
user_input.context
@@ -217,20 +228,31 @@ class OpenAIConversationEntity(
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 history.extra_system_prompt
+ )
+
+ if extra_system_prompt:
+ prompt_parts.append(extra_system_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),
- ]
+ history = ChatHistory(
+ extra_system_prompt,
+ [
+ ChatCompletionSystemMessageParam(role="system", content=prompt),
+ *history.messages[1:],
+ ChatCompletionUserMessageParam(role="user", content=user_input.text),
+ ],
+ )
- LOGGER.debug("Prompt: %s", messages)
+ LOGGER.debug("Prompt: %s", history.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},
+ {"messages": history.messages, "tools": llm_api.tools if llm_api else None},
)
client = self.entry.runtime_data
@@ -240,7 +262,7 @@ class OpenAIConversationEntity(
try:
result = await client.chat.completions.create(
model=options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL),
- messages=messages,
+ messages=history.messages,
tools=tools or NOT_GIVEN,
max_tokens=options.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS),
top_p=options.get(CONF_TOP_P, RECOMMENDED_TOP_P),
@@ -286,7 +308,7 @@ class OpenAIConversationEntity(
param["tool_calls"] = tool_calls
return param
- messages.append(message_convert(response))
+ history.messages.append(message_convert(response))
tool_calls = response.tool_calls
if not tool_calls or not llm_api:
@@ -309,7 +331,7 @@ class OpenAIConversationEntity(
tool_response["error_text"] = str(e)
LOGGER.debug("Tool response: %s", tool_response)
- messages.append(
+ history.messages.append(
ChatCompletionToolMessageParam(
role="tool",
tool_call_id=tool_call.id,
@@ -317,7 +339,7 @@ class OpenAIConversationEntity(
)
)
- self.history[conversation_id] = messages
+ self.history[conversation_id] = history
intent_response = intent.IntentResponse(language=user_input.language)
intent_response.async_set_speech(response.content or "")
diff --git a/homeassistant/components/openalpr_cloud/manifest.json b/homeassistant/components/openalpr_cloud/manifest.json
index 45bce5c7345..5148cb396b6 100644
--- a/homeassistant/components/openalpr_cloud/manifest.json
+++ b/homeassistant/components/openalpr_cloud/manifest.json
@@ -3,5 +3,6 @@
"name": "OpenALPR Cloud",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/openalpr_cloud",
- "iot_class": "cloud_push"
+ "iot_class": "cloud_push",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/openerz/manifest.json b/homeassistant/components/openerz/manifest.json
index c7a5a202568..f75e3e492a8 100644
--- a/homeassistant/components/openerz/manifest.json
+++ b/homeassistant/components/openerz/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/openerz",
"iot_class": "cloud_polling",
"loggers": ["openerz_api"],
+ "quality_scale": "legacy",
"requirements": ["openerz-api==0.3.0"]
}
diff --git a/homeassistant/components/openevse/manifest.json b/homeassistant/components/openevse/manifest.json
index 066eb5ee384..45452fe325b 100644
--- a/homeassistant/components/openevse/manifest.json
+++ b/homeassistant/components/openevse/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/openevse",
"iot_class": "local_polling",
"loggers": ["openevsewifi"],
+ "quality_scale": "legacy",
"requirements": ["openevsewifi==1.1.2"]
}
diff --git a/homeassistant/components/openhardwaremonitor/manifest.json b/homeassistant/components/openhardwaremonitor/manifest.json
index 562a2433eab..901424eebc1 100644
--- a/homeassistant/components/openhardwaremonitor/manifest.json
+++ b/homeassistant/components/openhardwaremonitor/manifest.json
@@ -3,5 +3,6 @@
"name": "Open Hardware Monitor",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/openhardwaremonitor",
- "iot_class": "local_polling"
+ "iot_class": "local_polling",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/openhome/strings.json b/homeassistant/components/openhome/strings.json
index b13fb997b7f..f4b15e52e7c 100644
--- a/homeassistant/components/openhome/strings.json
+++ b/homeassistant/components/openhome/strings.json
@@ -1,12 +1,12 @@
{
"services": {
"invoke_pin": {
- "name": "Invoke PIN",
- "description": "Invokes a pin on the specified device.",
+ "name": "Play pin",
+ "description": "Starts playing content pinned on the specified device.",
"fields": {
"pin": {
- "name": "PIN",
- "description": "Which pin to invoke."
+ "name": "Pin ID",
+ "description": "ID of the pinned content."
}
}
}
diff --git a/homeassistant/components/opensensemap/manifest.json b/homeassistant/components/opensensemap/manifest.json
index 8fed7ec906e..0256ae42a3a 100644
--- a/homeassistant/components/opensensemap/manifest.json
+++ b/homeassistant/components/opensensemap/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/opensensemap",
"iot_class": "cloud_polling",
"loggers": ["opensensemap_api"],
+ "quality_scale": "legacy",
"requirements": ["opensensemap-api==0.2.0"]
}
diff --git a/homeassistant/components/opentherm_gw/__init__.py b/homeassistant/components/opentherm_gw/__init__.py
index 5ce9d808b21..8c92c70ab49 100644
--- a/homeassistant/components/opentherm_gw/__init__.py
+++ b/homeassistant/components/opentherm_gw/__init__.py
@@ -47,6 +47,7 @@ from .const import (
CONF_CLIMATE,
CONF_FLOOR_TEMP,
CONF_PRECISION,
+ CONF_TEMPORARY_OVRD_MODE,
CONNECTION_TIMEOUT,
DATA_GATEWAYS,
DATA_OPENTHERM_GW,
@@ -105,6 +106,7 @@ PLATFORMS = [
async def options_updated(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Handle options update."""
gateway = hass.data[DATA_OPENTHERM_GW][DATA_GATEWAYS][entry.data[CONF_ID]]
+ gateway.options = entry.options
async_dispatcher_send(hass, gateway.options_update_signal, entry)
@@ -469,7 +471,7 @@ class OpenThermGatewayHub:
self.device_path = config_entry.data[CONF_DEVICE]
self.hub_id = config_entry.data[CONF_ID]
self.name = config_entry.data[CONF_NAME]
- self.climate_config = config_entry.options
+ self.options = config_entry.options
self.config_entry_id = config_entry.entry_id
self.update_signal = f"{DATA_OPENTHERM_GW}_{self.hub_id}_update"
self.options_update_signal = f"{DATA_OPENTHERM_GW}_{self.hub_id}_options_update"
@@ -565,3 +567,9 @@ class OpenThermGatewayHub:
def connected(self):
"""Report whether or not we are connected to the gateway."""
return self.gateway.connection.connected
+
+ async def set_room_setpoint(self, temp) -> float:
+ """Set the room temperature setpoint on the gateway. Return the new temperature."""
+ return await self.gateway.set_target_temp(
+ temp, self.options.get(CONF_TEMPORARY_OVRD_MODE, True)
+ )
diff --git a/homeassistant/components/opentherm_gw/button.py b/homeassistant/components/opentherm_gw/button.py
index bac50295199..00b91ad33e0 100644
--- a/homeassistant/components/opentherm_gw/button.py
+++ b/homeassistant/components/opentherm_gw/button.py
@@ -16,7 +16,12 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import OpenThermGatewayHub
-from .const import DATA_GATEWAYS, DATA_OPENTHERM_GW, GATEWAY_DEVICE_DESCRIPTION
+from .const import (
+ DATA_GATEWAYS,
+ DATA_OPENTHERM_GW,
+ GATEWAY_DEVICE_DESCRIPTION,
+ THERMOSTAT_DEVICE_DESCRIPTION,
+)
from .entity import OpenThermEntity, OpenThermEntityDescription
@@ -30,6 +35,12 @@ class OpenThermButtonEntityDescription(
BUTTON_DESCRIPTIONS: tuple[OpenThermButtonEntityDescription, ...] = (
+ OpenThermButtonEntityDescription(
+ key="cancel_room_setpoint_override",
+ translation_key="cancel_room_setpoint_override",
+ device_description=THERMOSTAT_DEVICE_DESCRIPTION,
+ action=lambda hub: hub.set_room_setpoint(0),
+ ),
OpenThermButtonEntityDescription(
key="restart_button",
device_class=ButtonDeviceClass.RESTART,
diff --git a/homeassistant/components/opentherm_gw/climate.py b/homeassistant/components/opentherm_gw/climate.py
index 6edfeb35ec3..e8aa99f7325 100644
--- a/homeassistant/components/opentherm_gw/climate.py
+++ b/homeassistant/components/opentherm_gw/climate.py
@@ -28,7 +28,6 @@ from . import OpenThermGatewayHub
from .const import (
CONF_READ_PRECISION,
CONF_SET_PRECISION,
- CONF_TEMPORARY_OVRD_MODE,
DATA_GATEWAYS,
DATA_OPENTHERM_GW,
THERMOSTAT_DEVICE_DESCRIPTION,
@@ -86,7 +85,7 @@ class OpenThermClimate(OpenThermStatusEntity, ClimateEntity):
_away_mode_b: int | None = None
_away_state_a = False
_away_state_b = False
- _enable_turn_on_off_backwards_compatibility = False
+
_target_temperature: float | None = None
_new_target_temperature: float | None = None
entity_description: OpenThermClimateEntityDescription
@@ -102,14 +101,12 @@ class OpenThermClimate(OpenThermStatusEntity, ClimateEntity):
if CONF_READ_PRECISION in options:
self._attr_precision = options[CONF_READ_PRECISION]
self._attr_target_temperature_step = options.get(CONF_SET_PRECISION)
- self.temporary_ovrd_mode = options.get(CONF_TEMPORARY_OVRD_MODE, True)
@callback
def update_options(self, entry):
"""Update climate entity options."""
self._attr_precision = entry.options[CONF_READ_PRECISION]
self._attr_target_temperature_step = entry.options[CONF_SET_PRECISION]
- self.temporary_ovrd_mode = entry.options[CONF_TEMPORARY_OVRD_MODE]
self.async_write_ha_state()
async def async_added_to_hass(self) -> None:
@@ -195,7 +192,5 @@ class OpenThermClimate(OpenThermStatusEntity, ClimateEntity):
temp = float(kwargs[ATTR_TEMPERATURE])
if temp == self.target_temperature:
return
- self._new_target_temperature = await self._gateway.gateway.set_target_temp(
- temp, self.temporary_ovrd_mode
- )
+ self._new_target_temperature = await self._gateway.set_room_setpoint(temp)
self.async_write_ha_state()
diff --git a/homeassistant/components/opentherm_gw/strings.json b/homeassistant/components/opentherm_gw/strings.json
index 834168eb113..77c7e3ab40a 100644
--- a/homeassistant/components/opentherm_gw/strings.json
+++ b/homeassistant/components/opentherm_gw/strings.json
@@ -158,6 +158,11 @@
"name": "Programmed change has priority over override"
}
},
+ "button": {
+ "cancel_room_setpoint_override": {
+ "name": "Cancel room setpoint override"
+ }
+ },
"select": {
"gpio_mode_n": {
"name": "GPIO {gpio_id} mode",
@@ -380,7 +385,7 @@
},
"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%]",
@@ -412,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%]",
@@ -420,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."
}
}
},
@@ -434,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."
}
}
},
@@ -448,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."
}
}
},
@@ -466,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."
}
}
},
@@ -484,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%]",
@@ -498,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%]",
@@ -512,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/opnsense/manifest.json b/homeassistant/components/opnsense/manifest.json
index bf8a41d1785..4dd82216f1a 100644
--- a/homeassistant/components/opnsense/manifest.json
+++ b/homeassistant/components/opnsense/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/opnsense",
"iot_class": "local_polling",
"loggers": ["pbr", "pyopnsense"],
+ "quality_scale": "legacy",
"requirements": ["pyopnsense==0.4.0"]
}
diff --git a/homeassistant/components/opower/manifest.json b/homeassistant/components/opower/manifest.json
index 593e4cf34b8..bd68cc84d13 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.6"]
+ "requirements": ["opower==0.8.7"]
}
diff --git a/homeassistant/components/opple/light.py b/homeassistant/components/opple/light.py
index a4aa98bbf69..da2993d1996 100644
--- a/homeassistant/components/opple/light.py
+++ b/homeassistant/components/opple/light.py
@@ -10,7 +10,7 @@ import voluptuous as vol
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
- ATTR_COLOR_TEMP,
+ ATTR_COLOR_TEMP_KELVIN,
PLATFORM_SCHEMA as LIGHT_PLATFORM_SCHEMA,
ColorMode,
LightEntity,
@@ -20,10 +20,6 @@ from homeassistant.core import HomeAssistant
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
-from homeassistant.util.color import (
- color_temperature_kelvin_to_mired as kelvin_to_mired,
- color_temperature_mired_to_kelvin as mired_to_kelvin,
-)
_LOGGER = logging.getLogger(__name__)
@@ -58,6 +54,8 @@ class OppleLight(LightEntity):
_attr_color_mode = ColorMode.COLOR_TEMP
_attr_supported_color_modes = {ColorMode.COLOR_TEMP}
+ _attr_min_color_temp_kelvin = 3000 # 333 Mireds
+ _attr_max_color_temp_kelvin = 5700 # 175 Mireds
def __init__(self, name, host):
"""Initialize an Opple light."""
@@ -67,7 +65,6 @@ class OppleLight(LightEntity):
self._name = name
self._is_on = None
self._brightness = None
- self._color_temp = None
@property
def available(self) -> bool:
@@ -94,21 +91,6 @@ class OppleLight(LightEntity):
"""Return the brightness of the light."""
return self._brightness
- @property
- def color_temp(self):
- """Return the color temperature of this light."""
- return kelvin_to_mired(self._color_temp)
-
- @property
- def min_mireds(self):
- """Return minimum supported color temperature."""
- return 175
-
- @property
- def max_mireds(self):
- """Return maximum supported color temperature."""
- return 333
-
def turn_on(self, **kwargs: Any) -> None:
"""Instruct the light to turn on."""
_LOGGER.debug("Turn on light %s %s", self._device.ip, kwargs)
@@ -118,9 +100,11 @@ class OppleLight(LightEntity):
if ATTR_BRIGHTNESS in kwargs and self.brightness != kwargs[ATTR_BRIGHTNESS]:
self._device.brightness = kwargs[ATTR_BRIGHTNESS]
- if ATTR_COLOR_TEMP in kwargs and self.color_temp != kwargs[ATTR_COLOR_TEMP]:
- color_temp = mired_to_kelvin(kwargs[ATTR_COLOR_TEMP])
- self._device.color_temperature = color_temp
+ if (
+ ATTR_COLOR_TEMP_KELVIN in kwargs
+ and self.color_temp_kelvin != kwargs[ATTR_COLOR_TEMP_KELVIN]
+ ):
+ self._device.color_temperature = kwargs[ATTR_COLOR_TEMP_KELVIN]
def turn_off(self, **kwargs: Any) -> None:
"""Instruct the light to turn off."""
@@ -136,7 +120,7 @@ class OppleLight(LightEntity):
prev_available == self.available
and self._is_on == self._device.power_on
and self._brightness == self._device.brightness
- and self._color_temp == self._device.color_temperature
+ and self._attr_color_temp_kelvin == self._device.color_temperature
):
return
@@ -146,7 +130,7 @@ class OppleLight(LightEntity):
self._is_on = self._device.power_on
self._brightness = self._device.brightness
- self._color_temp = self._device.color_temperature
+ self._attr_color_temp_kelvin = self._device.color_temperature
if not self.is_on:
_LOGGER.debug("Update light %s success: power off", self._device.ip)
@@ -155,5 +139,5 @@ class OppleLight(LightEntity):
"Update light %s success: power on brightness %s color temperature %s",
self._device.ip,
self._brightness,
- self._color_temp,
+ self._attr_color_temp_kelvin,
)
diff --git a/homeassistant/components/opple/manifest.json b/homeassistant/components/opple/manifest.json
index 174907dfd0f..dc28d1f0f33 100644
--- a/homeassistant/components/opple/manifest.json
+++ b/homeassistant/components/opple/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/opple",
"iot_class": "local_polling",
"loggers": ["pyoppleio"],
+ "quality_scale": "legacy",
"requirements": ["pyoppleio-legacy==1.0.8"]
}
diff --git a/homeassistant/components/oru/manifest.json b/homeassistant/components/oru/manifest.json
index 23c43e32306..347388b6f15 100644
--- a/homeassistant/components/oru/manifest.json
+++ b/homeassistant/components/oru/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/oru",
"iot_class": "cloud_polling",
"loggers": ["oru"],
+ "quality_scale": "legacy",
"requirements": ["oru==0.1.11"]
}
diff --git a/homeassistant/components/orvibo/manifest.json b/homeassistant/components/orvibo/manifest.json
index 05ce5edd8bd..e3a6676b2f2 100644
--- a/homeassistant/components/orvibo/manifest.json
+++ b/homeassistant/components/orvibo/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/orvibo",
"iot_class": "local_push",
"loggers": ["orvibo"],
+ "quality_scale": "legacy",
"requirements": ["orvibo==1.1.2"]
}
diff --git a/homeassistant/components/osramlightify/light.py b/homeassistant/components/osramlightify/light.py
index 0254c478b42..6ddd392af7b 100644
--- a/homeassistant/components/osramlightify/light.py
+++ b/homeassistant/components/osramlightify/light.py
@@ -11,7 +11,7 @@ import voluptuous as vol
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
- ATTR_COLOR_TEMP,
+ ATTR_COLOR_TEMP_KELVIN,
ATTR_EFFECT,
ATTR_HS_COLOR,
ATTR_TRANSITION,
@@ -191,10 +191,7 @@ class Luminary(LightEntity):
self._effect_list = []
self._is_on = False
self._available = True
- self._min_mireds = None
- self._max_mireds = None
self._brightness = None
- self._color_temp = None
self._rgb_color = None
self._device_attributes = None
@@ -256,11 +253,6 @@ class Luminary(LightEntity):
"""Return last hs color value set."""
return color_util.color_RGB_to_hs(*self._rgb_color)
- @property
- def color_temp(self):
- """Return the color temperature."""
- return self._color_temp
-
@property
def brightness(self):
"""Return brightness of the luminary (0..255)."""
@@ -276,16 +268,6 @@ class Luminary(LightEntity):
"""List of supported effects."""
return self._effect_list
- @property
- def min_mireds(self):
- """Return the coldest color_temp that this light supports."""
- return self._min_mireds
-
- @property
- def max_mireds(self):
- """Return the warmest color_temp that this light supports."""
- return self._max_mireds
-
@property
def unique_id(self):
"""Return a unique ID."""
@@ -326,12 +308,10 @@ class Luminary(LightEntity):
self._rgb_color = color_util.color_hs_to_RGB(*kwargs[ATTR_HS_COLOR])
self._luminary.set_rgb(*self._rgb_color, transition)
- if ATTR_COLOR_TEMP in kwargs:
- self._color_temp = kwargs[ATTR_COLOR_TEMP]
- self._luminary.set_temperature(
- int(color_util.color_temperature_mired_to_kelvin(self._color_temp)),
- transition,
- )
+ if ATTR_COLOR_TEMP_KELVIN in kwargs:
+ color_temp_kelvin = kwargs[ATTR_COLOR_TEMP_KELVIN]
+ self._attr_color_temp_kelvin = color_temp_kelvin
+ self._luminary.set_temperature(color_temp_kelvin, transition)
self._is_on = True
if ATTR_BRIGHTNESS in kwargs:
@@ -362,10 +342,10 @@ class Luminary(LightEntity):
self._attr_supported_features = self._get_supported_features()
self._effect_list = self._get_effect_list()
if ColorMode.COLOR_TEMP in self._attr_supported_color_modes:
- self._min_mireds = color_util.color_temperature_kelvin_to_mired(
+ self._attr_max_color_temp_kelvin = (
self._luminary.max_temp() or DEFAULT_KELVIN
)
- self._max_mireds = color_util.color_temperature_kelvin_to_mired(
+ self._attr_min_color_temp_kelvin = (
self._luminary.min_temp() or DEFAULT_KELVIN
)
if len(self._attr_supported_color_modes) == 1:
@@ -380,9 +360,7 @@ class Luminary(LightEntity):
self._brightness = int(self._luminary.lum() * 2.55)
if ColorMode.COLOR_TEMP in self._attr_supported_color_modes:
- self._color_temp = color_util.color_temperature_kelvin_to_mired(
- self._luminary.temp() or DEFAULT_KELVIN
- )
+ self._attr_color_temp_kelvin = self._luminary.temp() or DEFAULT_KELVIN
if ColorMode.HS in self._attr_supported_color_modes:
self._rgb_color = self._luminary.rgb()
diff --git a/homeassistant/components/osramlightify/manifest.json b/homeassistant/components/osramlightify/manifest.json
index f6a922a09ec..3b11200f1e5 100644
--- a/homeassistant/components/osramlightify/manifest.json
+++ b/homeassistant/components/osramlightify/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/osramlightify",
"iot_class": "local_polling",
"loggers": ["lightify"],
+ "quality_scale": "legacy",
"requirements": ["lightify==1.0.7.3"]
}
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 ce877e15261..51efb52e55d 100644
--- a/homeassistant/components/overkiz/__init__.py
+++ b/homeassistant/components/overkiz/__init__.py
@@ -41,20 +41,24 @@ from .const import (
PLATFORMS,
UPDATE_INTERVAL,
UPDATE_INTERVAL_ALL_ASSUMED_STATE,
+ UPDATE_INTERVAL_LOCAL,
)
from .coordinator import OverkizDataUpdateCoordinator
@dataclass
class HomeAssistantOverkizData:
- """Overkiz data stored in the Home Assistant data object."""
+ """Overkiz data stored in the runtime data object."""
coordinator: OverkizDataUpdateCoordinator
platforms: defaultdict[Platform, list[Device]]
scenarios: list[Scenario]
-async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+type OverkizDataConfigEntry = ConfigEntry[HomeAssistantOverkizData]
+
+
+async def async_setup_entry(hass: HomeAssistant, entry: OverkizDataConfigEntry) -> bool:
"""Set up Overkiz from a config entry."""
client: OverkizClient | None = None
api_type = entry.data.get(CONF_API_TYPE, APIType.CLOUD)
@@ -113,17 +117,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
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)
- hass.data.setdefault(DOMAIN, {})[entry.entry_id] = HomeAssistantOverkizData(
+ entry.runtime_data = HomeAssistantOverkizData(
coordinator=coordinator, platforms=platforms, scenarios=scenarios
)
@@ -162,17 +170,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: OverkizDataConfigEntry
+) -> 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 _async_migrate_entries(
- hass: HomeAssistant, config_entry: ConfigEntry
+ hass: HomeAssistant, config_entry: OverkizDataConfigEntry
) -> bool:
"""Migrate old entries to new unique IDs."""
entity_registry = er.async_get(hass)
diff --git a/homeassistant/components/overkiz/alarm_control_panel.py b/homeassistant/components/overkiz/alarm_control_panel.py
index bdbf4d0cc8d..90c135291c3 100644
--- a/homeassistant/components/overkiz/alarm_control_panel.py
+++ b/homeassistant/components/overkiz/alarm_control_panel.py
@@ -16,14 +16,12 @@ from homeassistant.components.alarm_control_panel import (
AlarmControlPanelEntityFeature,
AlarmControlPanelState,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from . import HomeAssistantOverkizData
-from .const import DOMAIN
+from . import OverkizDataConfigEntry
from .coordinator import OverkizDataUpdateCoordinator
from .entity import OverkizDescriptiveEntity
@@ -210,11 +208,11 @@ SUPPORTED_DEVICES = {description.key: description for description in ALARM_DESCR
async def async_setup_entry(
hass: HomeAssistant,
- entry: ConfigEntry,
+ entry: OverkizDataConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Overkiz alarm control panel from a config entry."""
- data: HomeAssistantOverkizData = hass.data[DOMAIN][entry.entry_id]
+ data = entry.runtime_data
async_add_entities(
OverkizAlarmControlPanel(
diff --git a/homeassistant/components/overkiz/binary_sensor.py b/homeassistant/components/overkiz/binary_sensor.py
index 57df3cd4e09..3a75cd77c2f 100644
--- a/homeassistant/components/overkiz/binary_sensor.py
+++ b/homeassistant/components/overkiz/binary_sensor.py
@@ -14,12 +14,11 @@ 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 . import HomeAssistantOverkizData
-from .const import DOMAIN, IGNORED_OVERKIZ_DEVICES
+from . import OverkizDataConfigEntry
+from .const import IGNORED_OVERKIZ_DEVICES
from .entity import OverkizDescriptiveEntity
@@ -143,11 +142,11 @@ SUPPORTED_STATES = {
async def async_setup_entry(
hass: HomeAssistant,
- entry: ConfigEntry,
+ entry: OverkizDataConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Overkiz binary sensors from a config entry."""
- data: HomeAssistantOverkizData = hass.data[DOMAIN][entry.entry_id]
+ data = entry.runtime_data
entities: list[OverkizBinarySensor] = []
for device in data.coordinator.data.values():
diff --git a/homeassistant/components/overkiz/button.py b/homeassistant/components/overkiz/button.py
index 5a1116aeeb5..92711ac8ca8 100644
--- a/homeassistant/components/overkiz/button.py
+++ b/homeassistant/components/overkiz/button.py
@@ -4,17 +4,20 @@ from __future__ import annotations
from dataclasses import dataclass
-from pyoverkiz.enums import OverkizCommand
+from pyoverkiz.enums import OverkizCommand, OverkizCommandParam
from pyoverkiz.types import StateType as OverkizStateType
-from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
-from homeassistant.config_entries import ConfigEntry
+from homeassistant.components.button import (
+ ButtonDeviceClass,
+ ButtonEntity,
+ ButtonEntityDescription,
+)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from . import HomeAssistantOverkizData
-from .const import DOMAIN, IGNORED_OVERKIZ_DEVICES
+from . import OverkizDataConfigEntry
+from .const import IGNORED_OVERKIZ_DEVICES
from .entity import OverkizDescriptiveEntity
@@ -28,41 +31,48 @@ class OverkizButtonDescription(ButtonEntityDescription):
BUTTON_DESCRIPTIONS: list[OverkizButtonDescription] = [
# My Position (cover, light)
OverkizButtonDescription(
- key="my",
+ key=OverkizCommand.MY,
name="My position",
icon="mdi:star",
),
# Identify
OverkizButtonDescription(
- key="identify", # startIdentify and identify are reversed... Swap this when fixed in API.
+ key=OverkizCommand.IDENTIFY, # startIdentify and identify are reversed... Swap this when fixed in API.
name="Start identify",
icon="mdi:human-greeting-variant",
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
OverkizButtonDescription(
- key="stopIdentify",
+ key=OverkizCommand.STOP_IDENTIFY,
name="Stop identify",
icon="mdi:human-greeting-variant",
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
OverkizButtonDescription(
- key="startIdentify", # startIdentify and identify are reversed... Swap this when fixed in API.
+ key=OverkizCommand.START_IDENTIFY, # startIdentify and identify are reversed... Swap this when fixed in API.
name="Identify",
icon="mdi:human-greeting-variant",
entity_category=EntityCategory.DIAGNOSTIC,
+ device_class=ButtonDeviceClass.IDENTIFY,
),
# RTDIndoorSiren / RTDOutdoorSiren
- OverkizButtonDescription(key="dingDong", name="Ding dong", icon="mdi:bell-ring"),
- OverkizButtonDescription(key="bip", name="Bip", icon="mdi:bell-ring"),
OverkizButtonDescription(
- key="fastBipSequence", name="Fast bip sequence", icon="mdi:bell-ring"
+ key=OverkizCommand.DING_DONG, name="Ding dong", icon="mdi:bell-ring"
+ ),
+ OverkizButtonDescription(key=OverkizCommand.BIP, name="Bip", icon="mdi:bell-ring"),
+ OverkizButtonDescription(
+ key=OverkizCommand.FAST_BIP_SEQUENCE,
+ name="Fast bip sequence",
+ icon="mdi:bell-ring",
+ ),
+ OverkizButtonDescription(
+ key=OverkizCommand.RING, name="Ring", icon="mdi:bell-ring"
),
- OverkizButtonDescription(key="ring", name="Ring", icon="mdi:bell-ring"),
# DynamicScreen (ogp:blind) uses goToAlias (id 1: favorite1) instead of 'my'
OverkizButtonDescription(
- key="goToAlias",
+ key=OverkizCommand.GO_TO_ALIAS,
press_args="1",
name="My position",
icon="mdi:star",
@@ -72,6 +82,14 @@ BUTTON_DESCRIPTIONS: list[OverkizButtonDescription] = [
name="Toggle",
icon="mdi:sync",
),
+ # SmokeSensor
+ OverkizButtonDescription(
+ key=OverkizCommand.CHECK_EVENT_TRIGGER,
+ press_args=OverkizCommandParam.SHORT,
+ name="Test",
+ icon="mdi:smoke-detector",
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
]
SUPPORTED_COMMANDS = {
@@ -81,11 +99,11 @@ SUPPORTED_COMMANDS = {
async def async_setup_entry(
hass: HomeAssistant,
- entry: ConfigEntry,
+ entry: OverkizDataConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Overkiz button from a config entry."""
- data: HomeAssistantOverkizData = hass.data[DOMAIN][entry.entry_id]
+ data = entry.runtime_data
entities: list[ButtonEntity] = []
for device in data.coordinator.data.values():
diff --git a/homeassistant/components/overkiz/climate/__init__.py b/homeassistant/components/overkiz/climate/__init__.py
index 97840df7a41..1398bb7c25a 100644
--- a/homeassistant/components/overkiz/climate/__init__.py
+++ b/homeassistant/components/overkiz/climate/__init__.py
@@ -7,14 +7,12 @@ from enum import StrEnum, unique
from pyoverkiz.enums import Protocol
from pyoverkiz.enums.ui import UIWidget
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from .. import HomeAssistantOverkizData
-from ..const import DOMAIN
+from .. import OverkizDataConfigEntry
from .atlantic_electrical_heater import AtlanticElectricalHeater
from .atlantic_electrical_heater_with_adjustable_temperature_setpoint import (
AtlanticElectricalHeaterWithAdjustableTemperatureSetpoint,
@@ -29,6 +27,7 @@ from .atlantic_pass_apc_zone_control import AtlanticPassAPCZoneControl
from .atlantic_pass_apc_zone_control_zone import AtlanticPassAPCZoneControlZone
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
from .somfy_heating_temperature_interface import SomfyHeatingTemperatureInterface
from .somfy_thermostat import SomfyThermostat
from .valve_heating_temperature_interface import ValveHeatingTemperatureInterface
@@ -53,6 +52,7 @@ WIDGET_TO_CLIMATE_ENTITY = {
UIWidget.ATLANTIC_HEAT_RECOVERY_VENTILATION: AtlanticHeatRecoveryVentilation,
UIWidget.ATLANTIC_PASS_APC_HEATING_ZONE: AtlanticPassAPCHeatingZone,
UIWidget.ATLANTIC_PASS_APC_ZONE_CONTROL: AtlanticPassAPCZoneControl,
+ UIWidget.HITACHI_AIR_TO_WATER_HEATING_ZONE: HitachiAirToWaterHeatingZone,
UIWidget.SOMFY_HEATING_TEMPERATURE_INTERFACE: SomfyHeatingTemperatureInterface,
UIWidget.SOMFY_THERMOSTAT: SomfyThermostat,
UIWidget.VALVE_HEATING_TEMPERATURE_INTERFACE: ValveHeatingTemperatureInterface,
@@ -79,11 +79,11 @@ WIDGET_AND_PROTOCOL_TO_CLIMATE_ENTITY = {
async def async_setup_entry(
hass: HomeAssistant,
- entry: ConfigEntry,
+ entry: OverkizDataConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Overkiz climate from a config entry."""
- data: HomeAssistantOverkizData = hass.data[DOMAIN][entry.entry_id]
+ data = entry.runtime_data
# Match devices based on the widget.
entities_based_on_widget: list[Entity] = [
diff --git a/homeassistant/components/overkiz/climate/atlantic_electrical_heater.py b/homeassistant/components/overkiz/climate/atlantic_electrical_heater.py
index ce9857f9d8c..059e64ef55d 100644
--- a/homeassistant/components/overkiz/climate/atlantic_electrical_heater.py
+++ b/homeassistant/components/overkiz/climate/atlantic_electrical_heater.py
@@ -54,7 +54,6 @@ class AtlanticElectricalHeater(OverkizEntity, ClimateEntity):
)
_attr_temperature_unit = UnitOfTemperature.CELSIUS
_attr_translation_key = DOMAIN
- _enable_turn_on_off_backwards_compatibility = False
@property
def hvac_mode(self) -> HVACMode:
diff --git a/homeassistant/components/overkiz/climate/atlantic_electrical_heater_with_adjustable_temperature_setpoint.py b/homeassistant/components/overkiz/climate/atlantic_electrical_heater_with_adjustable_temperature_setpoint.py
index 64a7dc1e645..93c7d03293b 100644
--- a/homeassistant/components/overkiz/climate/atlantic_electrical_heater_with_adjustable_temperature_setpoint.py
+++ b/homeassistant/components/overkiz/climate/atlantic_electrical_heater_with_adjustable_temperature_setpoint.py
@@ -76,7 +76,6 @@ class AtlanticElectricalHeaterWithAdjustableTemperatureSetpoint(
| ClimateEntityFeature.TURN_ON
)
_attr_translation_key = DOMAIN
- _enable_turn_on_off_backwards_compatibility = False
def __init__(
self, device_url: str, coordinator: OverkizDataUpdateCoordinator
diff --git a/homeassistant/components/overkiz/climate/atlantic_electrical_towel_dryer.py b/homeassistant/components/overkiz/climate/atlantic_electrical_towel_dryer.py
index e49fc4358e9..0b5ba3ffcc7 100644
--- a/homeassistant/components/overkiz/climate/atlantic_electrical_towel_dryer.py
+++ b/homeassistant/components/overkiz/climate/atlantic_electrical_towel_dryer.py
@@ -46,7 +46,6 @@ class AtlanticElectricalTowelDryer(OverkizEntity, ClimateEntity):
_attr_preset_modes = [*PRESET_MODE_TO_OVERKIZ]
_attr_temperature_unit = UnitOfTemperature.CELSIUS
_attr_translation_key = DOMAIN
- _enable_turn_on_off_backwards_compatibility = False
def __init__(
self, device_url: str, coordinator: OverkizDataUpdateCoordinator
@@ -85,12 +84,15 @@ class AtlanticElectricalTowelDryer(OverkizEntity, ClimateEntity):
)
@property
- def target_temperature(self) -> None:
- """Return the temperature."""
- if self.hvac_mode == HVACMode.AUTO:
- self.executor.select_state(OverkizState.IO_EFFECTIVE_TEMPERATURE_SETPOINT)
- else:
- self.executor.select_state(OverkizState.CORE_TARGET_TEMPERATURE)
+ def target_temperature(self) -> float | None:
+ """Return the target temperature."""
+ state = (
+ OverkizState.IO_EFFECTIVE_TEMPERATURE_SETPOINT
+ if self.hvac_mode == HVACMode.AUTO
+ else OverkizState.CORE_TARGET_TEMPERATURE
+ )
+
+ return cast(float, self.executor.select_state(state))
@property
def current_temperature(self) -> float | None:
diff --git a/homeassistant/components/overkiz/climate/atlantic_heat_recovery_ventilation.py b/homeassistant/components/overkiz/climate/atlantic_heat_recovery_ventilation.py
index f1d96b5687b..bb84fa76f22 100644
--- a/homeassistant/components/overkiz/climate/atlantic_heat_recovery_ventilation.py
+++ b/homeassistant/components/overkiz/climate/atlantic_heat_recovery_ventilation.py
@@ -55,7 +55,6 @@ class AtlanticHeatRecoveryVentilation(OverkizEntity, ClimateEntity):
| ClimateEntityFeature.TURN_ON
)
_attr_translation_key = DOMAIN
- _enable_turn_on_off_backwards_compatibility = False
def __init__(
self, device_url: str, coordinator: OverkizDataUpdateCoordinator
diff --git a/homeassistant/components/overkiz/climate/atlantic_pass_apc_heat_pump_main_component.py b/homeassistant/components/overkiz/climate/atlantic_pass_apc_heat_pump_main_component.py
index 1cd13205b13..800516e4bda 100644
--- a/homeassistant/components/overkiz/climate/atlantic_pass_apc_heat_pump_main_component.py
+++ b/homeassistant/components/overkiz/climate/atlantic_pass_apc_heat_pump_main_component.py
@@ -41,7 +41,6 @@ class AtlanticPassAPCHeatPumpMainComponent(OverkizEntity, ClimateEntity):
)
_attr_temperature_unit = UnitOfTemperature.CELSIUS
_attr_translation_key = DOMAIN
- _enable_turn_on_off_backwards_compatibility = False
@property
def hvac_mode(self) -> HVACMode:
diff --git a/homeassistant/components/overkiz/climate/atlantic_pass_apc_heating_zone.py b/homeassistant/components/overkiz/climate/atlantic_pass_apc_heating_zone.py
index 3da2ccc922b..3df31fb44fc 100644
--- a/homeassistant/components/overkiz/climate/atlantic_pass_apc_heating_zone.py
+++ b/homeassistant/components/overkiz/climate/atlantic_pass_apc_heating_zone.py
@@ -92,7 +92,6 @@ class AtlanticPassAPCHeatingZone(OverkizEntity, ClimateEntity):
)
_attr_temperature_unit = UnitOfTemperature.CELSIUS
_attr_translation_key = DOMAIN
- _enable_turn_on_off_backwards_compatibility = False
def __init__(
self, device_url: str, coordinator: OverkizDataUpdateCoordinator
diff --git a/homeassistant/components/overkiz/climate/atlantic_pass_apc_zone_control.py b/homeassistant/components/overkiz/climate/atlantic_pass_apc_zone_control.py
index 7fbab821b8d..7846b058619 100644
--- a/homeassistant/components/overkiz/climate/atlantic_pass_apc_zone_control.py
+++ b/homeassistant/components/overkiz/climate/atlantic_pass_apc_zone_control.py
@@ -31,7 +31,6 @@ class AtlanticPassAPCZoneControl(OverkizEntity, ClimateEntity):
_attr_supported_features = (
ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON
)
- _enable_turn_on_off_backwards_compatibility = False
def __init__(
self, device_url: str, coordinator: OverkizDataUpdateCoordinator
diff --git a/homeassistant/components/overkiz/climate/hitachi_air_to_air_heat_pump_hlrrwifi.py b/homeassistant/components/overkiz/climate/hitachi_air_to_air_heat_pump_hlrrwifi.py
index efdae2165a9..41da90f1ce8 100644
--- a/homeassistant/components/overkiz/climate/hitachi_air_to_air_heat_pump_hlrrwifi.py
+++ b/homeassistant/components/overkiz/climate/hitachi_air_to_air_heat_pump_hlrrwifi.py
@@ -91,7 +91,6 @@ class HitachiAirToAirHeatPumpHLRRWIFI(OverkizEntity, ClimateEntity):
_attr_target_temperature_step = 1.0
_attr_temperature_unit = UnitOfTemperature.CELSIUS
_attr_translation_key = DOMAIN
- _enable_turn_on_off_backwards_compatibility = False
def __init__(
self, device_url: str, coordinator: OverkizDataUpdateCoordinator
diff --git a/homeassistant/components/overkiz/climate/hitachi_air_to_air_heat_pump_ovp.py b/homeassistant/components/overkiz/climate/hitachi_air_to_air_heat_pump_ovp.py
index b31ecf91ec0..f60cbbeca2b 100644
--- a/homeassistant/components/overkiz/climate/hitachi_air_to_air_heat_pump_ovp.py
+++ b/homeassistant/components/overkiz/climate/hitachi_air_to_air_heat_pump_ovp.py
@@ -95,7 +95,6 @@ class HitachiAirToAirHeatPumpOVP(OverkizEntity, ClimateEntity):
_attr_target_temperature_step = 1.0
_attr_temperature_unit = UnitOfTemperature.CELSIUS
_attr_translation_key = DOMAIN
- _enable_turn_on_off_backwards_compatibility = False
def __init__(
self, device_url: str, coordinator: OverkizDataUpdateCoordinator
diff --git a/homeassistant/components/overkiz/climate/hitachi_air_to_water_heating_zone.py b/homeassistant/components/overkiz/climate/hitachi_air_to_water_heating_zone.py
new file mode 100644
index 00000000000..8410e50873d
--- /dev/null
+++ b/homeassistant/components/overkiz/climate/hitachi_air_to_water_heating_zone.py
@@ -0,0 +1,123 @@
+"""Support for HitachiAirToWaterHeatingZone."""
+
+from __future__ import annotations
+
+from typing import Any, cast
+
+from pyoverkiz.enums import OverkizCommand, OverkizCommandParam, OverkizState
+
+from homeassistant.components.climate import (
+ PRESET_COMFORT,
+ PRESET_ECO,
+ PRESET_NONE,
+ ClimateEntity,
+ ClimateEntityFeature,
+ HVACMode,
+)
+from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
+
+from ..const import DOMAIN
+from ..entity import OverkizDataUpdateCoordinator, OverkizEntity
+
+OVERKIZ_TO_HVAC_MODE: dict[str, HVACMode] = {
+ OverkizCommandParam.MANU: HVACMode.HEAT,
+ OverkizCommandParam.AUTO: HVACMode.AUTO,
+}
+
+HVAC_MODE_TO_OVERKIZ = {v: k for k, v in OVERKIZ_TO_HVAC_MODE.items()}
+
+OVERKIZ_TO_PRESET_MODE: dict[str, str] = {
+ OverkizCommandParam.COMFORT: PRESET_COMFORT,
+ OverkizCommandParam.ECO: PRESET_ECO,
+}
+
+PRESET_MODE_TO_OVERKIZ = {v: k for k, v in OVERKIZ_TO_PRESET_MODE.items()}
+
+
+class HitachiAirToWaterHeatingZone(OverkizEntity, ClimateEntity):
+ """Representation of HitachiAirToWaterHeatingZone."""
+
+ _attr_hvac_modes = [*HVAC_MODE_TO_OVERKIZ]
+ _attr_preset_modes = [*PRESET_MODE_TO_OVERKIZ]
+ _attr_supported_features = (
+ ClimateEntityFeature.PRESET_MODE | ClimateEntityFeature.TARGET_TEMPERATURE
+ )
+ _attr_min_temp = 5.0
+ _attr_max_temp = 35.0
+ _attr_precision = 0.1
+ _attr_target_temperature_step = 0.5
+ _attr_temperature_unit = UnitOfTemperature.CELSIUS
+ _attr_translation_key = DOMAIN
+
+ 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"] = "Hitachi"
+
+ @property
+ def hvac_mode(self) -> HVACMode:
+ """Return hvac operation ie. heat, cool mode."""
+ if (
+ state := self.device.states[OverkizState.MODBUS_AUTO_MANU_MODE_ZONE_1]
+ ) and state.value_as_str:
+ return OVERKIZ_TO_HVAC_MODE[state.value_as_str]
+
+ 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_AUTO_MANU_MODE, HVAC_MODE_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.MODBUS_YUTAKI_TARGET_MODE]
+ ) and state.value_as_str:
+ return OVERKIZ_TO_PRESET_MODE[state.value_as_str]
+
+ return PRESET_NONE
+
+ async def async_set_preset_mode(self, preset_mode: str) -> None:
+ """Set new preset mode."""
+ await self.executor.async_execute_command(
+ OverkizCommand.SET_TARGET_MODE, PRESET_MODE_TO_OVERKIZ[preset_mode]
+ )
+
+ @property
+ def current_temperature(self) -> float | None:
+ """Return the current temperature."""
+ current_temperature = self.device.states[
+ OverkizState.MODBUS_ROOM_AMBIENT_TEMPERATURE_STATUS_ZONE_1
+ ]
+
+ if current_temperature:
+ return current_temperature.value_as_float
+
+ return None
+
+ @property
+ def target_temperature(self) -> float | None:
+ """Return the temperature we try to reach."""
+ target_temperature = self.device.states[
+ OverkizState.MODBUS_THERMOSTAT_SETTING_CONTROL_ZONE_1
+ ]
+
+ if target_temperature:
+ return target_temperature.value_as_float
+
+ return None
+
+ async def async_set_temperature(self, **kwargs: Any) -> None:
+ """Set new target temperature."""
+ temperature = cast(float, kwargs.get(ATTR_TEMPERATURE))
+
+ await self.executor.async_execute_command(
+ OverkizCommand.SET_THERMOSTAT_SETTING_CONTROL_ZONE_1, int(temperature)
+ )
diff --git a/homeassistant/components/overkiz/climate/somfy_heating_temperature_interface.py b/homeassistant/components/overkiz/climate/somfy_heating_temperature_interface.py
index acc761664ec..5ca17f9b6b1 100644
--- a/homeassistant/components/overkiz/climate/somfy_heating_temperature_interface.py
+++ b/homeassistant/components/overkiz/climate/somfy_heating_temperature_interface.py
@@ -82,7 +82,6 @@ class SomfyHeatingTemperatureInterface(OverkizEntity, ClimateEntity):
# Both min and max temp values have been retrieved from the Somfy Application.
_attr_min_temp = 15.0
_attr_max_temp = 26.0
- _enable_turn_on_off_backwards_compatibility = False
def __init__(
self, device_url: str, coordinator: OverkizDataUpdateCoordinator
diff --git a/homeassistant/components/overkiz/climate/somfy_thermostat.py b/homeassistant/components/overkiz/climate/somfy_thermostat.py
index 829a3bad03b..d2aa1658302 100644
--- a/homeassistant/components/overkiz/climate/somfy_thermostat.py
+++ b/homeassistant/components/overkiz/climate/somfy_thermostat.py
@@ -57,15 +57,11 @@ class SomfyThermostat(OverkizEntity, ClimateEntity):
_attr_temperature_unit = UnitOfTemperature.CELSIUS
_attr_supported_features = (
- ClimateEntityFeature.PRESET_MODE
- | ClimateEntityFeature.TARGET_TEMPERATURE
- | ClimateEntityFeature.TURN_OFF
- | ClimateEntityFeature.TURN_ON
+ ClimateEntityFeature.PRESET_MODE | ClimateEntityFeature.TARGET_TEMPERATURE
)
_attr_hvac_modes = [*HVAC_MODES_TO_OVERKIZ]
_attr_preset_modes = [*PRESET_MODES_TO_OVERKIZ]
_attr_translation_key = DOMAIN
- _enable_turn_on_off_backwards_compatibility = False
# Both min and max temp values have been retrieved from the Somfy Application.
_attr_min_temp = 15.0
@@ -83,11 +79,12 @@ class SomfyThermostat(OverkizEntity, ClimateEntity):
@property
def hvac_mode(self) -> HVACMode:
"""Return hvac operation ie. heat, cool mode."""
- return OVERKIZ_TO_HVAC_MODES[
- cast(
- str, self.executor.select_state(OverkizState.CORE_DEROGATION_ACTIVATION)
- )
- ]
+ if derogation_activation := self.executor.select_state(
+ OverkizState.CORE_DEROGATION_ACTIVATION
+ ):
+ return OVERKIZ_TO_HVAC_MODES[cast(str, derogation_activation)]
+
+ return HVACMode.AUTO
@property
def preset_mode(self) -> str:
@@ -97,9 +94,10 @@ class SomfyThermostat(OverkizEntity, ClimateEntity):
else:
state_key = OverkizState.SOMFY_THERMOSTAT_DEROGATION_HEATING_MODE
- state = cast(str, self.executor.select_state(state_key))
+ if state := self.executor.select_state(state_key):
+ return OVERKIZ_TO_PRESET_MODES[OverkizCommandParam(cast(str, state))]
- return OVERKIZ_TO_PRESET_MODES[OverkizCommandParam(state)]
+ return PRESET_NONE
@property
def current_temperature(self) -> float | None:
diff --git a/homeassistant/components/overkiz/climate/valve_heating_temperature_interface.py b/homeassistant/components/overkiz/climate/valve_heating_temperature_interface.py
index e2165e8b6c6..54c00b33167 100644
--- a/homeassistant/components/overkiz/climate/valve_heating_temperature_interface.py
+++ b/homeassistant/components/overkiz/climate/valve_heating_temperature_interface.py
@@ -56,7 +56,6 @@ class ValveHeatingTemperatureInterface(OverkizEntity, ClimateEntity):
)
_attr_temperature_unit = UnitOfTemperature.CELSIUS
_attr_translation_key = DOMAIN
- _enable_turn_on_off_backwards_compatibility = False
def __init__(
self, device_url: str, coordinator: OverkizDataUpdateCoordinator
diff --git a/homeassistant/components/overkiz/config_flow.py b/homeassistant/components/overkiz/config_flow.py
index 471a13d0de2..9a94c30d95d 100644
--- a/homeassistant/components/overkiz/config_flow.py
+++ b/homeassistant/components/overkiz/config_flow.py
@@ -76,7 +76,7 @@ class OverkizConfigFlow(ConfigFlow, domain=DOMAIN):
for gateway in gateways:
if is_overkiz_gateway(gateway.id):
gateway_id = gateway.id
- await self.async_set_unique_id(gateway_id)
+ await self.async_set_unique_id(gateway_id, raise_on_progress=False)
return user_input
@@ -151,9 +151,11 @@ class OverkizConfigFlow(ConfigFlow, domain=DOMAIN):
except BadCredentialsException as exception:
# If authentication with CozyTouch auth server is valid, but token is invalid
# for Overkiz API server, the hardware is not supported.
- if user_input[CONF_HUB] == Server.ATLANTIC_COZYTOUCH and not isinstance(
- exception, CozyTouchBadCredentialsException
- ):
+ if user_input[CONF_HUB] in {
+ Server.ATLANTIC_COZYTOUCH,
+ Server.SAUTER_COZYTOUCH,
+ Server.THERMOR_COZYTOUCH,
+ } and not isinstance(exception, CozyTouchBadCredentialsException):
description_placeholders["unsupported_device"] = "CozyTouch"
errors["base"] = "unsupported_hardware"
else:
diff --git a/homeassistant/components/overkiz/const.py b/homeassistant/components/overkiz/const.py
index a90260e0f0f..41b567500a9 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] = [
@@ -102,6 +103,7 @@ OVERKIZ_DEVICE_TO_PLATFORM: dict[UIClass | UIWidget, Platform | None] = {
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.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)
UIWidget.MY_FOX_ALARM_CONTROLLER: Platform.ALARM_CONTROL_PANEL, # widgetName, uiClass is Alarm (not supported)
UIWidget.MY_FOX_SECURITY_CAMERA: Platform.SWITCH, # widgetName, uiClass is Camera (not supported)
@@ -141,8 +143,8 @@ OVERKIZ_UNIT_TO_HA: dict[str, str] = {
MeasuredValueType.ELECTRICAL_POWER_IN_W: UnitOfPower.WATT,
MeasuredValueType.ELECTRIC_CURRENT_IN_AMPERE: UnitOfElectricCurrent.AMPERE,
MeasuredValueType.ELECTRIC_CURRENT_IN_MILLI_AMPERE: UnitOfElectricCurrent.MILLIAMPERE,
- MeasuredValueType.ENERGY_IN_CAL: "cal",
- MeasuredValueType.ENERGY_IN_KCAL: "kcal",
+ MeasuredValueType.ENERGY_IN_CAL: UnitOfEnergy.CALORIE,
+ MeasuredValueType.ENERGY_IN_KCAL: UnitOfEnergy.KILO_CALORIE,
MeasuredValueType.FLOW_IN_LITRE_PER_SECOND: f"{UnitOfVolume.LITERS}/{UnitOfTime.SECONDS}",
MeasuredValueType.FLOW_IN_METER_CUBE_PER_HOUR: UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR,
MeasuredValueType.FLOW_IN_METER_CUBE_PER_SECOND: f"{UnitOfVolume.CUBIC_METERS}/{UnitOfTime.SECONDS}",
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/cover/__init__.py b/homeassistant/components/overkiz/cover/__init__.py
index f9df3256253..38c02eba1bb 100644
--- a/homeassistant/components/overkiz/cover/__init__.py
+++ b/homeassistant/components/overkiz/cover/__init__.py
@@ -2,23 +2,23 @@
from pyoverkiz.enums import OverkizCommand, UIClass
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from .. import HomeAssistantOverkizData
-from ..const import DOMAIN
+from .. import OverkizDataConfigEntry
from .awning import Awning
from .generic_cover import OverkizGenericCover
from .vertical_cover import LowSpeedCover, VerticalCover
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: OverkizDataConfigEntry,
+ async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Overkiz covers from a config entry."""
- data: HomeAssistantOverkizData = hass.data[DOMAIN][entry.entry_id]
+ data = entry.runtime_data
entities: list[OverkizGenericCover] = [
Awning(device.device_url, data.coordinator)
diff --git a/homeassistant/components/overkiz/diagnostics.py b/homeassistant/components/overkiz/diagnostics.py
index 427230b9c82..dae0c6c59cf 100644
--- a/homeassistant/components/overkiz/diagnostics.py
+++ b/homeassistant/components/overkiz/diagnostics.py
@@ -7,20 +7,18 @@ from typing import Any
from pyoverkiz.enums import APIType
from pyoverkiz.obfuscate import obfuscate_id
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntry
-from . import HomeAssistantOverkizData
-from .const import CONF_API_TYPE, CONF_HUB, DOMAIN
+from . import OverkizDataConfigEntry
+from .const import CONF_API_TYPE, CONF_HUB
async def async_get_config_entry_diagnostics(
- hass: HomeAssistant, entry: ConfigEntry
+ hass: HomeAssistant, entry: OverkizDataConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
- entry_data: HomeAssistantOverkizData = hass.data[DOMAIN][entry.entry_id]
- client = entry_data.coordinator.client
+ client = entry.runtime_data.coordinator.client
data = {
"setup": await client.get_diagnostic_data(),
@@ -39,11 +37,10 @@ async def async_get_config_entry_diagnostics(
async def async_get_device_diagnostics(
- hass: HomeAssistant, entry: ConfigEntry, device: DeviceEntry
+ hass: HomeAssistant, entry: OverkizDataConfigEntry, device: DeviceEntry
) -> dict[str, Any]:
"""Return diagnostics for a device entry."""
- entry_data: HomeAssistantOverkizData = hass.data[DOMAIN][entry.entry_id]
- client = entry_data.coordinator.client
+ client = entry.runtime_data.coordinator.client
device_url = min(device.identifiers)[1]
diff --git a/homeassistant/components/overkiz/executor.py b/homeassistant/components/overkiz/executor.py
index 02829eaf1a3..220c6fe7cb2 100644
--- a/homeassistant/components/overkiz/executor.py
+++ b/homeassistant/components/overkiz/executor.py
@@ -6,7 +6,7 @@ from typing import Any, cast
from urllib.parse import urlparse
from pyoverkiz.enums import OverkizCommand, Protocol
-from pyoverkiz.exceptions import OverkizException
+from pyoverkiz.exceptions import BaseOverkizException
from pyoverkiz.models import Command, Device, StateDefinition
from pyoverkiz.types import StateType as OverkizStateType
@@ -105,7 +105,7 @@ class OverkizExecutor:
"Home Assistant",
)
# Catch Overkiz exceptions to support `continue_on_error` functionality
- except OverkizException as exception:
+ except BaseOverkizException as exception:
raise HomeAssistantError(exception) from exception
# ExecutionRegisteredEvent doesn't contain the device_url, thus we need to register it here
diff --git a/homeassistant/components/overkiz/light.py b/homeassistant/components/overkiz/light.py
index 18d724dd63a..933d4cf695b 100644
--- a/homeassistant/components/overkiz/light.py
+++ b/homeassistant/components/overkiz/light.py
@@ -12,24 +12,22 @@ from homeassistant.components.light import (
ColorMode,
LightEntity,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from . import HomeAssistantOverkizData
-from .const import DOMAIN
+from . import OverkizDataConfigEntry
from .coordinator import OverkizDataUpdateCoordinator
from .entity import OverkizEntity
async def async_setup_entry(
hass: HomeAssistant,
- entry: ConfigEntry,
+ entry: OverkizDataConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Overkiz lights from a config entry."""
- data: HomeAssistantOverkizData = hass.data[DOMAIN][entry.entry_id]
+ data = entry.runtime_data
async_add_entities(
OverkizLight(device.device_url, data.coordinator)
diff --git a/homeassistant/components/overkiz/lock.py b/homeassistant/components/overkiz/lock.py
index 2494903d076..1c073d2f9aa 100644
--- a/homeassistant/components/overkiz/lock.py
+++ b/homeassistant/components/overkiz/lock.py
@@ -7,23 +7,21 @@ from typing import Any
from pyoverkiz.enums import OverkizCommand, OverkizCommandParam, OverkizState
from homeassistant.components.lock import LockEntity
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from . import HomeAssistantOverkizData
-from .const import DOMAIN
+from . import OverkizDataConfigEntry
from .entity import OverkizEntity
async def async_setup_entry(
hass: HomeAssistant,
- entry: ConfigEntry,
+ entry: OverkizDataConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Overkiz locks from a config entry."""
- data: HomeAssistantOverkizData = hass.data[DOMAIN][entry.entry_id]
+ data = entry.runtime_data
async_add_entities(
OverkizLock(device.device_url, data.coordinator)
diff --git a/homeassistant/components/overkiz/manifest.json b/homeassistant/components/overkiz/manifest.json
index 52fd1dfc669..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": [
{
@@ -20,7 +13,7 @@
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["boto3", "botocore", "pyhumps", "pyoverkiz", "s3transfer"],
- "requirements": ["pyoverkiz==1.14.1"],
+ "requirements": ["pyoverkiz==1.15.5"],
"zeroconf": [
{
"type": "_kizbox._tcp.local.",
diff --git a/homeassistant/components/overkiz/number.py b/homeassistant/components/overkiz/number.py
index 494d430c393..0e03e822424 100644
--- a/homeassistant/components/overkiz/number.py
+++ b/homeassistant/components/overkiz/number.py
@@ -14,13 +14,12 @@ from homeassistant.components.number import (
NumberEntity,
NumberEntityDescription,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from . import HomeAssistantOverkizData
-from .const import DOMAIN, IGNORED_OVERKIZ_DEVICES
+from . import OverkizDataConfigEntry
+from .const import IGNORED_OVERKIZ_DEVICES
from .coordinator import OverkizDataUpdateCoordinator
from .entity import OverkizDescriptiveEntity
@@ -191,11 +190,11 @@ SUPPORTED_STATES = {description.key: description for description in NUMBER_DESCR
async def async_setup_entry(
hass: HomeAssistant,
- entry: ConfigEntry,
+ entry: OverkizDataConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Overkiz number from a config entry."""
- data: HomeAssistantOverkizData = hass.data[DOMAIN][entry.entry_id]
+ data = entry.runtime_data
entities: list[OverkizNumber] = []
for device in data.coordinator.data.values():
diff --git a/homeassistant/components/overkiz/scene.py b/homeassistant/components/overkiz/scene.py
index 8cbbb9dbe5d..4533ed3245c 100644
--- a/homeassistant/components/overkiz/scene.py
+++ b/homeassistant/components/overkiz/scene.py
@@ -8,21 +8,19 @@ from pyoverkiz.client import OverkizClient
from pyoverkiz.models import Scenario
from homeassistant.components.scene import Scene
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from . import HomeAssistantOverkizData
-from .const import DOMAIN
+from . import OverkizDataConfigEntry
async def async_setup_entry(
hass: HomeAssistant,
- entry: ConfigEntry,
+ entry: OverkizDataConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Overkiz scenes from a config entry."""
- data: HomeAssistantOverkizData = hass.data[DOMAIN][entry.entry_id]
+ data = entry.runtime_data
async_add_entities(
OverkizScene(scene, data.coordinator.client) for scene in data.scenarios
diff --git a/homeassistant/components/overkiz/select.py b/homeassistant/components/overkiz/select.py
index 83cdc9c4f2b..ac467eaaa7a 100644
--- a/homeassistant/components/overkiz/select.py
+++ b/homeassistant/components/overkiz/select.py
@@ -8,13 +8,12 @@ from dataclasses import dataclass
from pyoverkiz.enums import OverkizCommand, OverkizCommandParam, OverkizState
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 . import HomeAssistantOverkizData
-from .const import DOMAIN, IGNORED_OVERKIZ_DEVICES
+from . import OverkizDataConfigEntry
+from .const import IGNORED_OVERKIZ_DEVICES
from .entity import OverkizDescriptiveEntity
@@ -129,11 +128,11 @@ SUPPORTED_STATES = {description.key: description for description in SELECT_DESCR
async def async_setup_entry(
hass: HomeAssistant,
- entry: ConfigEntry,
+ entry: OverkizDataConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Overkiz select from a config entry."""
- data: HomeAssistantOverkizData = hass.data[DOMAIN][entry.entry_id]
+ data = entry.runtime_data
entities: list[OverkizSelect] = []
for device in data.coordinator.data.values():
diff --git a/homeassistant/components/overkiz/sensor.py b/homeassistant/components/overkiz/sensor.py
index 5c54a1bd383..84d25b01d24 100644
--- a/homeassistant/components/overkiz/sensor.py
+++ b/homeassistant/components/overkiz/sensor.py
@@ -15,7 +15,6 @@ from homeassistant.components.sensor import (
SensorEntityDescription,
SensorStateClass,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONCENTRATION_PARTS_PER_MILLION,
LIGHT_LUX,
@@ -34,7 +33,7 @@ from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import StateType
-from . import HomeAssistantOverkizData
+from . import OverkizDataConfigEntry
from .const import (
DOMAIN,
IGNORED_OVERKIZ_DEVICES,
@@ -423,7 +422,7 @@ SENSOR_DESCRIPTIONS: list[OverkizSensorDescription] = [
OverkizSensorDescription(
key=OverkizState.CORE_REMAINING_HOT_WATER,
name="Warm water remaining",
- device_class=SensorDeviceClass.VOLUME,
+ device_class=SensorDeviceClass.VOLUME_STORAGE,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfVolume.LITERS,
),
@@ -458,6 +457,24 @@ SENSOR_DESCRIPTIONS: list[OverkizSensorDescription] = [
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
),
+ # HitachiHeatingSystem/HitachiAirToWaterHeatingZone
+ OverkizSensorDescription(
+ key=OverkizState.MODBUS_ROOM_AMBIENT_TEMPERATURE_STATUS_ZONE_1,
+ name="Room ambient temperature",
+ native_value=lambda value: cast(float, value),
+ device_class=SensorDeviceClass.TEMPERATURE,
+ native_unit_of_measurement=UnitOfTemperature.CELSIUS,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ # HitachiHeatingSystem/HitachiAirToWaterMainComponent
+ OverkizSensorDescription(
+ key=OverkizState.MODBUS_OUTDOOR_AMBIENT_TEMPERATURE,
+ name="Outdoor ambient temperature",
+ native_value=lambda value: cast(int, value),
+ device_class=SensorDeviceClass.TEMPERATURE,
+ native_unit_of_measurement=UnitOfTemperature.CELSIUS,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
]
SUPPORTED_STATES = {description.key: description for description in SENSOR_DESCRIPTIONS}
@@ -465,11 +482,11 @@ SUPPORTED_STATES = {description.key: description for description in SENSOR_DESCR
async def async_setup_entry(
hass: HomeAssistant,
- entry: ConfigEntry,
+ entry: OverkizDataConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Overkiz sensors from a config entry."""
- data: HomeAssistantOverkizData = hass.data[DOMAIN][entry.entry_id]
+ data = entry.runtime_data
entities: list[SensorEntity] = []
for device in data.coordinator.data.values():
diff --git a/homeassistant/components/overkiz/siren.py b/homeassistant/components/overkiz/siren.py
index a7ba41e2fef..f7246e50ec0 100644
--- a/homeassistant/components/overkiz/siren.py
+++ b/homeassistant/components/overkiz/siren.py
@@ -10,23 +10,21 @@ from homeassistant.components.siren import (
SirenEntity,
SirenEntityFeature,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from . import HomeAssistantOverkizData
-from .const import DOMAIN
+from . import OverkizDataConfigEntry
from .entity import OverkizEntity
async def async_setup_entry(
hass: HomeAssistant,
- entry: ConfigEntry,
+ entry: OverkizDataConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Overkiz sirens from a config entry."""
- data: HomeAssistantOverkizData = hass.data[DOMAIN][entry.entry_id]
+ data = entry.runtime_data
async_add_entities(
OverkizSiren(device.device_url, data.coordinator)
diff --git a/homeassistant/components/overkiz/strings.json b/homeassistant/components/overkiz/strings.json
index a756df4d0d6..0c564a003d6 100644
--- a/homeassistant/components/overkiz/strings.json
+++ b/homeassistant/components/overkiz/strings.json
@@ -6,12 +6,18 @@
"description": "Select your server. The Overkiz platform is used by various vendors like Somfy (Connexoon / TaHoma), Hitachi (Hi Kumo) and Atlantic (Cozytouch).",
"data": {
"hub": "Server"
+ },
+ "data_description": {
+ "hub": "Select the mobile app that you use to control your devices."
}
},
"local_or_cloud": {
- "description": "Choose between local or cloud API. Local API supports TaHoma Connexoon, TaHoma v2, and TaHoma Switch. Climate devices and scenarios are not supported in local API.",
+ "description": "Choose how you want to connect to your gateway.",
"data": {
"api_type": "API type"
+ },
+ "data_description": {
+ "api_type": "Local API is only supported by TaHoma Connexoon, TaHoma v2, and TaHoma Switch. Climate devices and scenarios are **not** available via the local API."
}
},
"cloud": {
@@ -19,15 +25,25 @@
"data": {
"username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]"
+ },
+ "data_description": {
+ "username": "The username of your cloud account (app).",
+ "password": "The password of your cloud account (app)."
}
},
"local": {
- "description": "By activating the [Developer Mode of your TaHoma box](https://github.com/Somfy-Developer/Somfy-TaHoma-Developer-Mode#getting-started), you can authorize third-party software (like Home Assistant) to connect to it via your local network. \n\n After activation, enter your application credentials and change the host to include your gateway-pin or enter the IP address of your gateway.",
+ "description": "By activating the [Developer Mode of your TaHoma box](https://github.com/Somfy-Developer/Somfy-TaHoma-Developer-Mode#getting-started), you can authorize third-party software (like Home Assistant) to connect to it via your local network.\n\nAfter activation, enter your application credentials and change the host to include your Gateway PIN or enter the IP address of your gateway.",
"data": {
"host": "[%key:common::config_flow::data::host%]",
"username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]",
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
+ },
+ "data_description": {
+ "host": "The hostname or IP address of your Overkiz hub.",
+ "username": "The username of your cloud account (app).",
+ "password": "The password of your cloud account (app).",
+ "verify_ssl": "Verify the SSL certificate. Select this only if you are connecting via the hostname."
}
}
},
diff --git a/homeassistant/components/overkiz/switch.py b/homeassistant/components/overkiz/switch.py
index ac3ea351559..c921dbab776 100644
--- a/homeassistant/components/overkiz/switch.py
+++ b/homeassistant/components/overkiz/switch.py
@@ -15,13 +15,11 @@ from homeassistant.components.switch import (
SwitchEntity,
SwitchEntityDescription,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from . import HomeAssistantOverkizData
-from .const import DOMAIN
+from . import OverkizDataConfigEntry
from .entity import OverkizDescriptiveEntity
@@ -111,11 +109,11 @@ SUPPORTED_DEVICES = {
async def async_setup_entry(
hass: HomeAssistant,
- entry: ConfigEntry,
+ entry: OverkizDataConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Overkiz switch from a config entry."""
- data: HomeAssistantOverkizData = hass.data[DOMAIN][entry.entry_id]
+ data = entry.runtime_data
async_add_entities(
OverkizSwitch(
diff --git a/homeassistant/components/overkiz/water_heater.py b/homeassistant/components/overkiz/water_heater.py
deleted file mode 100644
index 99bfb279e4c..00000000000
--- a/homeassistant/components/overkiz/water_heater.py
+++ /dev/null
@@ -1,42 +0,0 @@
-"""Support for Overkiz water heater devices."""
-
-from __future__ import annotations
-
-from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import Platform
-from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
-
-from . import HomeAssistantOverkizData
-from .const import DOMAIN
-from .entity import OverkizEntity
-from .water_heater_entities import (
- CONTROLLABLE_NAME_TO_WATER_HEATER_ENTITY,
- WIDGET_TO_WATER_HEATER_ENTITY,
-)
-
-
-async def async_setup_entry(
- hass: HomeAssistant,
- entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
-) -> None:
- """Set up the Overkiz DHW from a config entry."""
- data: HomeAssistantOverkizData = hass.data[DOMAIN][entry.entry_id]
- entities: list[OverkizEntity] = []
-
- for device in data.platforms[Platform.WATER_HEATER]:
- if device.controllable_name in CONTROLLABLE_NAME_TO_WATER_HEATER_ENTITY:
- entities.append(
- CONTROLLABLE_NAME_TO_WATER_HEATER_ENTITY[device.controllable_name](
- device.device_url, data.coordinator
- )
- )
- elif device.widget in WIDGET_TO_WATER_HEATER_ENTITY:
- entities.append(
- WIDGET_TO_WATER_HEATER_ENTITY[device.widget](
- device.device_url, data.coordinator
- )
- )
-
- async_add_entities(entities)
diff --git a/homeassistant/components/overkiz/water_heater/__init__.py b/homeassistant/components/overkiz/water_heater/__init__.py
index 1fb5e5696bd..1dd1d596a33 100644
--- a/homeassistant/components/overkiz/water_heater/__init__.py
+++ b/homeassistant/components/overkiz/water_heater/__init__.py
@@ -4,13 +4,11 @@ from __future__ import annotations
from pyoverkiz.enums.ui import UIWidget
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from .. import HomeAssistantOverkizData
-from ..const import DOMAIN
+from .. import OverkizDataConfigEntry
from ..entity import OverkizEntity
from .atlantic_domestic_hot_water_production_mlb_component import (
AtlanticDomesticHotWaterProductionMBLComponent,
@@ -22,11 +20,11 @@ from .hitachi_dhw import HitachiDHW
async def async_setup_entry(
hass: HomeAssistant,
- entry: ConfigEntry,
+ entry: OverkizDataConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Overkiz DHW from a config entry."""
- data: HomeAssistantOverkizData = hass.data[DOMAIN][entry.entry_id]
+ data = entry.runtime_data
entities: list[OverkizEntity] = []
for device in data.platforms[Platform.WATER_HEATER]:
diff --git a/homeassistant/components/overkiz/water_heater/atlantic_domestic_hot_water_production_mlb_component.py b/homeassistant/components/overkiz/water_heater/atlantic_domestic_hot_water_production_mlb_component.py
index 1b2a1e218d4..8ba2c1678c2 100644
--- a/homeassistant/components/overkiz/water_heater/atlantic_domestic_hot_water_production_mlb_component.py
+++ b/homeassistant/components/overkiz/water_heater/atlantic_domestic_hot_water_production_mlb_component.py
@@ -13,6 +13,7 @@ from homeassistant.components.water_heater import (
WaterHeaterEntityFeature,
)
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
+from homeassistant.util import dt as dt_util
from .. import OverkizDataUpdateCoordinator
from ..entity import OverkizEntity
@@ -153,11 +154,11 @@ class AtlanticDomesticHotWaterProductionMBLComponent(OverkizEntity, WaterHeaterE
async def async_turn_away_mode_on(self) -> None:
"""Turn away mode on.
- This requires the start date and the end date to be also set.
+ This requires the start date and the end date to be also set, and those dates have to match the device datetime.
The API accepts setting dates in the format of the core:DateTimeState state for the DHW
- {'day': 11, 'hour': 21, 'minute': 12, 'month': 7, 'second': 53, 'weekday': 3, 'year': 2024})
- The dict is then passed as an away mode start date, and then as an end date, but with the year incremented by 1,
- so the away mode is getting turned on for the next year.
+ {'day': 11, 'hour': 21, 'minute': 12, 'month': 7, 'second': 53, 'weekday': 3, 'year': 2024}
+ The dict is then passed as an actual device date, the away mode start date, and then as an end date,
+ but with the year incremented by 1, so the away mode is getting turned on for the next year.
The weekday number seems to have no effect so the calculation of the future date's weekday number is redundant,
but possible via homeassistant dt_util to form both start and end dates dictionaries from scratch
based on datetime.now() and datetime.timedelta into the future.
@@ -167,13 +168,19 @@ class AtlanticDomesticHotWaterProductionMBLComponent(OverkizEntity, WaterHeaterE
With `refresh_afterwards=False` on the first commands, and `refresh_afterwards=True` only the last command,
the API is not choking and the transition is smooth without the unavailability state.
"""
- now_date = cast(
- dict,
- self.executor.select_state(OverkizState.CORE_DATETIME),
- )
+ now = dt_util.now()
+ now_date = {
+ "month": now.month,
+ "hour": now.hour,
+ "year": now.year,
+ "weekday": now.weekday(),
+ "day": now.day,
+ "minute": now.minute,
+ "second": now.second,
+ }
await self.executor.async_execute_command(
- OverkizCommand.SET_ABSENCE_MODE,
- OverkizCommandParam.PROG,
+ OverkizCommand.SET_DATE_TIME,
+ now_date,
refresh_afterwards=False,
)
await self.executor.async_execute_command(
@@ -183,7 +190,11 @@ class AtlanticDomesticHotWaterProductionMBLComponent(OverkizEntity, WaterHeaterE
await self.executor.async_execute_command(
OverkizCommand.SET_ABSENCE_END_DATE, now_date, refresh_afterwards=False
)
-
+ await self.executor.async_execute_command(
+ OverkizCommand.SET_ABSENCE_MODE,
+ OverkizCommandParam.PROG,
+ refresh_afterwards=False,
+ )
await self.coordinator.async_refresh()
async def async_turn_away_mode_off(self) -> None:
diff --git a/homeassistant/components/overkiz/water_heater/hitachi_dhw.py b/homeassistant/components/overkiz/water_heater/hitachi_dhw.py
index dc2a93a8d2f..988c66afdb0 100644
--- a/homeassistant/components/overkiz/water_heater/hitachi_dhw.py
+++ b/homeassistant/components/overkiz/water_heater/hitachi_dhw.py
@@ -48,8 +48,10 @@ class HitachiDHW(OverkizEntity, WaterHeaterEntity):
def current_temperature(self) -> float | None:
"""Return the current temperature."""
current_temperature = self.device.states[OverkizState.CORE_DHW_TEMPERATURE]
- if current_temperature:
- return current_temperature.value_as_float
+
+ if current_temperature and current_temperature.value_as_int:
+ return float(current_temperature.value_as_int)
+
return None
@property
@@ -58,13 +60,14 @@ class HitachiDHW(OverkizEntity, WaterHeaterEntity):
target_temperature = self.device.states[
OverkizState.MODBUS_CONTROL_DHW_SETTING_TEMPERATURE
]
- if target_temperature:
- return target_temperature.value_as_float
+
+ if target_temperature and target_temperature.value_as_int:
+ return float(target_temperature.value_as_int)
+
return None
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperature."""
-
await self.executor.async_execute_command(
OverkizCommand.SET_CONTROL_DHW_SETTING_TEMPERATURE,
int(kwargs[ATTR_TEMPERATURE]),
diff --git a/homeassistant/components/overseerr/__init__.py b/homeassistant/components/overseerr/__init__.py
new file mode 100644
index 00000000000..c16b02739ed
--- /dev/null
+++ b/homeassistant/components/overseerr/__init__.py
@@ -0,0 +1,136 @@
+"""The Overseerr integration."""
+
+from __future__ import annotations
+
+import json
+
+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.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.http import HomeAssistantView
+from homeassistant.helpers.typing import ConfigType
+
+from .const import DOMAIN, JSON_PAYLOAD, LOGGER, REGISTERED_NOTIFICATIONS
+from .coordinator import OverseerrConfigEntry, OverseerrCoordinator
+from .services import setup_services
+
+PLATFORMS: list[Platform] = [Platform.SENSOR]
+
+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)
+
+
+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)
+ 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():
+ return
+ for url in self.webhook_urls:
+ 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,
+ )
+ return
+ LOGGER.error("Failed to set Overseerr webhook")
+
+ 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 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()
+ return HomeAssistantView.json({"message": "ok"})
+
+ async def unregister_webhook(self) -> None:
+ """Unregister webhook."""
+ async_unregister(self.hass, self.entry.data[CONF_WEBHOOK_ID])
diff --git a/homeassistant/components/overseerr/config_flow.py b/homeassistant/components/overseerr/config_flow.py
new file mode 100644
index 00000000000..2ad0c8d6d61
--- /dev/null
+++ b/homeassistant/components/overseerr/config_flow.py
@@ -0,0 +1,69 @@
+"""Config flow for Overseerr."""
+
+from typing import Any
+
+from python_overseerr import OverseerrClient
+from python_overseerr.exceptions import OverseerrError
+import voluptuous as vol
+from yarl import URL
+
+from homeassistant.components.webhook import async_generate_id
+from homeassistant.config_entries import 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 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
+ client = OverseerrClient(
+ host,
+ port,
+ user_input[CONF_API_KEY],
+ ssl=url.scheme == "https",
+ session=async_get_clientsession(self.hass),
+ )
+ try:
+ await client.get_request_count()
+ except OverseerrError:
+ errors["base"] = "cannot_connect"
+ else:
+ 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(),
+ },
+ )
+ 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,
+ )
diff --git a/homeassistant/components/overseerr/const.py b/homeassistant/components/overseerr/const.py
new file mode 100644
index 00000000000..48f5436c336
--- /dev/null
+++ b/homeassistant/components/overseerr/const.py
@@ -0,0 +1,50 @@
+"""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"
+
+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}}\\",\\"event\\":\\"'
+ '{{event}}\\",\\"subject\\":\\"{{subject}}\\",\\"message\\":\\"{{messa'
+ 'ge}}\\",\\"image\\":\\"{{image}}\\",\\"{{media}}\\":{\\"media_type\\"'
+ ':\\"{{media_type}}\\",\\"tmdbId\\":\\"{{media_tmdbid}}\\",\\"tvdbId\\'
+ '":\\"{{media_tvdbid}}\\",\\"status\\":\\"{{media_status}}\\",\\"statu'
+ 's4k\\":\\"{{media_status4k}}\\"},\\"{{request}}\\":{\\"request_id\\":'
+ '\\"{{request_id}}\\",\\"requestedBy_email\\":\\"{{requestedBy_email}}'
+ '\\",\\"requestedBy_username\\":\\"{{requestedBy_username}}\\",\\"requ'
+ 'estedBy_avatar\\":\\"{{requestedBy_avatar}}\\",\\"requestedBy_setting'
+ 's_discordId\\":\\"{{requestedBy_settings_discordId}}\\",\\"requestedB'
+ 'y_settings_telegramChatId\\":\\"{{requestedBy_settings_telegramChatId'
+ '}}\\"},\\"{{issue}}\\":{\\"issue_id\\":\\"{{issue_id}}\\",\\"issue_ty'
+ 'pe\\":\\"{{issue_type}}\\",\\"issue_status\\":\\"{{issue_status}}\\",'
+ '\\"reportedBy_email\\":\\"{{reportedBy_email}}\\",\\"reportedBy_usern'
+ 'ame\\":\\"{{reportedBy_username}}\\",\\"reportedBy_avatar\\":\\"{{rep'
+ 'ortedBy_avatar}}\\",\\"reportedBy_settings_discordId\\":\\"{{reported'
+ 'By_settings_discordId}}\\",\\"reportedBy_settings_telegramChatId\\":'
+ '\\"{{reportedBy_settings_telegramChatId}}\\"},\\"{{comment}}\\":{\\"c'
+ 'omment_message\\":\\"{{comment_message}}\\",\\"commentedBy_email\\":'
+ '\\"{{commentedBy_email}}\\",\\"commentedBy_username\\":\\"{{commented'
+ 'By_username}}\\",\\"commentedBy_avatar\\":\\"{{commentedBy_avatar}}'
+ '\\",\\"commentedBy_settings_discordId\\":\\"{{commentedBy_settings_di'
+ 'scordId}}\\",\\"commentedBy_settings_telegramChatId\\":\\"{{commented'
+ 'By_settings_telegramChatId}}\\"},\\"{{extra}}\\":[]\\n}"'
+)
diff --git a/homeassistant/components/overseerr/coordinator.py b/homeassistant/components/overseerr/coordinator.py
new file mode 100644
index 00000000000..79ad738c037
--- /dev/null
+++ b/homeassistant/components/overseerr/coordinator.py
@@ -0,0 +1,50 @@
+"""Define an object to coordinate fetching Overseerr data."""
+
+from datetime import timedelta
+
+from python_overseerr import OverseerrClient, RequestCount
+from python_overseerr.exceptions import OverseerrConnectionError
+
+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.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),
+ )
+ self.client = OverseerrClient(
+ entry.data[CONF_HOST],
+ entry.data[CONF_PORT],
+ entry.data[CONF_API_KEY],
+ ssl=entry.data[CONF_SSL],
+ session=async_get_clientsession(hass),
+ )
+
+ async def _async_update_data(self) -> RequestCount:
+ """Fetch data from API endpoint."""
+ try:
+ return await self.client.get_request_count()
+ 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/entity.py b/homeassistant/components/overseerr/entity.py
new file mode 100644
index 00000000000..6e835347736
--- /dev/null
+++ b/homeassistant/components/overseerr/entity.py
@@ -0,0 +1,22 @@
+"""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,
+ )
+ self._attr_unique_id = f"{coordinator.config_entry.entry_id}-{key}"
diff --git a/homeassistant/components/overseerr/icons.json b/homeassistant/components/overseerr/icons.json
new file mode 100644
index 00000000000..2876eb5f882
--- /dev/null
+++ b/homeassistant/components/overseerr/icons.json
@@ -0,0 +1,32 @@
+{
+ "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"
+ }
+ }
+ },
+ "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..ddcf9ccce5e
--- /dev/null
+++ b/homeassistant/components/overseerr/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "overseerr",
+ "name": "Overseerr",
+ "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": "bronze",
+ "requirements": ["python-overseerr==0.5.0"]
+}
diff --git a/homeassistant/components/overseerr/quality_scale.yaml b/homeassistant/components/overseerr/quality_scale.yaml
new file mode 100644
index 00000000000..144f5c1977c
--- /dev/null
+++ b/homeassistant/components/overseerr/quality_scale.yaml
@@ -0,0 +1,83 @@
+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: todo
+ test-coverage: todo
+ # Gold
+ devices: done
+ diagnostics: todo
+ 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: 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 has a fixed single device.
+ entity-category: todo
+ entity-device-class: todo
+ entity-disabled-by-default: todo
+ entity-translations: done
+ exception-translations: done
+ icon-translations: todo
+ reconfiguration-flow: todo
+ 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..338c9d91a38
--- /dev/null
+++ b/homeassistant/components/overseerr/strings.json
@@ -0,0 +1,101 @@
+{
+ "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."
+ }
+ }
+ },
+ "abort": {
+ "already_configured": "[%key:common::config_flow::abort::already_configured_service%]"
+ },
+ "error": {
+ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
+ "invalid_host": "The provided URL is not a valid host."
+ }
+ },
+ "entity": {
+ "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}"
+ },
+ "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/p1_monitor/__init__.py b/homeassistant/components/p1_monitor/__init__.py
index 3361506dafb..d2ccc83972a 100644
--- a/homeassistant/components/p1_monitor/__init__.py
+++ b/homeassistant/components/p1_monitor/__init__.py
@@ -7,10 +7,12 @@ from homeassistant.const import CONF_HOST, CONF_PORT, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
-from .const import DOMAIN, LOGGER
+from .const import LOGGER
from .coordinator import P1MonitorDataUpdateCoordinator
-PLATFORMS = [Platform.SENSOR]
+PLATFORMS: list[Platform] = [Platform.SENSOR]
+
+type P1MonitorConfigEntry = ConfigEntry[P1MonitorDataUpdateCoordinator]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
@@ -23,8 +25,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
await coordinator.p1monitor.close()
raise
- hass.data.setdefault(DOMAIN, {})
- hass.data[DOMAIN][entry.entry_id] = coordinator
+ entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
@@ -55,7 +56,4 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload P1 Monitor 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/p1_monitor/diagnostics.py b/homeassistant/components/p1_monitor/diagnostics.py
index c8b4e99099e..d2e2ec5c24e 100644
--- a/homeassistant/components/p1_monitor/diagnostics.py
+++ b/homeassistant/components/p1_monitor/diagnostics.py
@@ -11,13 +11,11 @@ from homeassistant.const import CONF_HOST, CONF_PORT
from homeassistant.core import HomeAssistant
from .const import (
- DOMAIN,
SERVICE_PHASES,
SERVICE_SETTINGS,
SERVICE_SMARTMETER,
SERVICE_WATERMETER,
)
-from .coordinator import P1MonitorDataUpdateCoordinator
if TYPE_CHECKING:
from _typeshed import DataclassInstance
@@ -29,23 +27,21 @@ async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: ConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
- coordinator: P1MonitorDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
-
data = {
"entry": {
"title": entry.title,
"data": async_redact_data(entry.data, TO_REDACT),
},
"data": {
- "smartmeter": asdict(coordinator.data[SERVICE_SMARTMETER]),
- "phases": asdict(coordinator.data[SERVICE_PHASES]),
- "settings": asdict(coordinator.data[SERVICE_SETTINGS]),
+ "smartmeter": asdict(entry.runtime_data.data[SERVICE_SMARTMETER]),
+ "phases": asdict(entry.runtime_data.data[SERVICE_PHASES]),
+ "settings": asdict(entry.runtime_data.data[SERVICE_SETTINGS]),
},
}
- if coordinator.has_water_meter:
+ if entry.runtime_data.has_water_meter:
data["data"]["watermeter"] = asdict(
- cast("DataclassInstance", coordinator.data[SERVICE_WATERMETER])
+ cast("DataclassInstance", entry.runtime_data.data[SERVICE_WATERMETER])
)
return data
diff --git a/homeassistant/components/p1_monitor/manifest.json b/homeassistant/components/p1_monitor/manifest.json
index dfc681977a5..28016242a6a 100644
--- a/homeassistant/components/p1_monitor/manifest.json
+++ b/homeassistant/components/p1_monitor/manifest.json
@@ -6,6 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/p1_monitor",
"iot_class": "local_polling",
"loggers": ["p1monitor"],
- "quality_scale": "platinum",
"requirements": ["p1monitor==3.1.0"]
}
diff --git a/homeassistant/components/p1_monitor/sensor.py b/homeassistant/components/p1_monitor/sensor.py
index 88f6d165f14..771ef0e19af 100644
--- a/homeassistant/components/p1_monitor/sensor.py
+++ b/homeassistant/components/p1_monitor/sensor.py
@@ -239,11 +239,10 @@ async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Set up P1 Monitor Sensors based on a config entry."""
- coordinator: P1MonitorDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
entities: list[P1MonitorSensorEntity] = []
entities.extend(
P1MonitorSensorEntity(
- coordinator=coordinator,
+ entry=entry,
description=description,
name="SmartMeter",
service=SERVICE_SMARTMETER,
@@ -252,7 +251,7 @@ async def async_setup_entry(
)
entities.extend(
P1MonitorSensorEntity(
- coordinator=coordinator,
+ entry=entry,
description=description,
name="Phases",
service=SERVICE_PHASES,
@@ -261,17 +260,17 @@ async def async_setup_entry(
)
entities.extend(
P1MonitorSensorEntity(
- coordinator=coordinator,
+ entry=entry,
description=description,
name="Settings",
service=SERVICE_SETTINGS,
)
for description in SENSORS_SETTINGS
)
- if coordinator.has_water_meter:
+ if entry.runtime_data.has_water_meter:
entities.extend(
P1MonitorSensorEntity(
- coordinator=coordinator,
+ entry=entry,
description=description,
name="WaterMeter",
service=SERVICE_WATERMETER,
@@ -291,24 +290,26 @@ class P1MonitorSensorEntity(
def __init__(
self,
*,
- coordinator: P1MonitorDataUpdateCoordinator,
+ entry: ConfigEntry,
description: SensorEntityDescription,
name: str,
service: Literal["smartmeter", "watermeter", "phases", "settings"],
) -> None:
"""Initialize P1 Monitor sensor."""
- super().__init__(coordinator=coordinator)
+ super().__init__(coordinator=entry.runtime_data)
self._service_key = service
self.entity_description = description
self._attr_unique_id = (
- f"{coordinator.config_entry.entry_id}_{service}_{description.key}"
+ f"{entry.runtime_data.config_entry.entry_id}_{service}_{description.key}"
)
self._attr_device_info = DeviceInfo(
entry_type=DeviceEntryType.SERVICE,
- identifiers={(DOMAIN, f"{coordinator.config_entry.entry_id}_{service}")},
- configuration_url=f"http://{coordinator.config_entry.data[CONF_HOST]}",
+ identifiers={
+ (DOMAIN, f"{entry.runtime_data.config_entry.entry_id}_{service}")
+ },
+ configuration_url=f"http://{entry.runtime_data.config_entry.data[CONF_HOST]}",
manufacturer="P1 Monitor",
name=name,
)
diff --git a/homeassistant/components/palazzetti/__init__.py b/homeassistant/components/palazzetti/__init__.py
index ecaa8089097..f20b3d11261 100644
--- a/homeassistant/components/palazzetti/__init__.py
+++ b/homeassistant/components/palazzetti/__init__.py
@@ -7,7 +7,7 @@ from homeassistant.core import HomeAssistant
from .coordinator import PalazzettiConfigEntry, PalazzettiDataUpdateCoordinator
-PLATFORMS: list[Platform] = [Platform.CLIMATE]
+PLATFORMS: list[Platform] = [Platform.CLIMATE, Platform.NUMBER, Platform.SENSOR]
async def async_setup_entry(hass: HomeAssistant, entry: PalazzettiConfigEntry) -> bool:
diff --git a/homeassistant/components/palazzetti/climate.py b/homeassistant/components/palazzetti/climate.py
index aff988051f3..356f3a7306f 100644
--- a/homeassistant/components/palazzetti/climate.py
+++ b/homeassistant/components/palazzetti/climate.py
@@ -7,18 +7,18 @@ from pypalazzetti.exceptions import CommunicationError, ValidationError
from homeassistant.components.climate import (
ClimateEntity,
ClimateEntityFeature,
+ HVACAction,
HVACMode,
)
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
-from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import PalazzettiConfigEntry
-from .const import DOMAIN, FAN_AUTO, FAN_HIGH, FAN_MODES, FAN_SILENT, PALAZZETTI
+from .const import DOMAIN, FAN_AUTO, FAN_HIGH, FAN_MODES, FAN_SILENT
from .coordinator import PalazzettiDataUpdateCoordinator
+from .entity import PalazzettiEntity
async def async_setup_entry(
@@ -30,9 +30,7 @@ async def async_setup_entry(
async_add_entities([PalazzettiClimateEntity(entry.runtime_data)])
-class PalazzettiClimateEntity(
- CoordinatorEntity[PalazzettiDataUpdateCoordinator], ClimateEntity
-):
+class PalazzettiClimateEntity(PalazzettiEntity, ClimateEntity):
"""Defines a Palazzetti climate."""
_attr_has_entity_name = True
@@ -52,15 +50,7 @@ class PalazzettiClimateEntity(
super().__init__(coordinator)
client = coordinator.client
mac = coordinator.config_entry.unique_id
- assert mac is not None
self._attr_unique_id = mac
- self._attr_device_info = dr.DeviceInfo(
- connections={(dr.CONNECTION_NETWORK_MAC, mac)},
- name=client.name,
- manufacturer=PALAZZETTI,
- sw_version=client.sw_version,
- hw_version=client.hw_version,
- )
self._attr_hvac_modes = [HVACMode.HEAT, HVACMode.OFF]
self._attr_min_temp = client.target_temperature_min
self._attr_max_temp = client.target_temperature_max
@@ -74,16 +64,19 @@ class PalazzettiClimateEntity(
if client.has_fan_auto:
self._attr_fan_modes.append(FAN_AUTO)
- @property
- def available(self) -> bool:
- """Is the entity available."""
- return super().available and self.coordinator.client.connected
-
@property
def hvac_mode(self) -> HVACMode:
"""Return hvac operation ie. heat or off mode."""
- is_heating = bool(self.coordinator.client.is_heating)
- return HVACMode.HEAT if is_heating else HVACMode.OFF
+ return HVACMode.HEAT if self.coordinator.client.is_on else HVACMode.OFF
+
+ @property
+ def hvac_action(self) -> HVACAction:
+ """Return hvac action ie. heating or idle."""
+ return (
+ HVACAction.HEATING
+ if self.coordinator.client.is_heating
+ else HVACAction.IDLE
+ )
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set new target hvac mode."""
diff --git a/homeassistant/components/palazzetti/config_flow.py b/homeassistant/components/palazzetti/config_flow.py
index a58461b9ca7..fe892b6624d 100644
--- a/homeassistant/components/palazzetti/config_flow.py
+++ b/homeassistant/components/palazzetti/config_flow.py
@@ -6,6 +6,7 @@ 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
@@ -16,6 +17,8 @@ from .const import DOMAIN, LOGGER
class PalazzettiConfigFlow(ConfigFlow, domain=DOMAIN):
"""Palazzetti config flow."""
+ _discovered_device: PalazzettiClient
+
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
@@ -48,3 +51,41 @@ class PalazzettiConfigFlow(ConfigFlow, domain=DOMAIN):
data_schema=vol.Schema({vol.Required(CONF_HOST): str}),
errors=errors,
)
+
+ async def async_step_dhcp(
+ self, discovery_info: dhcp.DhcpServiceInfo
+ ) -> ConfigFlowResult:
+ """Handle DHCP discovery."""
+
+ LOGGER.debug(
+ "DHCP discovery detected Palazzetti: %s", discovery_info.macaddress
+ )
+
+ await self.async_set_unique_id(dr.format_mac(discovery_info.macaddress))
+ self._abort_if_unique_id_configured()
+ self._discovered_device = PalazzettiClient(hostname=discovery_info.ip)
+ try:
+ await self._discovered_device.connect()
+ except CommunicationError:
+ return self.async_abort(reason="cannot_connect")
+
+ 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._discovered_device.name,
+ data={CONF_HOST: self._discovered_device.host},
+ )
+
+ self._set_confirm_only()
+ return self.async_show_form(
+ step_id="discovery_confirm",
+ description_placeholders={
+ "name": self._discovered_device.name,
+ "host": self._discovered_device.host,
+ },
+ )
diff --git a/homeassistant/components/palazzetti/const.py b/homeassistant/components/palazzetti/const.py
index 4cb8b1f14a6..b2e27b2a6fd 100644
--- a/homeassistant/components/palazzetti/const.py
+++ b/homeassistant/components/palazzetti/const.py
@@ -4,6 +4,8 @@ from datetime import timedelta
import logging
from typing import Final
+from homeassistant.helpers.typing import StateType
+
DOMAIN: Final = "palazzetti"
PALAZZETTI: Final = "Palazzetti"
LOGGER = logging.getLogger(__package__)
@@ -17,3 +19,53 @@ 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]
+
+STATUS_TO_HA: Final[dict[StateType, str]] = {
+ 0: "off",
+ 1: "off_timer",
+ 2: "test_fire",
+ 3: "heatup",
+ 4: "fueling",
+ 5: "ign_test",
+ 6: "burning",
+ 7: "burning_mod",
+ 8: "unknown",
+ 9: "cool_fluid",
+ 10: "fire_stop",
+ 11: "clean_fire",
+ 12: "cooling",
+ 50: "cleanup",
+ 51: "ecomode",
+ 241: "chimney_alarm",
+ 243: "grate_error",
+ 244: "pellet_water_error",
+ 245: "t05_error",
+ 247: "hatch_door_open",
+ 248: "pressure_error",
+ 249: "main_probe_failure",
+ 250: "flue_probe_failure",
+ 252: "exhaust_temp_high",
+ 253: "pellet_finished",
+ 501: "off",
+ 502: "fueling",
+ 503: "ign_test",
+ 504: "burning",
+ 505: "firewood_finished",
+ 506: "cooling",
+ 507: "clean_fire",
+ 1000: "general_error",
+ 1001: "general_error",
+ 1239: "door_open",
+ 1240: "temp_too_high",
+ 1241: "cleaning_warning",
+ 1243: "fuel_error",
+ 1244: "pellet_water_error",
+ 1245: "t05_error",
+ 1247: "hatch_door_open",
+ 1248: "pressure_error",
+ 1249: "main_probe_failure",
+ 1250: "flue_probe_failure",
+ 1252: "exhaust_temp_high",
+ 1253: "pellet_finished",
+ 1508: "general_error",
+}
diff --git a/homeassistant/components/palazzetti/diagnostics.py b/homeassistant/components/palazzetti/diagnostics.py
new file mode 100644
index 00000000000..3843f0ec111
--- /dev/null
+++ b/homeassistant/components/palazzetti/diagnostics.py
@@ -0,0 +1,20 @@
+"""Provides diagnostics for Palazzetti."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from homeassistant.core import HomeAssistant
+
+from . import PalazzettiConfigEntry
+
+
+async def async_get_config_entry_diagnostics(
+ hass: HomeAssistant, entry: PalazzettiConfigEntry
+) -> dict[str, Any]:
+ """Return diagnostics for a config entry."""
+ client = entry.runtime_data.client
+
+ return {
+ "api_data": client.to_dict(redact=True),
+ }
diff --git a/homeassistant/components/palazzetti/entity.py b/homeassistant/components/palazzetti/entity.py
new file mode 100644
index 00000000000..677c6ccbdc4
--- /dev/null
+++ b/homeassistant/components/palazzetti/entity.py
@@ -0,0 +1,32 @@
+"""Base class for Palazzetti entities."""
+
+from homeassistant.helpers import device_registry as dr
+from homeassistant.helpers.update_coordinator import CoordinatorEntity
+
+from .const import PALAZZETTI
+from .coordinator import PalazzettiDataUpdateCoordinator
+
+
+class PalazzettiEntity(CoordinatorEntity[PalazzettiDataUpdateCoordinator]):
+ """Defines a base Palazzetti entity."""
+
+ _attr_has_entity_name = True
+
+ def __init__(self, coordinator: PalazzettiDataUpdateCoordinator) -> None:
+ """Initialize Palazzetti entity."""
+ super().__init__(coordinator)
+ client = coordinator.client
+ mac = coordinator.config_entry.unique_id
+ assert mac is not None
+ self._attr_device_info = dr.DeviceInfo(
+ connections={(dr.CONNECTION_NETWORK_MAC, mac)},
+ name=client.name,
+ manufacturer=PALAZZETTI,
+ sw_version=client.sw_version,
+ hw_version=client.hw_version,
+ )
+
+ @property
+ def available(self) -> bool:
+ """Is the entity available."""
+ return super().available and self.coordinator.client.connected
diff --git a/homeassistant/components/palazzetti/manifest.json b/homeassistant/components/palazzetti/manifest.json
index a1b25f563bf..70e58507159 100644
--- a/homeassistant/components/palazzetti/manifest.json
+++ b/homeassistant/components/palazzetti/manifest.json
@@ -3,8 +3,17 @@
"name": "Palazzetti",
"codeowners": ["@dotvav"],
"config_flow": true,
+ "dhcp": [
+ {
+ "hostname": "connbox*",
+ "macaddress": "40F3857*"
+ },
+ {
+ "registered_devices": true
+ }
+ ],
"documentation": "https://www.home-assistant.io/integrations/palazzetti",
"integration_type": "device",
"iot_class": "local_polling",
- "requirements": ["pypalazzetti==0.1.10"]
+ "requirements": ["pypalazzetti==0.1.15"]
}
diff --git a/homeassistant/components/palazzetti/number.py b/homeassistant/components/palazzetti/number.py
new file mode 100644
index 00000000000..06114bfef54
--- /dev/null
+++ b/homeassistant/components/palazzetti/number.py
@@ -0,0 +1,66 @@
+"""Number platform for Palazzetti settings."""
+
+from __future__ import annotations
+
+from pypalazzetti.exceptions import CommunicationError, ValidationError
+
+from homeassistant.components.number import NumberDeviceClass, NumberEntity
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
+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 number platform."""
+ async_add_entities([PalazzettiCombustionPowerEntity(config_entry.runtime_data)])
+
+
+class PalazzettiCombustionPowerEntity(PalazzettiEntity, NumberEntity):
+ """Representation of Palazzetti number entity for Combustion power."""
+
+ _attr_translation_key = "combustion_power"
+ _attr_device_class = NumberDeviceClass.POWER_FACTOR
+ _attr_native_min_value = 1
+ _attr_native_max_value = 5
+ _attr_native_step = 1
+
+ def __init__(
+ self,
+ coordinator: PalazzettiDataUpdateCoordinator,
+ ) -> None:
+ """Initialize the Palazzetti number entity."""
+ super().__init__(coordinator)
+ self._attr_unique_id = f"{coordinator.config_entry.unique_id}-combustion_power"
+
+ @property
+ def native_value(self) -> float:
+ """Return the state of the setting entity."""
+ return self.coordinator.client.power_mode
+
+ async def async_set_native_value(self, value: float) -> None:
+ """Update the setting."""
+ try:
+ await self.coordinator.client.set_power_mode(int(value))
+ 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_combustion_power",
+ translation_placeholders={
+ "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
new file mode 100644
index 00000000000..493b2595117
--- /dev/null
+++ b/homeassistant/components/palazzetti/quality_scale.yaml
@@ -0,0 +1,86 @@
+rules:
+ # Bronze
+ action-setup:
+ status: exempt
+ comment: |
+ This integration does not register 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 register actions.
+ docs-high-level-description: done
+ docs-installation-instructions: todo
+ docs-removal-instructions: todo
+ entity-event-setup:
+ status: exempt
+ comment: |
+ This integration does not 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: |
+ This integration does not have configuration.
+ docs-installation-parameters: todo
+ entity-unavailable: done
+ integration-owner: done
+ log-when-unavailable: done
+ parallel-updates: todo
+ reauthentication-flow:
+ status: exempt
+ comment: |
+ This integration does not require authentication.
+ test-coverage: todo
+ # Gold
+ devices: done
+ diagnostics: done
+ 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:
+ status: exempt
+ comment: |
+ This integration connects to a single device.
+ entity-category: todo
+ entity-device-class: done
+ entity-disabled-by-default: todo
+ entity-translations: done
+ exception-translations: done
+ icon-translations:
+ status: exempt
+ comment: |
+ This integration does not have custom icons.
+ reconfiguration-flow: todo
+ repair-issues:
+ status: exempt
+ comment: |
+ This integration does not raise any repairable issues.
+ stale-devices:
+ status: exempt
+ comment: |
+ This integration connects to a single device.
+
+ # Platinum
+ async-dependency: done
+ inject-websession: todo
+ strict-typing: todo
diff --git a/homeassistant/components/palazzetti/sensor.py b/homeassistant/components/palazzetti/sensor.py
new file mode 100644
index 00000000000..11462201f4e
--- /dev/null
+++ b/homeassistant/components/palazzetti/sensor.py
@@ -0,0 +1,123 @@
+"""Support for Palazzetti sensors."""
+
+from dataclasses import dataclass
+
+from homeassistant.components.sensor import (
+ SensorDeviceClass,
+ SensorEntity,
+ SensorEntityDescription,
+ SensorStateClass,
+)
+from homeassistant.const import UnitOfLength, UnitOfMass, UnitOfTemperature
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.typing import StateType
+
+from . import PalazzettiConfigEntry
+from .const import STATUS_TO_HA
+from .coordinator import PalazzettiDataUpdateCoordinator
+from .entity import PalazzettiEntity
+
+
+@dataclass(frozen=True, kw_only=True)
+class PropertySensorEntityDescription(SensorEntityDescription):
+ """Describes a Palazzetti sensor entity that is read from a `PalazzettiClient` property."""
+
+ client_property: str
+ property_map: dict[StateType, str] | None = None
+ presence_flag: None | str = None
+
+
+PROPERTY_SENSOR_DESCRIPTIONS: list[PropertySensorEntityDescription] = [
+ PropertySensorEntityDescription(
+ key="status",
+ device_class=SensorDeviceClass.ENUM,
+ translation_key="status",
+ client_property="status",
+ property_map=STATUS_TO_HA,
+ options=list(STATUS_TO_HA.values()),
+ ),
+ PropertySensorEntityDescription(
+ key="pellet_quantity",
+ device_class=SensorDeviceClass.WEIGHT,
+ native_unit_of_measurement=UnitOfMass.KILOGRAMS,
+ state_class=SensorStateClass.MEASUREMENT,
+ translation_key="pellet_quantity",
+ client_property="pellet_quantity",
+ ),
+ PropertySensorEntityDescription(
+ key="pellet_level",
+ device_class=SensorDeviceClass.DISTANCE,
+ native_unit_of_measurement=UnitOfLength.CENTIMETERS,
+ state_class=SensorStateClass.MEASUREMENT,
+ translation_key="pellet_level",
+ presence_flag="has_pellet_level",
+ client_property="pellet_level",
+ ),
+]
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: PalazzettiConfigEntry,
+ async_add_entities: AddEntitiesCallback,
+) -> None:
+ """Set up Palazzetti sensor entities based on a config entry."""
+
+ coordinator = entry.runtime_data
+
+ sensors = [
+ PalazzettiSensor(
+ coordinator,
+ PropertySensorEntityDescription(
+ key=sensor.description_key.value,
+ device_class=SensorDeviceClass.TEMPERATURE,
+ native_unit_of_measurement=UnitOfTemperature.CELSIUS,
+ state_class=SensorStateClass.MEASUREMENT,
+ translation_key=sensor.description_key.value,
+ client_property=sensor.state_property,
+ ),
+ )
+ for sensor in coordinator.client.list_temperatures()
+ ]
+
+ sensors.extend(
+ [
+ PalazzettiSensor(coordinator, description)
+ for description in PROPERTY_SENSOR_DESCRIPTIONS
+ if not description.presence_flag
+ or getattr(coordinator.client, description.presence_flag)
+ ]
+ )
+
+ if sensors:
+ async_add_entities(sensors)
+
+
+class PalazzettiSensor(PalazzettiEntity, SensorEntity):
+ """Define a Palazzetti sensor."""
+
+ entity_description: PropertySensorEntityDescription
+
+ def __init__(
+ self,
+ coordinator: PalazzettiDataUpdateCoordinator,
+ description: PropertySensorEntityDescription,
+ ) -> None:
+ """Initialize Palazzetti sensor."""
+ super().__init__(coordinator)
+ self.entity_description = description
+ self._attr_unique_id = f"{coordinator.config_entry.unique_id}-{description.key}"
+
+ @property
+ def native_value(self) -> StateType:
+ """Return the state value of the sensor."""
+
+ raw_value = getattr(
+ self.coordinator.client, self.entity_description.client_property
+ )
+
+ if self.entity_description.property_map:
+ return self.entity_description.property_map[raw_value]
+
+ return raw_value
diff --git a/homeassistant/components/palazzetti/strings.json b/homeassistant/components/palazzetti/strings.json
index fdf50f29f0d..ad7bc498bd1 100644
--- a/homeassistant/components/palazzetti/strings.json
+++ b/homeassistant/components/palazzetti/strings.json
@@ -8,6 +8,9 @@
"data_description": {
"host": "The host name or the IP address of the Palazzetti CBox"
}
+ },
+ "discovery_confirm": {
+ "description": "Do you want to add {name} ({host}) to Home Assistant?"
}
},
"abort": {
@@ -24,9 +27,12 @@
"invalid_fan_mode": {
"message": "Fan mode {value} is invalid."
},
- "invalid_target_temperatures": {
+ "invalid_target_temperature": {
"message": "Target temperature {value} is invalid."
},
+ "invalid_combustion_power": {
+ "message": "Combustion power {value} is invalid."
+ },
"cannot_connect": {
"message": "Could not connect to the device."
}
@@ -44,6 +50,76 @@
}
}
}
+ },
+ "number": {
+ "combustion_power": {
+ "name": "Combustion power"
+ }
+ },
+ "sensor": {
+ "status": {
+ "name": "Status",
+ "state": {
+ "off": "Off",
+ "off_timer": "Timer-regulated switch off",
+ "test_fire": "Ignition test",
+ "heatup": "Pellet feed",
+ "fueling": "Ignition",
+ "ign_test": "Fuel check",
+ "burning": "Operating",
+ "burning_mod": "Operating - Modulating",
+ "unknown": "Unknown",
+ "cool_fluid": "Stand-by",
+ "fire_stop": "Switch off",
+ "clean_fire": "Burn pot cleaning",
+ "cooling": "Cooling in progress",
+ "cleanup": "Final cleaning",
+ "ecomode": "Ecomode",
+ "chimney_alarm": "Chimney alarm",
+ "grate_error": "Grate error",
+ "pellet_water_error": "Pellet probe or return water error",
+ "t05_error": "T05 error disconnected or faulty probe",
+ "hatch_door_open": "Feed hatch or door open",
+ "pressure_error": "Safety pressure switch error",
+ "main_probe_failure": "Main probe failure",
+ "flue_probe_failure": "Flue gas probe failure",
+ "exhaust_temp_high": "Too high exhaust gas temperature",
+ "pellet_finished": "Pellets finished or ignition failed",
+ "firewood_finished": "Firewood finished",
+ "general_error": "General error",
+ "door_open": "Door open",
+ "temp_too_high": "Temperature too high",
+ "cleaning_warning": "Cleaning warning",
+ "fuel_error": "Fuel error"
+ }
+ },
+ "pellet_quantity": {
+ "name": "Pellet quantity"
+ },
+ "pellet_level": {
+ "name": "Pellet level"
+ },
+ "air_outlet_temperature": {
+ "name": "Air outlet temperature"
+ },
+ "wood_combustion_temperature": {
+ "name": "Wood combustion temperature"
+ },
+ "room_temperature": {
+ "name": "Room temperature"
+ },
+ "return_water_temperature": {
+ "name": "Return water temperature"
+ },
+ "tank_water_temperature": {
+ "name": "Tank water temperature"
+ },
+ "t1_hydro": {
+ "name": "Hydro temperature 1"
+ },
+ "t2_hydro": {
+ "name": "Hydro temperature 2"
+ }
}
}
}
diff --git a/homeassistant/components/panasonic_bluray/manifest.json b/homeassistant/components/panasonic_bluray/manifest.json
index fa0202c0871..3de12b051e5 100644
--- a/homeassistant/components/panasonic_bluray/manifest.json
+++ b/homeassistant/components/panasonic_bluray/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/panasonic_bluray",
"iot_class": "local_polling",
"loggers": ["panacotta"],
+ "quality_scale": "legacy",
"requirements": ["panacotta==0.2"]
}
diff --git a/homeassistant/components/pandora/manifest.json b/homeassistant/components/pandora/manifest.json
index b86f0754af3..e67dbac27db 100644
--- a/homeassistant/components/pandora/manifest.json
+++ b/homeassistant/components/pandora/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/pandora",
"iot_class": "local_polling",
"loggers": ["pexpect", "ptyprocess"],
- "requirements": ["pexpect==4.6.0"]
+ "quality_scale": "legacy",
+ "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/peblar/__init__.py b/homeassistant/components/peblar/__init__.py
new file mode 100644
index 00000000000..bf1b3ef7e66
--- /dev/null
+++ b/homeassistant/components/peblar/__init__.py
@@ -0,0 +1,89 @@
+"""Integration for Peblar EV chargers."""
+
+from __future__ import annotations
+
+import asyncio
+
+from aiohttp import CookieJar
+from peblar import (
+ AccessMode,
+ Peblar,
+ PeblarAuthenticationError,
+ PeblarConnectionError,
+ PeblarError,
+)
+
+from homeassistant.const import CONF_HOST, CONF_PASSWORD, Platform
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
+from homeassistant.helpers.aiohttp_client import async_create_clientsession
+
+from .coordinator import (
+ PeblarConfigEntry,
+ PeblarDataUpdateCoordinator,
+ PeblarRuntimeData,
+ PeblarUserConfigurationDataUpdateCoordinator,
+ PeblarVersionDataUpdateCoordinator,
+)
+
+PLATFORMS = [
+ Platform.BINARY_SENSOR,
+ Platform.BUTTON,
+ Platform.NUMBER,
+ Platform.SELECT,
+ Platform.SENSOR,
+ Platform.SWITCH,
+ Platform.UPDATE,
+]
+
+
+async def async_setup_entry(hass: HomeAssistant, entry: PeblarConfigEntry) -> bool:
+ """Set up Peblar from a config entry."""
+
+ # Set up connection to the Peblar charger
+ peblar = Peblar(
+ host=entry.data[CONF_HOST],
+ session=async_create_clientsession(hass, cookie_jar=CookieJar(unsafe=True)),
+ )
+ try:
+ await peblar.login(password=entry.data[CONF_PASSWORD])
+ system_information = await peblar.system_information()
+ api = await peblar.rest_api(enable=True, access_mode=AccessMode.READ_WRITE)
+ except PeblarConnectionError as err:
+ raise ConfigEntryNotReady("Could not connect to Peblar charger") from err
+ except PeblarAuthenticationError as err:
+ raise ConfigEntryAuthFailed from err
+ except PeblarError as err:
+ raise ConfigEntryNotReady(
+ "Unknown error occurred while connecting to Peblar charger"
+ ) from err
+
+ # Setup the data coordinators
+ meter_coordinator = PeblarDataUpdateCoordinator(hass, entry, api)
+ user_configuration_coordinator = PeblarUserConfigurationDataUpdateCoordinator(
+ hass, entry, peblar
+ )
+ version_coordinator = PeblarVersionDataUpdateCoordinator(hass, entry, peblar)
+ await asyncio.gather(
+ meter_coordinator.async_config_entry_first_refresh(),
+ user_configuration_coordinator.async_config_entry_first_refresh(),
+ version_coordinator.async_config_entry_first_refresh(),
+ )
+
+ # Store the runtime data
+ entry.runtime_data = PeblarRuntimeData(
+ data_coordinator=meter_coordinator,
+ system_information=system_information,
+ user_configuration_coordinator=user_configuration_coordinator,
+ version_coordinator=version_coordinator,
+ )
+
+ # Forward the setup to the platforms
+ await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
+
+ return True
+
+
+async def async_unload_entry(hass: HomeAssistant, entry: PeblarConfigEntry) -> bool:
+ """Unload Peblar config entry."""
+ return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
diff --git a/homeassistant/components/peblar/binary_sensor.py b/homeassistant/components/peblar/binary_sensor.py
new file mode 100644
index 00000000000..e8e5095f050
--- /dev/null
+++ b/homeassistant/components/peblar/binary_sensor.py
@@ -0,0 +1,77 @@
+"""Support for Peblar binary sensors."""
+
+from __future__ import annotations
+
+from collections.abc import Callable
+from dataclasses import dataclass
+
+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 .coordinator import PeblarConfigEntry, PeblarData, PeblarDataUpdateCoordinator
+from .entity import PeblarEntity
+
+PARALLEL_UPDATES = 0
+
+
+@dataclass(frozen=True, kw_only=True)
+class PeblarBinarySensorEntityDescription(BinarySensorEntityDescription):
+ """Class describing Peblar binary sensor entities."""
+
+ is_on_fn: Callable[[PeblarData], bool]
+
+
+DESCRIPTIONS = [
+ PeblarBinarySensorEntityDescription(
+ key="active_error_codes",
+ translation_key="active_error_codes",
+ device_class=BinarySensorDeviceClass.PROBLEM,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ is_on_fn=lambda x: bool(x.system.active_error_codes),
+ ),
+ PeblarBinarySensorEntityDescription(
+ key="active_warning_codes",
+ translation_key="active_warning_codes",
+ device_class=BinarySensorDeviceClass.PROBLEM,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ is_on_fn=lambda x: bool(x.system.active_warning_codes),
+ ),
+]
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: PeblarConfigEntry,
+ async_add_entities: AddEntitiesCallback,
+) -> None:
+ """Set up Peblar binary sensor based on a config entry."""
+ async_add_entities(
+ PeblarBinarySensorEntity(
+ entry=entry,
+ coordinator=entry.runtime_data.data_coordinator,
+ description=description,
+ )
+ for description in DESCRIPTIONS
+ )
+
+
+class PeblarBinarySensorEntity(
+ PeblarEntity[PeblarDataUpdateCoordinator],
+ BinarySensorEntity,
+):
+ """Defines a Peblar binary sensor entity."""
+
+ entity_description: PeblarBinarySensorEntityDescription
+
+ @property
+ def is_on(self) -> bool:
+ """Return state of the binary sensor."""
+ return self.entity_description.is_on_fn(self.coordinator.data)
diff --git a/homeassistant/components/peblar/button.py b/homeassistant/components/peblar/button.py
new file mode 100644
index 00000000000..22150c82649
--- /dev/null
+++ b/homeassistant/components/peblar/button.py
@@ -0,0 +1,79 @@
+"""Support for Peblar button."""
+
+from __future__ import annotations
+
+from collections.abc import Awaitable, Callable
+from dataclasses import dataclass
+from typing import Any
+
+from peblar import Peblar
+
+from homeassistant.components.button import (
+ ButtonDeviceClass,
+ ButtonEntity,
+ ButtonEntityDescription,
+)
+from homeassistant.const import EntityCategory
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+
+from .coordinator import PeblarConfigEntry, PeblarUserConfigurationDataUpdateCoordinator
+from .entity import PeblarEntity
+from .helpers import peblar_exception_handler
+
+PARALLEL_UPDATES = 1
+
+
+@dataclass(frozen=True, kw_only=True)
+class PeblarButtonEntityDescription(ButtonEntityDescription):
+ """Describe a Peblar button."""
+
+ press_fn: Callable[[Peblar], Awaitable[Any]]
+
+
+DESCRIPTIONS = [
+ PeblarButtonEntityDescription(
+ key="identify",
+ device_class=ButtonDeviceClass.IDENTIFY,
+ entity_category=EntityCategory.CONFIG,
+ entity_registry_enabled_default=False,
+ press_fn=lambda x: x.identify(),
+ ),
+ PeblarButtonEntityDescription(
+ key="reboot",
+ device_class=ButtonDeviceClass.RESTART,
+ entity_category=EntityCategory.CONFIG,
+ entity_registry_enabled_default=False,
+ press_fn=lambda x: x.reboot(),
+ ),
+]
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: PeblarConfigEntry,
+ async_add_entities: AddEntitiesCallback,
+) -> None:
+ """Set up Peblar buttons based on a config entry."""
+ async_add_entities(
+ PeblarButtonEntity(
+ entry=entry,
+ coordinator=entry.runtime_data.user_configuration_coordinator,
+ description=description,
+ )
+ for description in DESCRIPTIONS
+ )
+
+
+class PeblarButtonEntity(
+ PeblarEntity[PeblarUserConfigurationDataUpdateCoordinator],
+ ButtonEntity,
+):
+ """Defines an Peblar button."""
+
+ entity_description: PeblarButtonEntityDescription
+
+ @peblar_exception_handler
+ async def async_press(self) -> None:
+ """Trigger button press on the Peblar device."""
+ await self.entity_description.press_fn(self.coordinator.peblar)
diff --git a/homeassistant/components/peblar/config_flow.py b/homeassistant/components/peblar/config_flow.py
new file mode 100644
index 00000000000..24248355f72
--- /dev/null
+++ b/homeassistant/components/peblar/config_flow.py
@@ -0,0 +1,244 @@
+"""Config flow to configure the Peblar integration."""
+
+from __future__ import annotations
+
+from collections.abc import Mapping
+from typing import Any
+
+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
+from homeassistant.helpers.selector import (
+ TextSelector,
+ TextSelectorConfig,
+ TextSelectorType,
+)
+
+from .const import DOMAIN, LOGGER
+
+
+class PeblarFlowHandler(ConfigFlow, domain=DOMAIN):
+ """Handle a Peblar config flow."""
+
+ VERSION = 1
+
+ _discovery_info: zeroconf.ZeroconfServiceInfo
+
+ async def async_step_user(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Handle a flow initiated by the user."""
+ errors = {}
+
+ if user_input is not None:
+ peblar = Peblar(
+ host=user_input[CONF_HOST],
+ session=async_create_clientsession(
+ self.hass, cookie_jar=CookieJar(unsafe=True)
+ ),
+ )
+ try:
+ await peblar.login(password=user_input[CONF_PASSWORD])
+ info = await peblar.system_information()
+ except PeblarAuthenticationError:
+ errors[CONF_PASSWORD] = "invalid_auth"
+ except PeblarConnectionError:
+ errors[CONF_HOST] = "cannot_connect"
+ except Exception: # noqa: BLE001
+ LOGGER.exception("Unexpected exception")
+ errors["base"] = "unknown"
+ else:
+ await self.async_set_unique_id(
+ info.product_serial_number, raise_on_progress=False
+ )
+ self._abort_if_unique_id_configured()
+ return self.async_create_entry(title="Peblar", data=user_input)
+ else:
+ user_input = {}
+
+ return self.async_show_form(
+ step_id="user",
+ data_schema=vol.Schema(
+ {
+ vol.Required(
+ CONF_HOST, default=user_input.get(CONF_HOST)
+ ): TextSelector(TextSelectorConfig(autocomplete="off")),
+ vol.Required(CONF_PASSWORD): TextSelector(
+ TextSelectorConfig(type=TextSelectorType.PASSWORD)
+ ),
+ }
+ ),
+ errors=errors,
+ )
+
+ async def async_step_reconfigure(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Handle reconfiguration of a Peblar device."""
+ errors = {}
+ reconfigure_entry = self._get_reconfigure_entry()
+
+ if user_input is not None:
+ peblar = Peblar(
+ host=user_input[CONF_HOST],
+ session=async_create_clientsession(
+ self.hass, cookie_jar=CookieJar(unsafe=True)
+ ),
+ )
+ try:
+ await peblar.login(password=user_input[CONF_PASSWORD])
+ info = await peblar.system_information()
+ except PeblarAuthenticationError:
+ errors[CONF_PASSWORD] = "invalid_auth"
+ except PeblarConnectionError:
+ errors[CONF_HOST] = "cannot_connect"
+ except Exception: # noqa: BLE001
+ LOGGER.exception("Unexpected exception")
+ errors["base"] = "unknown"
+ else:
+ await self.async_set_unique_id(info.product_serial_number)
+ self._abort_if_unique_id_mismatch(reason="different_device")
+ return self.async_update_reload_and_abort(
+ reconfigure_entry,
+ data_updates=user_input,
+ )
+
+ host = reconfigure_entry.data[CONF_HOST]
+ if user_input is not None:
+ host = user_input[CONF_HOST]
+
+ return self.async_show_form(
+ step_id="reconfigure",
+ data_schema=vol.Schema(
+ {
+ vol.Required(CONF_HOST, default=host): TextSelector(
+ TextSelectorConfig(autocomplete="off")
+ ),
+ vol.Required(CONF_PASSWORD): TextSelector(
+ TextSelectorConfig(type=TextSelectorType.PASSWORD)
+ ),
+ }
+ ),
+ errors=errors,
+ )
+
+ async def async_step_zeroconf(
+ self, discovery_info: zeroconf.ZeroconfServiceInfo
+ ) -> ConfigFlowResult:
+ """Handle zeroconf discovery of a Peblar device."""
+ if not (sn := discovery_info.properties.get("sn")):
+ return self.async_abort(reason="no_serial_number")
+
+ await self.async_set_unique_id(sn)
+ self._abort_if_unique_id_configured(updates={CONF_HOST: discovery_info.host})
+
+ self._discovery_info = discovery_info
+ self.context.update(
+ {
+ "title_placeholders": {
+ "name": discovery_info.name.replace("._http._tcp.local.", "")
+ },
+ "configuration_url": f"http://{discovery_info.host}",
+ },
+ )
+ return await self.async_step_zeroconf_confirm()
+
+ async def async_step_zeroconf_confirm(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Handle a flow initiated by zeroconf."""
+ errors = {}
+
+ if user_input is not None:
+ peblar = Peblar(
+ host=self._discovery_info.host,
+ session=async_create_clientsession(
+ self.hass, cookie_jar=CookieJar(unsafe=True)
+ ),
+ )
+ try:
+ await peblar.login(password=user_input[CONF_PASSWORD])
+ except PeblarAuthenticationError:
+ errors[CONF_PASSWORD] = "invalid_auth"
+ except Exception: # noqa: BLE001
+ LOGGER.exception("Unexpected exception")
+ errors["base"] = "unknown"
+ else:
+ return self.async_create_entry(
+ title="Peblar",
+ data={
+ CONF_HOST: self._discovery_info.host,
+ CONF_PASSWORD: user_input[CONF_PASSWORD],
+ },
+ )
+
+ return self.async_show_form(
+ step_id="zeroconf_confirm",
+ data_schema=vol.Schema(
+ {
+ vol.Required(CONF_PASSWORD): TextSelector(
+ TextSelectorConfig(type=TextSelectorType.PASSWORD)
+ ),
+ }
+ ),
+ description_placeholders={
+ "hostname": self._discovery_info.name.replace("._http._tcp.local.", ""),
+ "host": self._discovery_info.host,
+ },
+ errors=errors,
+ )
+
+ async def async_step_reauth(
+ self, entry_data: Mapping[str, Any]
+ ) -> ConfigFlowResult:
+ """Handle initiation of re-authentication with a Peblar device."""
+ 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 with a Peblar device."""
+ errors = {}
+
+ if user_input is not None:
+ reauth_entry = self._get_reauth_entry()
+ peblar = Peblar(
+ host=reauth_entry.data[CONF_HOST],
+ session=async_create_clientsession(
+ self.hass, cookie_jar=CookieJar(unsafe=True)
+ ),
+ )
+ try:
+ await peblar.login(password=user_input[CONF_PASSWORD])
+ except PeblarAuthenticationError:
+ errors[CONF_PASSWORD] = "invalid_auth"
+ except PeblarConnectionError:
+ errors["base"] = "cannot_connect"
+ except Exception: # noqa: BLE001
+ LOGGER.exception("Unexpected exception")
+ errors["base"] = "unknown"
+ else:
+ return self.async_update_reload_and_abort(
+ reauth_entry,
+ data={
+ CONF_HOST: reauth_entry.data[CONF_HOST],
+ CONF_PASSWORD: user_input[CONF_PASSWORD],
+ },
+ )
+
+ return self.async_show_form(
+ step_id="reauth_confirm",
+ data_schema=vol.Schema(
+ {
+ vol.Required(CONF_PASSWORD): TextSelector(
+ TextSelectorConfig(type=TextSelectorType.PASSWORD)
+ ),
+ }
+ ),
+ errors=errors,
+ )
diff --git a/homeassistant/components/peblar/const.py b/homeassistant/components/peblar/const.py
new file mode 100644
index 00000000000..d7d7c2fa5b5
--- /dev/null
+++ b/homeassistant/components/peblar/const.py
@@ -0,0 +1,42 @@
+"""Constants for the Peblar integration."""
+
+from __future__ import annotations
+
+import logging
+from typing import Final
+
+from peblar import ChargeLimiter, CPState
+
+DOMAIN: Final = "peblar"
+
+LOGGER = logging.getLogger(__package__)
+
+PEBLAR_CHARGE_LIMITER_TO_HOME_ASSISTANT = {
+ ChargeLimiter.CHARGING_CABLE: "charging_cable",
+ ChargeLimiter.CURRENT_LIMITER: "current_limiter",
+ ChargeLimiter.DYNAMIC_LOAD_BALANCING: "dynamic_load_balancing",
+ ChargeLimiter.EXTERNAL_POWER_LIMIT: "external_power_limit",
+ ChargeLimiter.GROUP_LOAD_BALANCING: "group_load_balancing",
+ ChargeLimiter.HARDWARE_LIMITATION: "hardware_limitation",
+ ChargeLimiter.HIGH_TEMPERATURE: "high_temperature",
+ ChargeLimiter.HOUSEHOLD_POWER_LIMIT: "household_power_limit",
+ ChargeLimiter.INSTALLATION_LIMIT: "installation_limit",
+ ChargeLimiter.LOCAL_MODBUS_API: "local_modbus_api",
+ ChargeLimiter.LOCAL_REST_API: "local_rest_api",
+ ChargeLimiter.LOCAL_SCHEDULED: "local_scheduled",
+ ChargeLimiter.OCPP_SMART_CHARGING: "ocpp_smart_charging",
+ ChargeLimiter.OVERCURRENT_PROTECTION: "overcurrent_protection",
+ ChargeLimiter.PHASE_IMBALANCE: "phase_imbalance",
+ ChargeLimiter.POWER_FACTOR: "power_factor",
+ ChargeLimiter.SOLAR_CHARGING: "solar_charging",
+}
+
+PEBLAR_CP_STATE_TO_HOME_ASSISTANT = {
+ CPState.CHARGING_SUSPENDED: "suspended",
+ CPState.CHARGING_VENTILATION: "charging",
+ CPState.CHARGING: "charging",
+ CPState.ERROR: "error",
+ CPState.FAULT: "fault",
+ CPState.INVALID: "invalid",
+ CPState.NO_EV_CONNECTED: "no_ev_connected",
+}
diff --git a/homeassistant/components/peblar/coordinator.py b/homeassistant/components/peblar/coordinator.py
new file mode 100644
index 00000000000..058f2aefb3b
--- /dev/null
+++ b/homeassistant/components/peblar/coordinator.py
@@ -0,0 +1,184 @@
+"""Data update coordinator for Peblar EV chargers."""
+
+from __future__ import annotations
+
+from collections.abc import Callable, Coroutine
+from dataclasses import dataclass
+from datetime import timedelta
+from typing import Any, Concatenate
+
+from peblar import (
+ Peblar,
+ PeblarApi,
+ PeblarAuthenticationError,
+ PeblarConnectionError,
+ PeblarError,
+ PeblarEVInterface,
+ PeblarMeter,
+ PeblarSystem,
+ PeblarSystemInformation,
+ PeblarUserConfiguration,
+ PeblarVersions,
+)
+
+from homeassistant.config_entries import ConfigEntry, ConfigEntryState
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import ConfigEntryAuthFailed
+from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
+
+from .const import DOMAIN, LOGGER
+
+
+@dataclass(kw_only=True)
+class PeblarRuntimeData:
+ """Class to hold runtime data."""
+
+ data_coordinator: PeblarDataUpdateCoordinator
+ system_information: PeblarSystemInformation
+ user_configuration_coordinator: PeblarUserConfigurationDataUpdateCoordinator
+ version_coordinator: PeblarVersionDataUpdateCoordinator
+
+
+type PeblarConfigEntry = ConfigEntry[PeblarRuntimeData]
+
+
+@dataclass(kw_only=True, frozen=True)
+class PeblarVersionInformation:
+ """Class to hold version information."""
+
+ current: PeblarVersions
+ available: PeblarVersions
+
+
+@dataclass(kw_only=True)
+class PeblarData:
+ """Class to hold active charging related information of Peblar.
+
+ This is data that needs to be polled and updated at a relatively high
+ frequency in order for this integration to function correctly.
+ All this data is updated at the same time by a single coordinator.
+ """
+
+ ev: PeblarEVInterface
+ meter: PeblarMeter
+ system: PeblarSystem
+
+
+def _coordinator_exception_handler[
+ _DataUpdateCoordinatorT: PeblarDataUpdateCoordinator
+ | PeblarVersionDataUpdateCoordinator
+ | PeblarUserConfigurationDataUpdateCoordinator,
+ **_P,
+](
+ func: Callable[Concatenate[_DataUpdateCoordinatorT, _P], Coroutine[Any, Any, Any]],
+) -> Callable[Concatenate[_DataUpdateCoordinatorT, _P], Coroutine[Any, Any, Any]]:
+ """Handle exceptions within the update handler of a coordinator."""
+
+ async def handler(
+ self: _DataUpdateCoordinatorT, *args: _P.args, **kwargs: _P.kwargs
+ ) -> Any:
+ try:
+ return await func(self, *args, **kwargs)
+ except PeblarAuthenticationError as error:
+ if self.config_entry and self.config_entry.state is ConfigEntryState.LOADED:
+ # This is not the first refresh, so let's reload
+ # the config entry to ensure we trigger a re-authentication
+ # flow (or recover in case of API token changes).
+ self.hass.config_entries.async_schedule_reload(
+ self.config_entry.entry_id
+ )
+ raise ConfigEntryAuthFailed(
+ translation_domain=DOMAIN,
+ translation_key="authentication_error",
+ ) from error
+ except PeblarConnectionError as error:
+ raise UpdateFailed(
+ translation_domain=DOMAIN,
+ translation_key="communication_error",
+ translation_placeholders={"error": str(error)},
+ ) from error
+ except PeblarError as error:
+ raise UpdateFailed(
+ translation_domain=DOMAIN,
+ translation_key="unknown_error",
+ translation_placeholders={"error": str(error)},
+ ) from error
+
+ return handler
+
+
+class PeblarVersionDataUpdateCoordinator(
+ DataUpdateCoordinator[PeblarVersionInformation]
+):
+ """Class to manage fetching Peblar version information."""
+
+ def __init__(
+ self, hass: HomeAssistant, entry: PeblarConfigEntry, peblar: Peblar
+ ) -> None:
+ """Initialize the coordinator."""
+ self.peblar = peblar
+ super().__init__(
+ hass,
+ LOGGER,
+ config_entry=entry,
+ name=f"Peblar {entry.title} version",
+ update_interval=timedelta(hours=2),
+ )
+
+ @_coordinator_exception_handler
+ async def _async_update_data(self) -> PeblarVersionInformation:
+ """Fetch data from the Peblar device."""
+ return PeblarVersionInformation(
+ current=await self.peblar.current_versions(),
+ available=await self.peblar.available_versions(),
+ )
+
+
+class PeblarDataUpdateCoordinator(DataUpdateCoordinator[PeblarData]):
+ """Class to manage fetching Peblar active data."""
+
+ def __init__(
+ self, hass: HomeAssistant, entry: PeblarConfigEntry, api: PeblarApi
+ ) -> None:
+ """Initialize the coordinator."""
+ self.api = api
+ super().__init__(
+ hass,
+ LOGGER,
+ config_entry=entry,
+ name=f"Peblar {entry.title} meter",
+ update_interval=timedelta(seconds=10),
+ )
+
+ @_coordinator_exception_handler
+ async def _async_update_data(self) -> PeblarData:
+ """Fetch data from the Peblar device."""
+ return PeblarData(
+ ev=await self.api.ev_interface(),
+ meter=await self.api.meter(),
+ system=await self.api.system(),
+ )
+
+
+class PeblarUserConfigurationDataUpdateCoordinator(
+ DataUpdateCoordinator[PeblarUserConfiguration]
+):
+ """Class to manage fetching Peblar user configuration data."""
+
+ def __init__(
+ self, hass: HomeAssistant, entry: PeblarConfigEntry, peblar: Peblar
+ ) -> None:
+ """Initialize the coordinator."""
+ self.peblar = peblar
+ super().__init__(
+ hass,
+ LOGGER,
+ config_entry=entry,
+ name=f"Peblar {entry.title} user configuration",
+ update_interval=timedelta(minutes=5),
+ )
+
+ @_coordinator_exception_handler
+ async def _async_update_data(self) -> PeblarUserConfiguration:
+ """Fetch data from the Peblar device."""
+ return await self.peblar.user_configuration()
diff --git a/homeassistant/components/peblar/diagnostics.py b/homeassistant/components/peblar/diagnostics.py
new file mode 100644
index 00000000000..a8c7423f79a
--- /dev/null
+++ b/homeassistant/components/peblar/diagnostics.py
@@ -0,0 +1,26 @@
+"""Diagnostics support for Peblar."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from homeassistant.core import HomeAssistant
+
+from .coordinator import PeblarConfigEntry
+
+
+async def async_get_config_entry_diagnostics(
+ hass: HomeAssistant, entry: PeblarConfigEntry
+) -> dict[str, Any]:
+ """Return diagnostics for a config entry."""
+ return {
+ "system_information": entry.runtime_data.system_information.to_dict(),
+ "user_configuration": entry.runtime_data.user_configuration_coordinator.data.to_dict(),
+ "ev": entry.runtime_data.data_coordinator.data.ev.to_dict(),
+ "meter": entry.runtime_data.data_coordinator.data.meter.to_dict(),
+ "system": entry.runtime_data.data_coordinator.data.system.to_dict(),
+ "versions": {
+ "available": entry.runtime_data.version_coordinator.data.available.to_dict(),
+ "current": entry.runtime_data.version_coordinator.data.current.to_dict(),
+ },
+ }
diff --git a/homeassistant/components/peblar/entity.py b/homeassistant/components/peblar/entity.py
new file mode 100644
index 00000000000..ecfd3e8232b
--- /dev/null
+++ b/homeassistant/components/peblar/entity.py
@@ -0,0 +1,55 @@
+"""Base entity for the Peblar integration."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from homeassistant.const import CONF_HOST
+from homeassistant.helpers import device_registry as dr
+from homeassistant.helpers.device_registry import DeviceInfo
+from homeassistant.helpers.entity import EntityDescription
+from homeassistant.helpers.update_coordinator import (
+ CoordinatorEntity,
+ DataUpdateCoordinator,
+)
+
+from .const import DOMAIN
+from .coordinator import PeblarConfigEntry
+
+
+class PeblarEntity[_DataUpdateCoordinatorT: DataUpdateCoordinator[Any]](
+ CoordinatorEntity[_DataUpdateCoordinatorT]
+):
+ """Defines a Peblar entity."""
+
+ _attr_has_entity_name = True
+
+ def __init__(
+ self,
+ *,
+ entry: PeblarConfigEntry,
+ coordinator: _DataUpdateCoordinatorT,
+ description: EntityDescription,
+ ) -> None:
+ """Initialize the Peblar entity."""
+ super().__init__(coordinator=coordinator)
+ self.entity_description = description
+ self._attr_unique_id = f"{entry.unique_id}_{description.key}"
+
+ system_information = entry.runtime_data.system_information
+ self._attr_device_info = DeviceInfo(
+ configuration_url=f"http://{entry.data[CONF_HOST]}",
+ connections={
+ (dr.CONNECTION_NETWORK_MAC, system_information.ethernet_mac_address),
+ (dr.CONNECTION_NETWORK_MAC, system_information.wlan_mac_address),
+ },
+ identifiers={
+ (DOMAIN, entry.runtime_data.system_information.product_serial_number)
+ },
+ manufacturer=system_information.product_vendor_name,
+ model=system_information.product_model_name,
+ model_id=system_information.product_number,
+ name="Peblar EV Charger",
+ serial_number=system_information.product_serial_number,
+ sw_version=entry.runtime_data.version_coordinator.data.current.firmware,
+ )
diff --git a/homeassistant/components/peblar/helpers.py b/homeassistant/components/peblar/helpers.py
new file mode 100644
index 00000000000..cc1eb228803
--- /dev/null
+++ b/homeassistant/components/peblar/helpers.py
@@ -0,0 +1,55 @@
+"""Helpers for Peblar."""
+
+from __future__ import annotations
+
+from collections.abc import Callable, Coroutine
+from typing import Any, Concatenate
+
+from peblar import PeblarAuthenticationError, PeblarConnectionError, PeblarError
+
+from homeassistant.exceptions import HomeAssistantError
+
+from .const import DOMAIN
+from .entity import PeblarEntity
+
+
+def peblar_exception_handler[_PeblarEntityT: PeblarEntity, **_P](
+ func: Callable[Concatenate[_PeblarEntityT, _P], Coroutine[Any, Any, Any]],
+) -> Callable[Concatenate[_PeblarEntityT, _P], Coroutine[Any, Any, None]]:
+ """Decorate Peblar calls to handle exceptions.
+
+ A decorator that wraps the passed in function, catches Peblar errors.
+ """
+
+ async def handler(
+ self: _PeblarEntityT, *args: _P.args, **kwargs: _P.kwargs
+ ) -> None:
+ try:
+ await func(self, *args, **kwargs)
+ self.coordinator.async_update_listeners()
+
+ except PeblarAuthenticationError as error:
+ # Reload the config entry to trigger reauth flow
+ self.hass.config_entries.async_schedule_reload(
+ self.coordinator.config_entry.entry_id
+ )
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="authentication_error",
+ ) from error
+
+ except PeblarConnectionError as error:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="communication_error",
+ translation_placeholders={"error": str(error)},
+ ) from error
+
+ except PeblarError 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/peblar/icons.json b/homeassistant/components/peblar/icons.json
new file mode 100644
index 00000000000..6244945077b
--- /dev/null
+++ b/homeassistant/components/peblar/icons.json
@@ -0,0 +1,49 @@
+{
+ "entity": {
+ "binary_sensor": {
+ "active_error_codes": {
+ "default": "mdi:alert"
+ },
+ "active_warning_codes": {
+ "default": "mdi:alert"
+ }
+ },
+ "number": {
+ "charge_current_limit": {
+ "default": "mdi:speedometer"
+ }
+ },
+ "select": {
+ "smart_charging": {
+ "default": "mdi:lightning-bolt",
+ "state": {
+ "fast_solar": "mdi:solar-power",
+ "pure_solar": "mdi:solar-power-variant",
+ "scheduled": "mdi:calendar-clock",
+ "smart_solar": "mdi:solar-power"
+ }
+ }
+ },
+ "sensor": {
+ "cp_state": {
+ "default": "mdi:ev-plug-type2"
+ },
+ "charge_current_limit_source": {
+ "default": "mdi:arrow-collapse-up"
+ },
+ "uptime": {
+ "default": "mdi:timer"
+ }
+ },
+ "switch": {
+ "force_single_phase": {
+ "default": "mdi:power-cycle"
+ }
+ },
+ "update": {
+ "customization": {
+ "default": "mdi:palette"
+ }
+ }
+ }
+}
diff --git a/homeassistant/components/peblar/manifest.json b/homeassistant/components/peblar/manifest.json
new file mode 100644
index 00000000000..859682d3f1d
--- /dev/null
+++ b/homeassistant/components/peblar/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "peblar",
+ "name": "Peblar",
+ "codeowners": ["@frenck"],
+ "config_flow": true,
+ "documentation": "https://www.home-assistant.io/integrations/peblar",
+ "integration_type": "device",
+ "iot_class": "local_polling",
+ "quality_scale": "platinum",
+ "requirements": ["peblar==0.3.3"],
+ "zeroconf": [{ "type": "_http._tcp.local.", "name": "pblr-*" }]
+}
diff --git a/homeassistant/components/peblar/number.py b/homeassistant/components/peblar/number.py
new file mode 100644
index 00000000000..1a7cec43295
--- /dev/null
+++ b/homeassistant/components/peblar/number.py
@@ -0,0 +1,102 @@
+"""Support for Peblar numbers."""
+
+from __future__ import annotations
+
+from collections.abc import Awaitable, Callable
+from dataclasses import dataclass
+from typing import Any
+
+from peblar import PeblarApi
+
+from homeassistant.components.number import (
+ NumberDeviceClass,
+ NumberEntity,
+ NumberEntityDescription,
+)
+from homeassistant.const import EntityCategory, UnitOfElectricCurrent
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+
+from .coordinator import (
+ PeblarConfigEntry,
+ PeblarData,
+ PeblarDataUpdateCoordinator,
+ PeblarRuntimeData,
+)
+from .entity import PeblarEntity
+from .helpers import peblar_exception_handler
+
+PARALLEL_UPDATES = 1
+
+
+@dataclass(frozen=True, kw_only=True)
+class PeblarNumberEntityDescription(NumberEntityDescription):
+ """Describe a Peblar number."""
+
+ native_max_value_fn: Callable[[PeblarRuntimeData], int]
+ set_value_fn: Callable[[PeblarApi, float], Awaitable[Any]]
+ value_fn: Callable[[PeblarData], int | None]
+
+
+DESCRIPTIONS = [
+ PeblarNumberEntityDescription(
+ key="charge_current_limit",
+ translation_key="charge_current_limit",
+ device_class=NumberDeviceClass.CURRENT,
+ entity_category=EntityCategory.CONFIG,
+ native_step=1,
+ native_min_value=6,
+ native_max_value_fn=lambda x: x.user_configuration_coordinator.data.user_defined_charge_limit_current,
+ native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
+ set_value_fn=lambda x, v: x.ev_interface(charge_current_limit=int(v) * 1000),
+ value_fn=lambda x: round(x.ev.charge_current_limit / 1000),
+ ),
+]
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: PeblarConfigEntry,
+ async_add_entities: AddEntitiesCallback,
+) -> None:
+ """Set up Peblar number based on a config entry."""
+ async_add_entities(
+ PeblarNumberEntity(
+ entry=entry,
+ coordinator=entry.runtime_data.data_coordinator,
+ description=description,
+ )
+ for description in DESCRIPTIONS
+ )
+
+
+class PeblarNumberEntity(
+ PeblarEntity[PeblarDataUpdateCoordinator],
+ NumberEntity,
+):
+ """Defines a Peblar number."""
+
+ entity_description: PeblarNumberEntityDescription
+
+ def __init__(
+ self,
+ entry: PeblarConfigEntry,
+ coordinator: PeblarDataUpdateCoordinator,
+ description: PeblarNumberEntityDescription,
+ ) -> None:
+ """Initialize the Peblar entity."""
+ super().__init__(entry=entry, coordinator=coordinator, description=description)
+ self._attr_native_max_value = description.native_max_value_fn(
+ entry.runtime_data
+ )
+
+ @property
+ def native_value(self) -> int | None:
+ """Return the number value."""
+ return self.entity_description.value_fn(self.coordinator.data)
+
+ @peblar_exception_handler
+ async def async_set_native_value(self, value: float) -> None:
+ """Change to new number value."""
+ await self.entity_description.set_value_fn(self.coordinator.api, value)
+ await self.coordinator.async_request_refresh()
diff --git a/homeassistant/components/peblar/quality_scale.yaml b/homeassistant/components/peblar/quality_scale.yaml
new file mode 100644
index 00000000000..91f9bb7af55
--- /dev/null
+++ b/homeassistant/components/peblar/quality_scale.yaml
@@ -0,0 +1,82 @@
+rules:
+ # Bronze
+ action-setup:
+ status: exempt
+ comment: Integration does not register custom 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 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: 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: |
+ This integration does not have any configuration parameters.
+ 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: 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: |
+ This integration connects to a single device.
+ entity-category: done
+ entity-device-class: done
+ entity-disabled-by-default: done
+ entity-translations: done
+ exception-translations:
+ status: exempt
+ comment: |
+ The coordinator needs translation when the update failed.
+ icon-translations: done
+ reconfiguration-flow: done
+ repair-issues:
+ status: exempt
+ comment: |
+ This integration does not raise any repairable issues.
+ stale-devices:
+ status: exempt
+ comment: |
+ This integration connects to a single device.
+
+ # Platinum
+ async-dependency: done
+ inject-websession: done
+ strict-typing: done
diff --git a/homeassistant/components/peblar/select.py b/homeassistant/components/peblar/select.py
new file mode 100644
index 00000000000..a2a0997a797
--- /dev/null
+++ b/homeassistant/components/peblar/select.py
@@ -0,0 +1,82 @@
+"""Support for Peblar selects."""
+
+from __future__ import annotations
+
+from collections.abc import Awaitable, Callable
+from dataclasses import dataclass
+from typing import Any
+
+from peblar import Peblar, PeblarUserConfiguration, SmartChargingMode
+
+from homeassistant.components.select import SelectEntity, SelectEntityDescription
+from homeassistant.const import EntityCategory
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+
+from .coordinator import PeblarConfigEntry, PeblarUserConfigurationDataUpdateCoordinator
+from .entity import PeblarEntity
+from .helpers import peblar_exception_handler
+
+PARALLEL_UPDATES = 1
+
+
+@dataclass(frozen=True, kw_only=True)
+class PeblarSelectEntityDescription(SelectEntityDescription):
+ """Class describing Peblar select entities."""
+
+ current_fn: Callable[[PeblarUserConfiguration], str | None]
+ select_fn: Callable[[Peblar, str], Awaitable[Any]]
+
+
+DESCRIPTIONS = [
+ PeblarSelectEntityDescription(
+ key="smart_charging",
+ translation_key="smart_charging",
+ entity_category=EntityCategory.CONFIG,
+ options=[
+ "default",
+ "fast_solar",
+ "pure_solar",
+ "scheduled",
+ "smart_solar",
+ ],
+ current_fn=lambda x: x.smart_charging.value if x.smart_charging else None,
+ select_fn=lambda x, mode: x.smart_charging(SmartChargingMode(mode)),
+ ),
+]
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: PeblarConfigEntry,
+ async_add_entities: AddEntitiesCallback,
+) -> None:
+ """Set up Peblar select based on a config entry."""
+ async_add_entities(
+ PeblarSelectEntity(
+ entry=entry,
+ coordinator=entry.runtime_data.user_configuration_coordinator,
+ description=description,
+ )
+ for description in DESCRIPTIONS
+ )
+
+
+class PeblarSelectEntity(
+ PeblarEntity[PeblarUserConfigurationDataUpdateCoordinator],
+ SelectEntity,
+):
+ """Defines a Peblar select entity."""
+
+ entity_description: PeblarSelectEntityDescription
+
+ @property
+ def current_option(self) -> str | None:
+ """Return the selected entity option to represent the entity state."""
+ return self.entity_description.current_fn(self.coordinator.data)
+
+ @peblar_exception_handler
+ async def async_select_option(self, option: str) -> None:
+ """Change the selected option."""
+ await self.entity_description.select_fn(self.coordinator.peblar, option)
+ await self.coordinator.async_request_refresh()
diff --git a/homeassistant/components/peblar/sensor.py b/homeassistant/components/peblar/sensor.py
new file mode 100644
index 00000000000..e655253d75c
--- /dev/null
+++ b/homeassistant/components/peblar/sensor.py
@@ -0,0 +1,256 @@
+"""Support for Peblar sensors."""
+
+from __future__ import annotations
+
+from collections.abc import Callable
+from dataclasses import dataclass
+from datetime import datetime, timedelta
+
+from peblar import PeblarUserConfiguration
+
+from homeassistant.components.sensor import (
+ SensorDeviceClass,
+ SensorEntity,
+ SensorEntityDescription,
+ SensorStateClass,
+)
+from homeassistant.const import (
+ EntityCategory,
+ UnitOfElectricCurrent,
+ UnitOfElectricPotential,
+ UnitOfEnergy,
+ UnitOfPower,
+)
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.util.dt import utcnow
+
+from .const import (
+ PEBLAR_CHARGE_LIMITER_TO_HOME_ASSISTANT,
+ PEBLAR_CP_STATE_TO_HOME_ASSISTANT,
+)
+from .coordinator import PeblarConfigEntry, PeblarData, PeblarDataUpdateCoordinator
+from .entity import PeblarEntity
+
+PARALLEL_UPDATES = 0
+
+
+@dataclass(frozen=True, kw_only=True)
+class PeblarSensorDescription(SensorEntityDescription):
+ """Describe a Peblar sensor."""
+
+ has_fn: Callable[[PeblarUserConfiguration], bool] = lambda _: True
+ value_fn: Callable[[PeblarData], datetime | int | str | None]
+
+
+DESCRIPTIONS: tuple[PeblarSensorDescription, ...] = (
+ PeblarSensorDescription(
+ key="cp_state",
+ translation_key="cp_state",
+ device_class=SensorDeviceClass.ENUM,
+ options=list(PEBLAR_CP_STATE_TO_HOME_ASSISTANT.values()),
+ value_fn=lambda x: PEBLAR_CP_STATE_TO_HOME_ASSISTANT[x.ev.cp_state],
+ ),
+ PeblarSensorDescription(
+ key="charge_current_limit_source",
+ translation_key="charge_current_limit_source",
+ device_class=SensorDeviceClass.ENUM,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ options=list(PEBLAR_CHARGE_LIMITER_TO_HOME_ASSISTANT.values()),
+ value_fn=lambda x: PEBLAR_CHARGE_LIMITER_TO_HOME_ASSISTANT[
+ x.ev.charge_current_limit_source
+ ],
+ ),
+ PeblarSensorDescription(
+ key="current_total",
+ device_class=SensorDeviceClass.CURRENT,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ native_unit_of_measurement=UnitOfElectricCurrent.MILLIAMPERE,
+ state_class=SensorStateClass.MEASUREMENT,
+ suggested_display_precision=1,
+ suggested_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
+ value_fn=lambda x: x.meter.current_total,
+ ),
+ PeblarSensorDescription(
+ key="current_phase_1",
+ translation_key="current_phase_1",
+ device_class=SensorDeviceClass.CURRENT,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ has_fn=lambda x: x.connected_phases >= 2,
+ native_unit_of_measurement=UnitOfElectricCurrent.MILLIAMPERE,
+ state_class=SensorStateClass.MEASUREMENT,
+ suggested_display_precision=1,
+ suggested_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
+ value_fn=lambda x: x.meter.current_phase_1,
+ ),
+ PeblarSensorDescription(
+ key="current_phase_2",
+ translation_key="current_phase_2",
+ device_class=SensorDeviceClass.CURRENT,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ has_fn=lambda x: x.connected_phases >= 2,
+ native_unit_of_measurement=UnitOfElectricCurrent.MILLIAMPERE,
+ state_class=SensorStateClass.MEASUREMENT,
+ suggested_display_precision=1,
+ suggested_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
+ value_fn=lambda x: x.meter.current_phase_2,
+ ),
+ PeblarSensorDescription(
+ key="current_phase_3",
+ translation_key="current_phase_3",
+ device_class=SensorDeviceClass.CURRENT,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ has_fn=lambda x: x.connected_phases == 3,
+ native_unit_of_measurement=UnitOfElectricCurrent.MILLIAMPERE,
+ state_class=SensorStateClass.MEASUREMENT,
+ suggested_display_precision=1,
+ suggested_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
+ value_fn=lambda x: x.meter.current_phase_3,
+ ),
+ PeblarSensorDescription(
+ key="energy_session",
+ translation_key="energy_session",
+ device_class=SensorDeviceClass.ENERGY,
+ native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
+ state_class=SensorStateClass.TOTAL_INCREASING,
+ suggested_display_precision=2,
+ suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
+ value_fn=lambda x: x.meter.energy_session,
+ ),
+ PeblarSensorDescription(
+ key="energy_total",
+ translation_key="energy_total",
+ device_class=SensorDeviceClass.ENERGY,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
+ state_class=SensorStateClass.TOTAL_INCREASING,
+ suggested_display_precision=2,
+ suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
+ value_fn=lambda x: x.meter.energy_total,
+ ),
+ PeblarSensorDescription(
+ key="power_total",
+ device_class=SensorDeviceClass.POWER,
+ native_unit_of_measurement=UnitOfPower.WATT,
+ state_class=SensorStateClass.MEASUREMENT,
+ value_fn=lambda x: x.meter.power_total,
+ ),
+ PeblarSensorDescription(
+ key="power_phase_1",
+ translation_key="power_phase_1",
+ device_class=SensorDeviceClass.POWER,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ has_fn=lambda x: x.connected_phases >= 2,
+ native_unit_of_measurement=UnitOfPower.WATT,
+ state_class=SensorStateClass.MEASUREMENT,
+ value_fn=lambda x: x.meter.power_phase_1,
+ ),
+ PeblarSensorDescription(
+ key="power_phase_2",
+ translation_key="power_phase_2",
+ device_class=SensorDeviceClass.POWER,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ has_fn=lambda x: x.connected_phases >= 2,
+ native_unit_of_measurement=UnitOfPower.WATT,
+ state_class=SensorStateClass.MEASUREMENT,
+ value_fn=lambda x: x.meter.power_phase_2,
+ ),
+ PeblarSensorDescription(
+ key="power_phase_3",
+ translation_key="power_phase_3",
+ device_class=SensorDeviceClass.POWER,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ has_fn=lambda x: x.connected_phases == 3,
+ native_unit_of_measurement=UnitOfPower.WATT,
+ state_class=SensorStateClass.MEASUREMENT,
+ value_fn=lambda x: x.meter.power_phase_3,
+ ),
+ PeblarSensorDescription(
+ key="voltage",
+ device_class=SensorDeviceClass.VOLTAGE,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ has_fn=lambda x: x.connected_phases == 1,
+ native_unit_of_measurement=UnitOfElectricPotential.VOLT,
+ state_class=SensorStateClass.MEASUREMENT,
+ value_fn=lambda x: x.meter.voltage_phase_1,
+ ),
+ PeblarSensorDescription(
+ key="voltage_phase_1",
+ translation_key="voltage_phase_1",
+ device_class=SensorDeviceClass.VOLTAGE,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ has_fn=lambda x: x.connected_phases >= 2,
+ native_unit_of_measurement=UnitOfElectricPotential.VOLT,
+ state_class=SensorStateClass.MEASUREMENT,
+ value_fn=lambda x: x.meter.voltage_phase_1,
+ ),
+ PeblarSensorDescription(
+ key="voltage_phase_2",
+ translation_key="voltage_phase_2",
+ device_class=SensorDeviceClass.VOLTAGE,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ has_fn=lambda x: x.connected_phases >= 2,
+ native_unit_of_measurement=UnitOfElectricPotential.VOLT,
+ state_class=SensorStateClass.MEASUREMENT,
+ value_fn=lambda x: x.meter.voltage_phase_2,
+ ),
+ PeblarSensorDescription(
+ key="voltage_phase_3",
+ translation_key="voltage_phase_3",
+ device_class=SensorDeviceClass.VOLTAGE,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ has_fn=lambda x: x.connected_phases == 3,
+ native_unit_of_measurement=UnitOfElectricPotential.VOLT,
+ state_class=SensorStateClass.MEASUREMENT,
+ value_fn=lambda x: x.meter.voltage_phase_3,
+ ),
+ PeblarSensorDescription(
+ key="uptime",
+ translation_key="uptime",
+ device_class=SensorDeviceClass.TIMESTAMP,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ value_fn=lambda x: (
+ utcnow().replace(microsecond=0) - timedelta(seconds=x.system.uptime)
+ ),
+ ),
+)
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: PeblarConfigEntry,
+ async_add_entities: AddEntitiesCallback,
+) -> None:
+ """Set up Peblar sensors based on a config entry."""
+ async_add_entities(
+ PeblarSensorEntity(
+ entry=entry,
+ coordinator=entry.runtime_data.data_coordinator,
+ description=description,
+ )
+ for description in DESCRIPTIONS
+ if description.has_fn(entry.runtime_data.user_configuration_coordinator.data)
+ )
+
+
+class PeblarSensorEntity(PeblarEntity[PeblarDataUpdateCoordinator], SensorEntity):
+ """Defines a Peblar sensor."""
+
+ entity_description: PeblarSensorDescription
+
+ @property
+ def native_value(self) -> datetime | int | str | None:
+ """Return the state of the sensor."""
+ return self.entity_description.value_fn(self.coordinator.data)
diff --git a/homeassistant/components/peblar/strings.json b/homeassistant/components/peblar/strings.json
new file mode 100644
index 00000000000..fffa2b08d85
--- /dev/null
+++ b/homeassistant/components/peblar/strings.json
@@ -0,0 +1,176 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
+ "different_device": "The information entered is from a different Peblar EV charger.",
+ "no_serial_number": "The discovered Peblar device did not provide a serial number.",
+ "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%]"
+ },
+ "step": {
+ "reauth_confirm": {
+ "data": {
+ "password": "[%key:common::config_flow::data::password%]"
+ },
+ "data_description": {
+ "password": "[%key:component::peblar::config::step::user::data_description::password%]"
+ },
+ "description": "Reauthenticate with your Peblar EV charger.\n\nTo do so, you will need to enter your new password you use to log in to the Peblar EV charger's web interface."
+ },
+ "reconfigure": {
+ "data": {
+ "host": "[%key:common::config_flow::data::host%]",
+ "password": "[%key:common::config_flow::data::password%]"
+ },
+ "data_description": {
+ "host": "[%key:component::peblar::config::step::user::data_description::host%]",
+ "password": "[%key:component::peblar::config::step::user::data_description::password%]"
+ },
+ "description": "Reconfigure your Peblar EV charger.\n\nThis allows you to change the IP address of your Peblar EV charger and the password you use to log in to its web interface."
+ },
+ "user": {
+ "data": {
+ "host": "[%key:common::config_flow::data::host%]",
+ "password": "[%key:common::config_flow::data::password%]"
+ },
+ "data_description": {
+ "host": "The hostname or IP address of your Peblar EV charger on your home network.",
+ "password": "The same password as you use to log in to the Peblar EV charger's local web interface."
+ },
+ "description": "Set up your Peblar EV charger to integrate with Home Assistant.\n\nTo do so, you will need to get the IP address of your Peblar EV charger and the password you use to log in to its web interface.\n\nHome Assistant will automatically configure your Peblar EV charger for use with Home Assistant."
+ },
+ "zeroconf_confirm": {
+ "data": {
+ "password": "[%key:common::config_flow::data::password%]"
+ },
+ "data_description": {
+ "password": "[%key:component::peblar::config::step::user::data_description::password%]"
+ },
+ "description": "Set up your Peblar EV charger {hostname}, on IP address {host}, to integrate with Home Assistant\n\nTo do so, you will need the password you use to log in to the Peblar EV charger's web interface.\n\nHome Assistant will automatically configure your Peblar EV charger for use with Home Assistant."
+ }
+ }
+ },
+ "entity": {
+ "binary_sensor": {
+ "active_error_codes": {
+ "name": "Active errors"
+ },
+ "active_warning_codes": {
+ "name": "Active warnings"
+ }
+ },
+ "number": {
+ "charge_current_limit": {
+ "name": "Charge limit"
+ }
+ },
+ "select": {
+ "smart_charging": {
+ "name": "Smart charging",
+ "state": {
+ "default": "Default",
+ "fast_solar": "Fast solar",
+ "pure_solar": "Pure solar",
+ "scheduled": "Scheduled",
+ "smart_solar": "Smart solar"
+ }
+ }
+ },
+ "sensor": {
+ "charge_current_limit_source": {
+ "name": "Limit source",
+ "state": {
+ "charging_cable": "Charging cable",
+ "current_limiter": "Current limiter",
+ "dynamic_load_balancing": "Dynamic load balancing",
+ "external_power_limit": "External power limit",
+ "group_load_balancing": "Group load balancing",
+ "hardware_limitation": "Hardware limitation",
+ "high_temperature": "High temperature",
+ "household_power_limit": "Household power limit",
+ "installation_limit": "Installation limit",
+ "local_modbus_api": "Modbus API",
+ "local_rest_api": "REST API",
+ "ocpp_smart_charging": "OCPP smart charging",
+ "overcurrent_protection": "Overcurrent protection",
+ "phase_imbalance": "Phase imbalance",
+ "power_factor": "Power factor",
+ "solar_charging": "Solar charging"
+ }
+ },
+ "cp_state": {
+ "name": "State",
+ "state": {
+ "charging": "Charging",
+ "error": "Error",
+ "fault": "Fault",
+ "invalid": "Invalid",
+ "no_ev_connected": "No EV connected",
+ "suspended": "Suspended"
+ }
+ },
+ "current_phase_1": {
+ "name": "Current phase 1"
+ },
+ "current_phase_2": {
+ "name": "Current phase 2"
+ },
+ "current_phase_3": {
+ "name": "Current phase 3"
+ },
+ "energy_session": {
+ "name": "Session energy"
+ },
+ "energy_total": {
+ "name": "Lifetime energy"
+ },
+ "power_phase_1": {
+ "name": "Power phase 1"
+ },
+ "power_phase_2": {
+ "name": "Power phase 2"
+ },
+ "power_phase_3": {
+ "name": "Power phase 3"
+ },
+ "uptime": {
+ "name": "Uptime"
+ },
+ "voltage_phase_1": {
+ "name": "Voltage phase 1"
+ },
+ "voltage_phase_2": {
+ "name": "Voltage phase 2"
+ },
+ "voltage_phase_3": {
+ "name": "Voltage phase 3"
+ }
+ },
+ "switch": {
+ "force_single_phase": {
+ "name": "Force single phase"
+ }
+ },
+ "update": {
+ "customization": {
+ "name": "Customization"
+ }
+ }
+ },
+ "exceptions": {
+ "authentication_error": {
+ "message": "An authentication failure occurred while communicating with the Peblar EV charger."
+ },
+ "communication_error": {
+ "message": "An error occurred while communicating with the Peblar EV charger: {error}"
+ },
+ "unknown_error": {
+ "message": "An unknown error occurred while communicating with the Peblar EV charger: {error}"
+ }
+ }
+}
diff --git a/homeassistant/components/peblar/switch.py b/homeassistant/components/peblar/switch.py
new file mode 100644
index 00000000000..e56c2fcdaec
--- /dev/null
+++ b/homeassistant/components/peblar/switch.py
@@ -0,0 +1,92 @@
+"""Support for Peblar selects."""
+
+from __future__ import annotations
+
+from collections.abc import Awaitable, Callable
+from dataclasses import dataclass
+from typing import Any
+
+from peblar import PeblarApi
+
+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 .coordinator import (
+ PeblarConfigEntry,
+ PeblarData,
+ PeblarDataUpdateCoordinator,
+ PeblarRuntimeData,
+)
+from .entity import PeblarEntity
+from .helpers import peblar_exception_handler
+
+PARALLEL_UPDATES = 1
+
+
+@dataclass(frozen=True, kw_only=True)
+class PeblarSwitchEntityDescription(SwitchEntityDescription):
+ """Class describing Peblar switch entities."""
+
+ has_fn: Callable[[PeblarRuntimeData], bool] = lambda x: True
+ is_on_fn: Callable[[PeblarData], bool]
+ set_fn: Callable[[PeblarApi, bool], Awaitable[Any]]
+
+
+DESCRIPTIONS = [
+ PeblarSwitchEntityDescription(
+ key="force_single_phase",
+ translation_key="force_single_phase",
+ entity_category=EntityCategory.CONFIG,
+ has_fn=lambda x: (
+ x.data_coordinator.data.system.force_single_phase_allowed
+ and x.user_configuration_coordinator.data.connected_phases > 1
+ ),
+ is_on_fn=lambda x: x.ev.force_single_phase,
+ set_fn=lambda x, on: x.ev_interface(force_single_phase=on),
+ ),
+]
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: PeblarConfigEntry,
+ async_add_entities: AddEntitiesCallback,
+) -> None:
+ """Set up Peblar switch based on a config entry."""
+ async_add_entities(
+ PeblarSwitchEntity(
+ entry=entry,
+ coordinator=entry.runtime_data.data_coordinator,
+ description=description,
+ )
+ for description in DESCRIPTIONS
+ if description.has_fn(entry.runtime_data)
+ )
+
+
+class PeblarSwitchEntity(
+ PeblarEntity[PeblarDataUpdateCoordinator],
+ SwitchEntity,
+):
+ """Defines a Peblar switch entity."""
+
+ entity_description: PeblarSwitchEntityDescription
+
+ @property
+ def is_on(self) -> bool:
+ """Return state of the switch."""
+ return self.entity_description.is_on_fn(self.coordinator.data)
+
+ @peblar_exception_handler
+ async def async_turn_on(self, **kwargs: Any) -> None:
+ """Turn the entity on."""
+ await self.entity_description.set_fn(self.coordinator.api, True)
+ await self.coordinator.async_request_refresh()
+
+ @peblar_exception_handler
+ async def async_turn_off(self, **kwargs: Any) -> None:
+ """Turn the entity off."""
+ await self.entity_description.set_fn(self.coordinator.api, False)
+ await self.coordinator.async_request_refresh()
diff --git a/homeassistant/components/peblar/update.py b/homeassistant/components/peblar/update.py
new file mode 100644
index 00000000000..9e132da63bc
--- /dev/null
+++ b/homeassistant/components/peblar/update.py
@@ -0,0 +1,86 @@
+"""Support for Peblar updates."""
+
+from __future__ import annotations
+
+from collections.abc import Callable
+from dataclasses import dataclass
+
+from homeassistant.components.update import (
+ UpdateDeviceClass,
+ UpdateEntity,
+ UpdateEntityDescription,
+)
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+
+from .coordinator import (
+ PeblarConfigEntry,
+ PeblarVersionDataUpdateCoordinator,
+ PeblarVersionInformation,
+)
+from .entity import PeblarEntity
+
+PARALLEL_UPDATES = 1
+
+
+@dataclass(frozen=True, kw_only=True)
+class PeblarUpdateEntityDescription(UpdateEntityDescription):
+ """Describe an Peblar update entity."""
+
+ available_fn: Callable[[PeblarVersionInformation], str | None]
+ has_fn: Callable[[PeblarVersionInformation], bool] = lambda _: True
+ installed_fn: Callable[[PeblarVersionInformation], str | None]
+
+
+DESCRIPTIONS: tuple[PeblarUpdateEntityDescription, ...] = (
+ PeblarUpdateEntityDescription(
+ key="firmware",
+ device_class=UpdateDeviceClass.FIRMWARE,
+ installed_fn=lambda x: x.current.firmware,
+ has_fn=lambda x: x.current.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,
+ installed_fn=lambda x: x.current.customization,
+ ),
+)
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: PeblarConfigEntry,
+ async_add_entities: AddEntitiesCallback,
+) -> None:
+ """Set up Peblar update based on a config entry."""
+ async_add_entities(
+ PeblarUpdateEntity(
+ entry=entry,
+ coordinator=entry.runtime_data.version_coordinator,
+ description=description,
+ )
+ for description in DESCRIPTIONS
+ if description.has_fn(entry.runtime_data.version_coordinator.data)
+ )
+
+
+class PeblarUpdateEntity(
+ PeblarEntity[PeblarVersionDataUpdateCoordinator],
+ UpdateEntity,
+):
+ """Defines a Peblar update entity."""
+
+ entity_description: PeblarUpdateEntityDescription
+
+ @property
+ def installed_version(self) -> str | None:
+ """Version currently installed and in use."""
+ return self.entity_description.installed_fn(self.coordinator.data)
+
+ @property
+ def latest_version(self) -> str | None:
+ """Latest version available for install."""
+ return self.entity_description.available_fn(self.coordinator.data)
diff --git a/homeassistant/components/pegel_online/__init__.py b/homeassistant/components/pegel_online/__init__.py
index 2c465342493..30e5f4d2a38 100644
--- a/homeassistant/components/pegel_online/__init__.py
+++ b/homeassistant/components/pegel_online/__init__.py
@@ -5,10 +5,12 @@ from __future__ import annotations
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
@@ -28,7 +30,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: PegelOnlineConfigEntry)
_LOGGER.debug("Setting up station with uuid %s", station_uuid)
api = PegelOnline(async_get_clientsession(hass))
- station = await api.async_get_station_details(station_uuid)
+ try:
+ station = await api.async_get_station_details(station_uuid)
+ except CONNECT_ERRORS as err:
+ raise ConfigEntryNotReady("Failed to connect") from err
coordinator = PegelOnlineDataUpdateCoordinator(hass, entry.title, api, station)
diff --git a/homeassistant/components/pegel_online/coordinator.py b/homeassistant/components/pegel_online/coordinator.py
index 1802af8e05c..c8233673fde 100644
--- a/homeassistant/components/pegel_online/coordinator.py
+++ b/homeassistant/components/pegel_online/coordinator.py
@@ -7,7 +7,7 @@ from aiopegelonline import CONNECT_ERRORS, PegelOnline, Station, StationMeasurem
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
-from .const import MIN_TIME_BETWEEN_UPDATES
+from .const import DOMAIN, MIN_TIME_BETWEEN_UPDATES
_LOGGER = logging.getLogger(__name__)
@@ -33,4 +33,8 @@ class PegelOnlineDataUpdateCoordinator(DataUpdateCoordinator[StationMeasurements
try:
return await self.api.async_get_station_measurements(self.station.uuid)
except CONNECT_ERRORS as err:
- raise UpdateFailed(f"Failed to communicate with API: {err}") from err
+ raise UpdateFailed(
+ translation_domain=DOMAIN,
+ translation_key="communication_error",
+ translation_placeholders={"error": str(err)},
+ ) from err
diff --git a/homeassistant/components/pegel_online/manifest.json b/homeassistant/components/pegel_online/manifest.json
index d51278d0c1b..0a0f31532b1 100644
--- a/homeassistant/components/pegel_online/manifest.json
+++ b/homeassistant/components/pegel_online/manifest.json
@@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["aiopegelonline"],
- "requirements": ["aiopegelonline==0.0.10"]
+ "requirements": ["aiopegelonline==0.1.1"]
}
diff --git a/homeassistant/components/pegel_online/strings.json b/homeassistant/components/pegel_online/strings.json
index e777f6169ba..b8d18e63a4f 100644
--- a/homeassistant/components/pegel_online/strings.json
+++ b/homeassistant/components/pegel_online/strings.json
@@ -48,5 +48,10 @@
"name": "Water temperature"
}
}
+ },
+ "exceptions": {
+ "communication_error": {
+ "message": "Failed to communicate with API: {error}"
+ }
}
}
diff --git a/homeassistant/components/pencom/manifest.json b/homeassistant/components/pencom/manifest.json
index 34ebe315972..306b2e7be49 100644
--- a/homeassistant/components/pencom/manifest.json
+++ b/homeassistant/components/pencom/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/pencom",
"iot_class": "local_polling",
"loggers": ["pencompy"],
+ "quality_scale": "legacy",
"requirements": ["pencompy==0.0.3"]
}
diff --git a/homeassistant/components/persistent_notification/strings.json b/homeassistant/components/persistent_notification/strings.json
index b9a4ae4f10f..e6c3d3b7775 100644
--- a/homeassistant/components/persistent_notification/strings.json
+++ b/homeassistant/components/persistent_notification/strings.json
@@ -21,17 +21,17 @@
},
"dismiss": {
"name": "Dismiss",
- "description": "Removes a notification from the notifications panel.",
+ "description": "Deletes a notification from the notifications panel.",
"fields": {
"notification_id": {
"name": "[%key:component::persistent_notification::services::create::fields::notification_id::name%]",
- "description": "ID of the notification to be removed."
+ "description": "ID of the notification to be deleted."
}
}
},
"dismiss_all": {
"name": "Dismiss all",
- "description": "Removes all notifications from the notifications panel."
+ "description": "Deletes all notifications from the notifications panel."
}
}
}
diff --git a/homeassistant/components/philips_js/strings.json b/homeassistant/components/philips_js/strings.json
index 3ea632ce436..1f187d89dda 100644
--- a/homeassistant/components/philips_js/strings.json
+++ b/homeassistant/components/philips_js/strings.json
@@ -18,11 +18,11 @@
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"unknown": "[%key:common::config_flow::error::unknown%]",
- "pairing_failure": "Unable to pair: {error_id}",
"invalid_pin": "Invalid PIN"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
+ "pairing_failure": "Unable to pair: {error_id}",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
}
},
diff --git a/homeassistant/components/pi_hole/sensor.py b/homeassistant/components/pi_hole/sensor.py
index 503883e9326..4cf5133e700 100644
--- a/homeassistant/components/pi_hole/sensor.py
+++ b/homeassistant/components/pi_hole/sensor.py
@@ -18,7 +18,6 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
SensorEntityDescription(
key="ads_blocked_today",
translation_key="ads_blocked_today",
- native_unit_of_measurement="ads",
),
SensorEntityDescription(
key="ads_percentage_today",
@@ -28,38 +27,20 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
SensorEntityDescription(
key="clients_ever_seen",
translation_key="clients_ever_seen",
- native_unit_of_measurement="clients",
),
SensorEntityDescription(
- key="dns_queries_today",
- translation_key="dns_queries_today",
- native_unit_of_measurement="queries",
+ key="dns_queries_today", translation_key="dns_queries_today"
),
SensorEntityDescription(
key="domains_being_blocked",
translation_key="domains_being_blocked",
- native_unit_of_measurement="domains",
),
+ SensorEntityDescription(key="queries_cached", translation_key="queries_cached"),
SensorEntityDescription(
- key="queries_cached",
- translation_key="queries_cached",
- native_unit_of_measurement="queries",
- ),
- SensorEntityDescription(
- key="queries_forwarded",
- translation_key="queries_forwarded",
- native_unit_of_measurement="queries",
- ),
- SensorEntityDescription(
- key="unique_clients",
- translation_key="unique_clients",
- native_unit_of_measurement="clients",
- ),
- SensorEntityDescription(
- key="unique_domains",
- translation_key="unique_domains",
- native_unit_of_measurement="domains",
+ key="queries_forwarded", translation_key="queries_forwarded"
),
+ SensorEntityDescription(key="unique_clients", translation_key="unique_clients"),
+ SensorEntityDescription(key="unique_domains", translation_key="unique_domains"),
)
diff --git a/homeassistant/components/pi_hole/strings.json b/homeassistant/components/pi_hole/strings.json
index b76b61f1903..9e1d5948a09 100644
--- a/homeassistant/components/pi_hole/strings.json
+++ b/homeassistant/components/pi_hole/strings.json
@@ -41,31 +41,39 @@
},
"sensor": {
"ads_blocked_today": {
- "name": "Ads blocked today"
+ "name": "Ads blocked today",
+ "unit_of_measurement": "ads"
},
"ads_percentage_today": {
"name": "Ads percentage blocked today"
},
"clients_ever_seen": {
- "name": "Seen clients"
+ "name": "Seen clients",
+ "unit_of_measurement": "clients"
},
"dns_queries_today": {
- "name": "DNS queries today"
+ "name": "DNS queries today",
+ "unit_of_measurement": "queries"
},
"domains_being_blocked": {
- "name": "Domains blocked"
+ "name": "Domains blocked",
+ "unit_of_measurement": "domains"
},
"queries_cached": {
- "name": "DNS queries cached"
+ "name": "DNS queries cached",
+ "unit_of_measurement": "[%key:component::pi_hole::entity::sensor::dns_queries_today::unit_of_measurement%]"
},
"queries_forwarded": {
- "name": "DNS queries forwarded"
+ "name": "DNS queries forwarded",
+ "unit_of_measurement": "[%key:component::pi_hole::entity::sensor::dns_queries_today::unit_of_measurement%]"
},
"unique_clients": {
- "name": "DNS unique clients"
+ "name": "DNS unique clients",
+ "unit_of_measurement": "[%key:component::pi_hole::entity::sensor::clients_ever_seen::unit_of_measurement%]"
},
"unique_domains": {
- "name": "DNS unique domains"
+ "name": "DNS unique domains",
+ "unit_of_measurement": "[%key:component::pi_hole::entity::sensor::domains_being_blocked::unit_of_measurement%]"
}
},
"update": {
diff --git a/homeassistant/components/picotts/manifest.json b/homeassistant/components/picotts/manifest.json
index 74b91e187ba..6e8c346a3c9 100644
--- a/homeassistant/components/picotts/manifest.json
+++ b/homeassistant/components/picotts/manifest.json
@@ -3,5 +3,6 @@
"name": "Pico TTS",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/picotts",
- "iot_class": "local_push"
+ "iot_class": "local_push",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/pilight/manifest.json b/homeassistant/components/pilight/manifest.json
index 341d0abdf67..da07c4ee645 100644
--- a/homeassistant/components/pilight/manifest.json
+++ b/homeassistant/components/pilight/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/pilight",
"iot_class": "local_push",
"loggers": ["pilight"],
+ "quality_scale": "legacy",
"requirements": ["pilight==0.1.1"]
}
diff --git a/homeassistant/components/ping/__init__.py b/homeassistant/components/ping/__init__.py
index f4a04caae5b..4b03e5e4407 100644
--- a/homeassistant/components/ping/__init__.py
+++ b/homeassistant/components/ping/__init__.py
@@ -2,7 +2,6 @@
from __future__ import annotations
-from dataclasses import dataclass
import logging
from icmplib import SocketPermissionError, async_ping
@@ -12,6 +11,7 @@ from homeassistant.const import CONF_HOST, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
+from homeassistant.util.hass_dict import HassKey
from .const import CONF_PING_COUNT, DOMAIN
from .coordinator import PingUpdateCoordinator
@@ -21,13 +21,7 @@ _LOGGER = logging.getLogger(__name__)
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
PLATFORMS = [Platform.BINARY_SENSOR, Platform.DEVICE_TRACKER, Platform.SENSOR]
-
-
-@dataclass(slots=True)
-class PingDomainData:
- """Dataclass to store privileged status."""
-
- privileged: bool | None
+DATA_PRIVILEGED_KEY: HassKey[bool | None] = HassKey(DOMAIN)
type PingConfigEntry = ConfigEntry[PingUpdateCoordinator]
@@ -35,29 +29,25 @@ type PingConfigEntry = ConfigEntry[PingUpdateCoordinator]
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the ping integration."""
-
- hass.data[DOMAIN] = PingDomainData(
- privileged=await _can_use_icmp_lib_with_privilege(),
- )
+ hass.data[DATA_PRIVILEGED_KEY] = await _can_use_icmp_lib_with_privilege()
return True
async def async_setup_entry(hass: HomeAssistant, entry: PingConfigEntry) -> bool:
"""Set up Ping (ICMP) from a config entry."""
-
- data: PingDomainData = hass.data[DOMAIN]
+ privileged = hass.data[DATA_PRIVILEGED_KEY]
host: str = entry.options[CONF_HOST]
count: int = int(entry.options[CONF_PING_COUNT])
ping_cls: type[PingDataICMPLib | PingDataSubProcess]
- if data.privileged is None:
+ if privileged is None:
ping_cls = PingDataSubProcess
else:
ping_cls = PingDataICMPLib
coordinator = PingUpdateCoordinator(
- hass=hass, ping=ping_cls(hass, host, count, data.privileged)
+ hass=hass, ping=ping_cls(hass, host, count, privileged)
)
await coordinator.async_config_entry_first_refresh()
diff --git a/homeassistant/components/ping/config_flow.py b/homeassistant/components/ping/config_flow.py
index 4f2adb0d2c0..27cb3f62bcd 100644
--- a/homeassistant/components/ping/config_flow.py
+++ b/homeassistant/components/ping/config_flow.py
@@ -27,6 +27,12 @@ from .const import CONF_PING_COUNT, DEFAULT_PING_COUNT, DOMAIN
_LOGGER = logging.getLogger(__name__)
+def _clean_user_input(user_input: dict[str, Any]) -> dict[str, Any]:
+ """Clean up the user input."""
+ user_input[CONF_HOST] = user_input[CONF_HOST].strip()
+ return user_input
+
+
class PingConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Ping."""
@@ -46,6 +52,7 @@ class PingConfigFlow(ConfigFlow, domain=DOMAIN):
),
)
+ user_input = _clean_user_input(user_input)
if not is_ip_address(user_input[CONF_HOST]):
self.async_abort(reason="invalid_ip_address")
@@ -77,7 +84,7 @@ class OptionsFlowHandler(OptionsFlow):
) -> ConfigFlowResult:
"""Manage the options."""
if user_input is not None:
- return self.async_create_entry(title="", data=user_input)
+ return self.async_create_entry(title="", data=_clean_user_input(user_input))
return self.async_show_form(
step_id="init",
diff --git a/homeassistant/components/pioneer/manifest.json b/homeassistant/components/pioneer/manifest.json
index c8aa3a79789..019b7680e09 100644
--- a/homeassistant/components/pioneer/manifest.json
+++ b/homeassistant/components/pioneer/manifest.json
@@ -3,5 +3,6 @@
"name": "Pioneer",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/pioneer",
- "iot_class": "local_polling"
+ "iot_class": "local_polling",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/pjlink/manifest.json b/homeassistant/components/pjlink/manifest.json
index 553ed185241..787311b250a 100644
--- a/homeassistant/components/pjlink/manifest.json
+++ b/homeassistant/components/pjlink/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/pjlink",
"iot_class": "local_polling",
"loggers": ["pypjlink"],
+ "quality_scale": "legacy",
"requirements": ["pypjlink2==1.2.1"]
}
diff --git a/homeassistant/components/plaato/__init__.py b/homeassistant/components/plaato/__init__.py
index 59441f25025..585b6ecfd82 100644
--- a/homeassistant/components/plaato/__init__.py
+++ b/homeassistant/components/plaato/__init__.py
@@ -64,10 +64,10 @@ WEBHOOK_SCHEMA = vol.Schema(
vol.Required(ATTR_DEVICE_NAME): cv.string,
vol.Required(ATTR_DEVICE_ID): cv.positive_int,
vol.Required(ATTR_TEMP_UNIT): vol.In(
- UnitOfTemperature.CELSIUS, UnitOfTemperature.FAHRENHEIT
+ [UnitOfTemperature.CELSIUS, UnitOfTemperature.FAHRENHEIT]
),
vol.Required(ATTR_VOLUME_UNIT): vol.In(
- UnitOfVolume.LITERS, UnitOfVolume.GALLONS
+ [UnitOfVolume.LITERS, UnitOfVolume.GALLONS]
),
vol.Required(ATTR_BPM): cv.positive_int,
vol.Required(ATTR_TEMP): vol.Coerce(float),
diff --git a/homeassistant/components/plaato/manifest.json b/homeassistant/components/plaato/manifest.json
index aac7ec2d06f..1547501ac50 100644
--- a/homeassistant/components/plaato/manifest.json
+++ b/homeassistant/components/plaato/manifest.json
@@ -8,5 +8,5 @@
"documentation": "https://www.home-assistant.io/integrations/plaato",
"iot_class": "cloud_push",
"loggers": ["pyplaato"],
- "requirements": ["pyplaato==0.0.18"]
+ "requirements": ["pyplaato==0.0.19"]
}
diff --git a/homeassistant/components/plex/server.py b/homeassistant/components/plex/server.py
index 0716b3606af..eab1d086d4c 100644
--- a/homeassistant/components/plex/server.py
+++ b/homeassistant/components/plex/server.py
@@ -425,9 +425,7 @@ class PlexServer:
client = resource.connect(timeout=3)
_LOGGER.debug("Resource connection successful to plex.tv: %s", client)
except NotFound:
- _LOGGER.error(
- "Resource connection failed to plex.tv: %s", resource.name
- )
+ _LOGGER.info("Resource connection failed to plex.tv: %s", resource.name)
else:
client.proxyThroughServer(value=False, server=self._plex_server)
self._client_device_cache[client.machineIdentifier] = client
diff --git a/homeassistant/components/plugwise/__init__.py b/homeassistant/components/plugwise/__init__.py
index 7d1b9ceac8a..a100103b029 100644
--- a/homeassistant/components/plugwise/__init__.py
+++ b/homeassistant/components/plugwise/__init__.py
@@ -83,7 +83,7 @@ def migrate_sensor_entities(
# Migrating opentherm_outdoor_temperature
# to opentherm_outdoor_air_temperature sensor
for device_id, device in coordinator.data.devices.items():
- if device.get("dev_class") != "heater_central":
+ if device["dev_class"] != "heater_central":
continue
old_unique_id = f"{device_id}-outdoor_temperature"
diff --git a/homeassistant/components/plugwise/binary_sensor.py b/homeassistant/components/plugwise/binary_sensor.py
index fb271ea7264..539fa243d6c 100644
--- a/homeassistant/components/plugwise/binary_sensor.py
+++ b/homeassistant/components/plugwise/binary_sensor.py
@@ -23,6 +23,9 @@ from .entity import PlugwiseEntity
SEVERITIES = ["other", "info", "warning", "error"]
+# Coordinator is used to centralize the data updates
+PARALLEL_UPDATES = 0
+
@dataclass(frozen=True)
class PlugwiseBinarySensorEntityDescription(BinarySensorEntityDescription):
@@ -34,7 +37,6 @@ class PlugwiseBinarySensorEntityDescription(BinarySensorEntityDescription):
BINARY_SENSORS: tuple[PlugwiseBinarySensorEntityDescription, ...] = (
PlugwiseBinarySensorEntityDescription(
key="low_battery",
- translation_key="low_battery",
device_class=BinarySensorDeviceClass.BATTERY,
entity_category=EntityCategory.DIAGNOSTIC,
),
@@ -56,7 +58,6 @@ BINARY_SENSORS: tuple[PlugwiseBinarySensorEntityDescription, ...] = (
PlugwiseBinarySensorEntityDescription(
key="flame_state",
translation_key="flame_state",
- name="Flame state",
entity_category=EntityCategory.DIAGNOSTIC,
),
PlugwiseBinarySensorEntityDescription(
diff --git a/homeassistant/components/plugwise/button.py b/homeassistant/components/plugwise/button.py
index 078d31bea12..8a05ede3496 100644
--- a/homeassistant/components/plugwise/button.py
+++ b/homeassistant/components/plugwise/button.py
@@ -13,6 +13,8 @@ from .coordinator import PlugwiseDataUpdateCoordinator
from .entity import PlugwiseEntity
from .util import plugwise_command
+PARALLEL_UPDATES = 0
+
async def async_setup_entry(
hass: HomeAssistant,
diff --git a/homeassistant/components/plugwise/climate.py b/homeassistant/components/plugwise/climate.py
index 7b0fe35835d..3caed1e7bc2 100644
--- a/homeassistant/components/plugwise/climate.py
+++ b/homeassistant/components/plugwise/climate.py
@@ -15,7 +15,7 @@ from homeassistant.components.climate import (
)
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.core import HomeAssistant, callback
-from homeassistant.exceptions import HomeAssistantError
+from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import PlugwiseConfigEntry
@@ -24,6 +24,8 @@ from .coordinator import PlugwiseDataUpdateCoordinator
from .entity import PlugwiseEntity
from .util import plugwise_command
+PARALLEL_UPDATES = 0
+
async def async_setup_entry(
hass: HomeAssistant,
@@ -39,11 +41,19 @@ async def async_setup_entry(
if not coordinator.new_devices:
return
- async_add_entities(
- PlugwiseClimateEntity(coordinator, device_id)
- for device_id in coordinator.new_devices
- if coordinator.data.devices[device_id]["dev_class"] in MASTER_THERMOSTATS
- )
+ if coordinator.data.gateway["smile_name"] == "Adam":
+ async_add_entities(
+ PlugwiseClimateEntity(coordinator, device_id)
+ for device_id in coordinator.new_devices
+ if coordinator.data.devices[device_id]["dev_class"] == "climate"
+ )
+ else:
+ async_add_entities(
+ PlugwiseClimateEntity(coordinator, device_id)
+ for device_id in coordinator.new_devices
+ if coordinator.data.devices[device_id]["dev_class"]
+ in MASTER_THERMOSTATS
+ )
_add_entities()
entry.async_on_unload(coordinator.async_add_listener(_add_entities))
@@ -52,11 +62,9 @@ async def async_setup_entry(
class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity):
"""Representation of a Plugwise thermostat."""
- _attr_has_entity_name = True
_attr_name = None
_attr_temperature_unit = UnitOfTemperature.CELSIUS
_attr_translation_key = DOMAIN
- _enable_turn_on_off_backwards_compatibility = False
_previous_mode: str = "heating"
@@ -67,17 +75,20 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity):
) -> None:
"""Set up the Plugwise API."""
super().__init__(coordinator, device_id)
- self._attr_extra_state_attributes = {}
self._attr_unique_id = f"{device_id}-climate"
- self.cdr_gateway = coordinator.data.gateway
- gateway_id: str = coordinator.data.gateway["gateway_id"]
- self.gateway_data = coordinator.data.devices[gateway_id]
+
+ self._devices = coordinator.data.devices
+ self._gateway = coordinator.data.gateway
+ gateway_id: str = self._gateway["gateway_id"]
+ self._gateway_data = self._devices[gateway_id]
+
+ self._location = device_id
+ if (location := self.device.get("location")) is not None:
+ self._location = location
+
# Determine supported features
self._attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE
- if (
- self.cdr_gateway["cooling_present"]
- and self.cdr_gateway["smile_name"] != "Adam"
- ):
+ if self._gateway["cooling_present"] and self._gateway["smile_name"] != "Adam":
self._attr_supported_features = (
ClimateEntityFeature.TARGET_TEMPERATURE_RANGE
)
@@ -103,10 +114,10 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity):
"""
# When no cooling available, _previous_mode is always heating
if (
- "regulation_modes" in self.gateway_data
- and "cooling" in self.gateway_data["regulation_modes"]
+ "regulation_modes" in self._gateway_data
+ and "cooling" in self._gateway_data["regulation_modes"]
):
- mode = self.gateway_data["select_regulation_mode"]
+ mode = self._gateway_data["select_regulation_mode"]
if mode in ("cooling", "heating"):
self._previous_mode = mode
@@ -143,7 +154,9 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity):
@property
def hvac_mode(self) -> HVACMode:
"""Return HVAC operation ie. auto, cool, heat, heat_cool, or off mode."""
- if (mode := self.device.get("mode")) is None or mode not in self.hvac_modes:
+ if (
+ mode := self.device.get("climate_mode")
+ ) is None or mode not in self.hvac_modes:
return HVACMode.HEAT
return HVACMode(mode)
@@ -151,17 +164,17 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity):
def hvac_modes(self) -> list[HVACMode]:
"""Return a list of available HVACModes."""
hvac_modes: list[HVACMode] = []
- if "regulation_modes" in self.gateway_data:
+ if "regulation_modes" in self._gateway_data:
hvac_modes.append(HVACMode.OFF)
if "available_schedules" in self.device:
hvac_modes.append(HVACMode.AUTO)
- if self.cdr_gateway["cooling_present"]:
- if "regulation_modes" in self.gateway_data:
- if self.gateway_data["select_regulation_mode"] == "cooling":
+ if self._gateway["cooling_present"]:
+ if "regulation_modes" in self._gateway_data:
+ if self._gateway_data["select_regulation_mode"] == "cooling":
hvac_modes.append(HVACMode.COOL)
- if self.gateway_data["select_regulation_mode"] == "heating":
+ if self._gateway_data["select_regulation_mode"] == "heating":
hvac_modes.append(HVACMode.HEAT)
else:
hvac_modes.append(HVACMode.HEAT_COOL)
@@ -175,23 +188,8 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity):
"""Return the current running hvac operation if supported."""
# Keep track of the previous action-mode
self._previous_action_mode(self.coordinator)
-
- # Adam provides the hvac_action for each thermostat
- if (control_state := self.device.get("control_state")) == "cooling":
- return HVACAction.COOLING
- if control_state == "heating":
- return HVACAction.HEATING
- if control_state == "preheating":
- return HVACAction.PREHEATING
- if control_state == "off":
- return HVACAction.IDLE
-
- heater: str = self.coordinator.data.gateway["heater_id"]
- heater_data = self.coordinator.data.devices[heater]
- if heater_data["binary_sensors"]["heating_state"]:
- return HVACAction.HEATING
- if heater_data["binary_sensors"].get("cooling_state", False):
- return HVACAction.COOLING
+ if (action := self.device.get("control_state")) is not None:
+ return HVACAction(action)
return HVACAction.IDLE
@@ -211,22 +209,24 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity):
if ATTR_TARGET_TEMP_LOW in kwargs:
data["setpoint_low"] = kwargs.get(ATTR_TARGET_TEMP_LOW)
- for temperature in data.values():
- if temperature is None or not (
- self._attr_min_temp <= temperature <= self._attr_max_temp
- ):
- raise ValueError("Invalid temperature change requested")
-
if mode := kwargs.get(ATTR_HVAC_MODE):
await self.async_set_hvac_mode(mode)
- await self.coordinator.api.set_temperature(self.device["location"], data)
+ await self.coordinator.api.set_temperature(self._location, data)
@plugwise_command
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set the hvac mode."""
if hvac_mode not in self.hvac_modes:
- raise HomeAssistantError("Unsupported hvac_mode")
+ hvac_modes = ", ".join(self.hvac_modes)
+ raise ServiceValidationError(
+ translation_domain=DOMAIN,
+ translation_key="unsupported_hvac_mode_requested",
+ translation_placeholders={
+ "hvac_mode": hvac_mode,
+ "hvac_modes": hvac_modes,
+ },
+ )
if hvac_mode == self.hvac_mode:
return
@@ -235,7 +235,7 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity):
await self.coordinator.api.set_regulation_mode(hvac_mode)
else:
await self.coordinator.api.set_schedule_state(
- self.device["location"],
+ self._location,
"on" if hvac_mode == HVACMode.AUTO else "off",
)
if self.hvac_mode == HVACMode.OFF:
@@ -244,4 +244,4 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity):
@plugwise_command
async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set the preset mode."""
- await self.coordinator.api.set_preset(self.device["location"], preset_mode)
+ await self.coordinator.api.set_preset(self._location, preset_mode)
diff --git a/homeassistant/components/plugwise/config_flow.py b/homeassistant/components/plugwise/config_flow.py
index 57abb1ccb86..6114dd39a6d 100644
--- a/homeassistant/components/plugwise/config_flow.py
+++ b/homeassistant/components/plugwise/config_flow.py
@@ -2,6 +2,7 @@
from __future__ import annotations
+import logging
from typing import Any, Self
from plugwise import Smile
@@ -41,8 +42,16 @@ from .const import (
ZEROCONF_MAP,
)
+_LOGGER = logging.getLogger(__name__)
-def base_schema(discovery_info: ZeroconfServiceInfo | None) -> vol.Schema:
+SMILE_RECONF_SCHEMA = vol.Schema(
+ {
+ vol.Required(CONF_HOST): str,
+ }
+)
+
+
+def smile_user_schema(discovery_info: ZeroconfServiceInfo | None) -> vol.Schema:
"""Generate base schema for gateways."""
schema = vol.Schema({vol.Required(CONF_PASSWORD): str})
@@ -50,6 +59,7 @@ def base_schema(discovery_info: ZeroconfServiceInfo | None) -> vol.Schema:
schema = schema.extend(
{
vol.Required(CONF_HOST): str,
+ # Port under investigation for removal (hence not added in #132878)
vol.Optional(CONF_PORT, default=DEFAULT_PORT): int,
vol.Required(CONF_USERNAME, default=SMILE): vol.In(
{SMILE: FLOW_SMILE, STRETCH: FLOW_STRETCH}
@@ -63,7 +73,7 @@ def base_schema(discovery_info: ZeroconfServiceInfo | None) -> vol.Schema:
async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> Smile:
"""Validate whether the user input allows us to connect to the gateway.
- Data has the keys from base_schema() with values provided by the user.
+ Data has the keys from the schema with values provided by the user.
"""
websession = async_get_clientsession(hass, verify_ssl=False)
api = Smile(
@@ -77,6 +87,32 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> Smile:
return api
+async def verify_connection(
+ hass: HomeAssistant, user_input: dict[str, Any]
+) -> tuple[Smile | None, dict[str, str]]:
+ """Verify and return the gateway connection or an error."""
+ errors: dict[str, str] = {}
+
+ try:
+ return (await validate_input(hass, user_input), errors)
+ except ConnectionFailedError:
+ errors[CONF_BASE] = "cannot_connect"
+ except InvalidAuthentication:
+ errors[CONF_BASE] = "invalid_auth"
+ except InvalidSetupError:
+ errors[CONF_BASE] = "invalid_setup"
+ except (InvalidXMLError, ResponseError):
+ errors[CONF_BASE] = "response_error"
+ except UnsupportedDeviceError:
+ errors[CONF_BASE] = "unsupported"
+ except Exception: # noqa: BLE001
+ _LOGGER.exception(
+ "Unknown exception while verifying connection with your Plugwise Smile"
+ )
+ errors[CONF_BASE] = "unknown"
+ return (None, errors)
+
+
class PlugwiseConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Plugwise Smile."""
@@ -166,30 +202,56 @@ class PlugwiseConfigFlow(ConfigFlow, domain=DOMAIN):
user_input[CONF_PORT] = self.discovery_info.port
user_input[CONF_USERNAME] = self._username
- try:
- api = await validate_input(self.hass, user_input)
- except ConnectionFailedError:
- errors[CONF_BASE] = "cannot_connect"
- except InvalidAuthentication:
- errors[CONF_BASE] = "invalid_auth"
- except InvalidSetupError:
- errors[CONF_BASE] = "invalid_setup"
- except (InvalidXMLError, ResponseError):
- errors[CONF_BASE] = "response_error"
- except UnsupportedDeviceError:
- errors[CONF_BASE] = "unsupported"
- except Exception: # noqa: BLE001
- errors[CONF_BASE] = "unknown"
- else:
+ api, errors = await verify_connection(self.hass, user_input)
+ if api:
await self.async_set_unique_id(
- api.smile_hostname or api.gateway_id, raise_on_progress=False
+ api.smile_hostname or api.gateway_id,
+ raise_on_progress=False,
)
self._abort_if_unique_id_configured()
-
return self.async_create_entry(title=api.smile_name, data=user_input)
return self.async_show_form(
step_id=SOURCE_USER,
- data_schema=base_schema(self.discovery_info),
+ data_schema=smile_user_schema(self.discovery_info),
+ 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:
+ # Keep current username and password
+ full_input = {
+ CONF_HOST: user_input.get(CONF_HOST),
+ CONF_PORT: reconfigure_entry.data.get(CONF_PORT),
+ CONF_USERNAME: reconfigure_entry.data.get(CONF_USERNAME),
+ CONF_PASSWORD: reconfigure_entry.data.get(CONF_PASSWORD),
+ }
+
+ api, errors = await verify_connection(self.hass, full_input)
+ if api:
+ await self.async_set_unique_id(
+ api.smile_hostname or api.gateway_id,
+ raise_on_progress=False,
+ )
+ self._abort_if_unique_id_mismatch(reason="not_the_same_smile")
+ return self.async_update_reload_and_abort(
+ reconfigure_entry,
+ data_updates=full_input,
+ )
+
+ return self.async_show_form(
+ step_id="reconfigure",
+ data_schema=self.add_suggested_values_to_schema(
+ data_schema=SMILE_RECONF_SCHEMA,
+ suggested_values=reconfigure_entry.data,
+ ),
+ description_placeholders={"title": reconfigure_entry.title},
errors=errors,
)
diff --git a/homeassistant/components/plugwise/coordinator.py b/homeassistant/components/plugwise/coordinator.py
index b897a8bf833..7ac0cc21c51 100644
--- a/homeassistant/components/plugwise/coordinator.py
+++ b/homeassistant/components/plugwise/coordinator.py
@@ -64,30 +64,41 @@ class PlugwiseDataUpdateCoordinator(DataUpdateCoordinator[PlugwiseData]):
version = await self.api.connect()
self._connected = isinstance(version, Version)
if self._connected:
- self.api.get_all_devices()
+ self.api.get_all_gateway_entities()
async def _async_update_data(self) -> PlugwiseData:
"""Fetch data from Plugwise."""
- data = PlugwiseData({}, {})
try:
if not self._connected:
await self._connect()
data = await self.api.async_update()
except ConnectionFailedError as err:
- raise UpdateFailed("Failed to connect") from err
+ raise UpdateFailed(
+ translation_domain=DOMAIN,
+ translation_key="failed_to_connect",
+ ) from err
except InvalidAuthentication as err:
- raise ConfigEntryError("Authentication failed") from err
+ raise ConfigEntryError(
+ translation_domain=DOMAIN,
+ translation_key="authentication_failed",
+ ) from err
except (InvalidXMLError, ResponseError) as err:
raise UpdateFailed(
- "Invalid XML data, or error indication received from the Plugwise Adam/Smile/Stretch"
+ translation_domain=DOMAIN,
+ translation_key="invalid_xml_data",
) from err
except PlugwiseError as err:
- raise UpdateFailed("Data incomplete or missing") from err
+ raise UpdateFailed(
+ translation_domain=DOMAIN,
+ translation_key="data_incomplete_or_missing",
+ ) from err
except UnsupportedDeviceError as err:
- raise ConfigEntryError("Device with unsupported firmware") from err
- else:
- self._async_add_remove_devices(data, self.config_entry)
+ raise ConfigEntryError(
+ translation_domain=DOMAIN,
+ translation_key="unsupported_firmware",
+ ) from err
+ self._async_add_remove_devices(data, self.config_entry)
return data
def _async_add_remove_devices(self, data: PlugwiseData, entry: ConfigEntry) -> None:
diff --git a/homeassistant/components/plugwise/diagnostics.py b/homeassistant/components/plugwise/diagnostics.py
index 9d15ea4fe28..47ff7d1a9fb 100644
--- a/homeassistant/components/plugwise/diagnostics.py
+++ b/homeassistant/components/plugwise/diagnostics.py
@@ -15,6 +15,6 @@ async def async_get_config_entry_diagnostics(
"""Return diagnostics for a config entry."""
coordinator = entry.runtime_data
return {
- "gateway": coordinator.data.gateway,
"devices": coordinator.data.devices,
+ "gateway": coordinator.data.gateway,
}
diff --git a/homeassistant/components/plugwise/entity.py b/homeassistant/components/plugwise/entity.py
index e24f3d1e1bb..3f63abaff43 100644
--- a/homeassistant/components/plugwise/entity.py
+++ b/homeassistant/components/plugwise/entity.py
@@ -2,7 +2,7 @@
from __future__ import annotations
-from plugwise.constants import DeviceData
+from plugwise.constants import GwEntityData
from homeassistant.const import ATTR_NAME, ATTR_VIA_DEVICE, CONF_HOST
from homeassistant.helpers.device_registry import (
@@ -74,11 +74,6 @@ class PlugwiseEntity(CoordinatorEntity[PlugwiseDataUpdateCoordinator]):
)
@property
- def device(self) -> DeviceData:
+ def device(self) -> GwEntityData:
"""Return data for this device."""
return self.coordinator.data.devices[self._dev_id]
-
- async def async_added_to_hass(self) -> None:
- """Subscribe to updates."""
- self._handle_coordinator_update()
- await super().async_added_to_hass()
diff --git a/homeassistant/components/plugwise/manifest.json b/homeassistant/components/plugwise/manifest.json
index dbbad15c0dc..ae60d4d7452 100644
--- a/homeassistant/components/plugwise/manifest.json
+++ b/homeassistant/components/plugwise/manifest.json
@@ -1,12 +1,12 @@
{
"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"],
- "requirements": ["plugwise==1.5.0"],
+ "requirements": ["plugwise==1.6.4"],
"zeroconf": ["_plugwise._tcp.local."]
}
diff --git a/homeassistant/components/plugwise/number.py b/homeassistant/components/plugwise/number.py
index 06db5faa55b..1d0b1382c24 100644
--- a/homeassistant/components/plugwise/number.py
+++ b/homeassistant/components/plugwise/number.py
@@ -20,6 +20,8 @@ from .coordinator import PlugwiseDataUpdateCoordinator
from .entity import PlugwiseEntity
from .util import plugwise_command
+PARALLEL_UPDATES = 0
+
@dataclass(frozen=True, kw_only=True)
class PlugwiseNumberEntityDescription(NumberEntityDescription):
@@ -91,12 +93,12 @@ class PlugwiseNumberEntity(PlugwiseEntity, NumberEntity):
) -> None:
"""Initiate Plugwise Number."""
super().__init__(coordinator, device_id)
- self.device_id = device_id
- self.entity_description = description
- self._attr_unique_id = f"{device_id}-{description.key}"
self._attr_mode = NumberMode.BOX
self._attr_native_max_value = self.device[description.key]["upper_bound"]
self._attr_native_min_value = self.device[description.key]["lower_bound"]
+ self._attr_unique_id = f"{device_id}-{description.key}"
+ self.device_id = device_id
+ self.entity_description = description
native_step = self.device[description.key]["resolution"]
if description.key != "temperature_offset":
diff --git a/homeassistant/components/plugwise/quality_scale.yaml b/homeassistant/components/plugwise/quality_scale.yaml
new file mode 100644
index 00000000000..a7b955b4713
--- /dev/null
+++ b/homeassistant/components/plugwise/quality_scale.yaml
@@ -0,0 +1,83 @@
+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: done
+ dependency-transparency: done
+ action-setup:
+ 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-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:
+ status: exempt
+ comment: The hubs have a hardcoded `Smile ID` printed on the sticker used as password, it can not be changed
+ 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-configuration-parameters:
+ status: exempt
+ comment: Plugwise 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: done
+ exception-translations: done
+ icon-translations: done
+ reconfiguration-flow: done
+ dynamic-devices: done
+ discovery-update-info: done
+ 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-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
+ ## Platinum
+ async-dependency: done
+ inject-websession: done
+ strict-typing: done
diff --git a/homeassistant/components/plugwise/select.py b/homeassistant/components/plugwise/select.py
index b7d4a0a1ded..ff268d8eded 100644
--- a/homeassistant/components/plugwise/select.py
+++ b/homeassistant/components/plugwise/select.py
@@ -10,11 +10,13 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import PlugwiseConfigEntry
-from .const import LOCATION, SelectOptionsType, SelectType
+from .const import SelectOptionsType, SelectType
from .coordinator import PlugwiseDataUpdateCoordinator
from .entity import PlugwiseEntity
from .util import plugwise_command
+PARALLEL_UPDATES = 0
+
@dataclass(frozen=True, kw_only=True)
class PlugwiseSelectEntityDescription(SelectEntityDescription):
@@ -89,8 +91,12 @@ class PlugwiseSelectEntity(PlugwiseEntity, SelectEntity):
) -> None:
"""Initialise the selector."""
super().__init__(coordinator, device_id)
- self.entity_description = entity_description
self._attr_unique_id = f"{device_id}-{entity_description.key}"
+ self.entity_description = entity_description
+
+ self._location = device_id
+ if (location := self.device.get("location")) is not None:
+ self._location = location
@property
def current_option(self) -> str:
@@ -106,8 +112,8 @@ class PlugwiseSelectEntity(PlugwiseEntity, SelectEntity):
async def async_select_option(self, option: str) -> None:
"""Change to the selected entity option.
- self.device[LOCATION] and STATE_ON are required for the thermostat-schedule select.
+ self._location and STATE_ON are required for the thermostat-schedule select.
"""
await self.coordinator.api.set_select(
- self.entity_description.key, self.device[LOCATION], option, STATE_ON
+ self.entity_description.key, self._location, option, STATE_ON
)
diff --git a/homeassistant/components/plugwise/sensor.py b/homeassistant/components/plugwise/sensor.py
index ae5b4e6ed91..14b42682376 100644
--- a/homeassistant/components/plugwise/sensor.py
+++ b/homeassistant/components/plugwise/sensor.py
@@ -31,6 +31,9 @@ from . import PlugwiseConfigEntry
from .coordinator import PlugwiseDataUpdateCoordinator
from .entity import PlugwiseEntity
+# Coordinator is used to centralize the data updates
+PARALLEL_UPDATES = 0
+
@dataclass(frozen=True)
class PlugwiseSensorEntityDescription(SensorEntityDescription):
@@ -439,8 +442,8 @@ class PlugwiseSensorEntity(PlugwiseEntity, SensorEntity):
) -> None:
"""Initialise the sensor."""
super().__init__(coordinator, device_id)
- self.entity_description = description
self._attr_unique_id = f"{device_id}-{description.key}"
+ self.entity_description = description
@property
def native_value(self) -> int | float:
diff --git a/homeassistant/components/plugwise/strings.json b/homeassistant/components/plugwise/strings.json
index c09323f458b..d16b38df992 100644
--- a/homeassistant/components/plugwise/strings.json
+++ b/homeassistant/components/plugwise/strings.json
@@ -1,17 +1,31 @@
{
"config": {
"step": {
+ "reconfigure": {
+ "description": "Update configuration for {title}.",
+ "data": {
+ "host": "[%key:common::config_flow::data::ip%]",
+ "port": "[%key:common::config_flow::data::port%]"
+ },
+ "data_description": {
+ "host": "[%key:component::plugwise::config::step::user::data_description::host%]",
+ "port": "[%key:component::plugwise::config::step::user::data_description::port%]"
+ }
+ },
"user": {
"title": "Connect to the Smile",
"description": "Please enter",
"data": {
- "password": "Smile ID",
"host": "[%key:common::config_flow::data::ip%]",
+ "password": "Smile ID",
"port": "[%key:common::config_flow::data::port%]",
"username": "Smile Username"
},
"data_description": {
- "host": "Leave empty if using Auto Discovery"
+ "password": "The Smile ID printed on the label on the back of your Adam, Smile-T, or P1.",
+ "host": "The hostname or IP-address of your Smile. You can find it in your router or the Plugwise App.",
+ "port": "By default your Smile uses port 80, normally you should not have to change this.",
+ "username": "Default is `smile`, or `stretch` for the legacy Stretch."
}
}
},
@@ -25,14 +39,13 @@
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
- "anna_with_adam": "Both Anna and Adam detected. Add your Adam instead of your Anna"
+ "anna_with_adam": "Both Anna and Adam detected. Add your Adam instead of your Anna",
+ "not_the_same_smile": "The configured Smile ID does not match the Smile ID on the requested IP address.",
+ "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
}
},
"entity": {
"binary_sensor": {
- "low_battery": {
- "name": "Battery state"
- },
"compressor_state": {
"name": "Compressor state"
},
@@ -284,5 +297,28 @@
"name": "Relay"
}
}
+ },
+ "exceptions": {
+ "authentication_failed": {
+ "message": "[%key:common::config_flow::error::invalid_auth%]"
+ },
+ "data_incomplete_or_missing": {
+ "message": "Data incomplete or missing."
+ },
+ "error_communicating_with_api": {
+ "message": "Error communicating with API: {error}."
+ },
+ "failed_to_connect": {
+ "message": "[%key:common::config_flow::error::cannot_connect%]"
+ },
+ "invalid_xml_data": {
+ "message": "[%key:component::plugwise::config::error::response_error%]"
+ },
+ "unsupported_firmware": {
+ "message": "[%key:component::plugwise::config::error::unsupported%]"
+ },
+ "unsupported_hvac_mode_requested": {
+ "message": "Unsupported mode {hvac_mode} requested, valid modes are: {hvac_modes}."
+ }
}
}
diff --git a/homeassistant/components/plugwise/switch.py b/homeassistant/components/plugwise/switch.py
index a134ab5b044..ea6d6f18b7f 100644
--- a/homeassistant/components/plugwise/switch.py
+++ b/homeassistant/components/plugwise/switch.py
@@ -21,6 +21,8 @@ from .coordinator import PlugwiseDataUpdateCoordinator
from .entity import PlugwiseEntity
from .util import plugwise_command
+PARALLEL_UPDATES = 0
+
@dataclass(frozen=True)
class PlugwiseSwitchEntityDescription(SwitchEntityDescription):
@@ -48,7 +50,6 @@ SWITCHES: tuple[PlugwiseSwitchEntityDescription, ...] = (
PlugwiseSwitchEntityDescription(
key="cooling_ena_switch",
translation_key="cooling_ena_switch",
- name="Cooling",
entity_category=EntityCategory.CONFIG,
),
)
@@ -93,8 +94,8 @@ class PlugwiseSwitchEntity(PlugwiseEntity, SwitchEntity):
) -> None:
"""Set up the Plugwise API."""
super().__init__(coordinator, device_id)
- self.entity_description = description
self._attr_unique_id = f"{device_id}-{description.key}"
+ self.entity_description = description
@property
def is_on(self) -> bool:
diff --git a/homeassistant/components/plugwise/util.py b/homeassistant/components/plugwise/util.py
index d998711f2b9..c830e5f69f3 100644
--- a/homeassistant/components/plugwise/util.py
+++ b/homeassistant/components/plugwise/util.py
@@ -7,6 +7,7 @@ from plugwise.exceptions import PlugwiseException
from homeassistant.exceptions import HomeAssistantError
+from .const import DOMAIN
from .entity import PlugwiseEntity
@@ -24,10 +25,14 @@ def plugwise_command[_PlugwiseEntityT: PlugwiseEntity, **_P, _R](
) -> _R:
try:
return await func(self, *args, **kwargs)
- except PlugwiseException as error:
+ except PlugwiseException as err:
raise HomeAssistantError(
- f"Error communicating with API: {error}"
- ) from error
+ translation_domain=DOMAIN,
+ translation_key="error_communicating_with_api",
+ translation_placeholders={
+ "error": str(err),
+ },
+ ) from err
finally:
await self.coordinator.async_request_refresh()
diff --git a/homeassistant/components/pocketcasts/manifest.json b/homeassistant/components/pocketcasts/manifest.json
index 3cb6f52995e..f2a85ecac0d 100644
--- a/homeassistant/components/pocketcasts/manifest.json
+++ b/homeassistant/components/pocketcasts/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/pocketcasts",
"iot_class": "cloud_polling",
"loggers": ["pycketcasts"],
+ "quality_scale": "legacy",
"requirements": ["pycketcasts==1.0.1"]
}
diff --git a/homeassistant/components/point/manifest.json b/homeassistant/components/point/manifest.json
index 7b0a2f0e01e..5aa733b510f 100644
--- a/homeassistant/components/point/manifest.json
+++ b/homeassistant/components/point/manifest.json
@@ -7,6 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/point",
"iot_class": "cloud_polling",
"loggers": ["pypoint"],
- "quality_scale": "silver",
"requirements": ["pypoint==3.0.0"]
}
diff --git a/homeassistant/components/powerfox/__init__.py b/homeassistant/components/powerfox/__init__.py
new file mode 100644
index 00000000000..243f3aacc4f
--- /dev/null
+++ b/homeassistant/components/powerfox/__init__.py
@@ -0,0 +1,55 @@
+"""The Powerfox integration."""
+
+from __future__ import annotations
+
+import asyncio
+
+from powerfox import Powerfox, PowerfoxConnectionError
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import ConfigEntryNotReady
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+
+from .coordinator import PowerfoxDataUpdateCoordinator
+
+PLATFORMS: list[Platform] = [Platform.SENSOR]
+
+type PowerfoxConfigEntry = ConfigEntry[list[PowerfoxDataUpdateCoordinator]]
+
+
+async def async_setup_entry(hass: HomeAssistant, entry: PowerfoxConfigEntry) -> bool:
+ """Set up Powerfox from a config entry."""
+ client = Powerfox(
+ username=entry.data[CONF_EMAIL],
+ password=entry.data[CONF_PASSWORD],
+ session=async_get_clientsession(hass),
+ )
+
+ try:
+ devices = await client.all_devices()
+ except PowerfoxConnectionError as err:
+ await client.close()
+ raise ConfigEntryNotReady from err
+
+ coordinators: list[PowerfoxDataUpdateCoordinator] = [
+ PowerfoxDataUpdateCoordinator(hass, client, device) for device in devices
+ ]
+
+ 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: PowerfoxConfigEntry) -> bool:
+ """Unload a config entry."""
+ return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
diff --git a/homeassistant/components/powerfox/config_flow.py b/homeassistant/components/powerfox/config_flow.py
new file mode 100644
index 00000000000..dd17badf881
--- /dev/null
+++ b/homeassistant/components/powerfox/config_flow.py
@@ -0,0 +1,135 @@
+"""Config flow for Powerfox integration."""
+
+from __future__ import annotations
+
+from collections.abc import Mapping
+from typing import Any
+
+from powerfox import Powerfox, PowerfoxAuthenticationError, PowerfoxConnectionError
+import voluptuous as vol
+
+from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
+from homeassistant.const import CONF_EMAIL, CONF_PASSWORD
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+
+from .const import DOMAIN
+
+STEP_USER_DATA_SCHEMA = vol.Schema(
+ {
+ vol.Required(CONF_EMAIL): str,
+ vol.Required(CONF_PASSWORD): str,
+ }
+)
+
+STEP_REAUTH_SCHEMA = vol.Schema(
+ {
+ vol.Required(CONF_PASSWORD): str,
+ }
+)
+
+
+class PowerfoxConfigFlow(ConfigFlow, domain=DOMAIN):
+ """Handle a config flow for Powerfox."""
+
+ async def async_step_user(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Handle the initial step."""
+ errors = {}
+
+ if user_input is not None:
+ self._async_abort_entries_match({CONF_EMAIL: user_input[CONF_EMAIL]})
+ client = Powerfox(
+ username=user_input[CONF_EMAIL],
+ password=user_input[CONF_PASSWORD],
+ session=async_get_clientsession(self.hass),
+ )
+ try:
+ await client.all_devices()
+ except PowerfoxAuthenticationError:
+ errors["base"] = "invalid_auth"
+ except PowerfoxConnectionError:
+ errors["base"] = "cannot_connect"
+ else:
+ return self.async_create_entry(
+ title=user_input[CONF_EMAIL],
+ data={
+ CONF_EMAIL: user_input[CONF_EMAIL],
+ CONF_PASSWORD: user_input[CONF_PASSWORD],
+ },
+ )
+ return self.async_show_form(
+ step_id="user",
+ errors=errors,
+ data_schema=STEP_USER_DATA_SCHEMA,
+ )
+
+ async def async_step_reauth(
+ self, entry_data: Mapping[str, Any]
+ ) -> ConfigFlowResult:
+ """Handle re-authentication flow for Powerfox."""
+ 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 flow for Powerfox."""
+ errors = {}
+
+ reauth_entry = self._get_reauth_entry()
+ if user_input is not None:
+ client = Powerfox(
+ username=reauth_entry.data[CONF_EMAIL],
+ password=user_input[CONF_PASSWORD],
+ session=async_get_clientsession(self.hass),
+ )
+ try:
+ await client.all_devices()
+ except PowerfoxAuthenticationError:
+ errors["base"] = "invalid_auth"
+ except PowerfoxConnectionError:
+ errors["base"] = "cannot_connect"
+ else:
+ return self.async_update_reload_and_abort(
+ reauth_entry,
+ data_updates=user_input,
+ )
+ return self.async_show_form(
+ step_id="reauth_confirm",
+ description_placeholders={"email": reauth_entry.data[CONF_EMAIL]},
+ data_schema=STEP_REAUTH_SCHEMA,
+ errors=errors,
+ )
+
+ async def async_step_reconfigure(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Reconfigure Powerfox configuration."""
+ errors = {}
+
+ reconfigure_entry = self._get_reconfigure_entry()
+ if user_input is not None:
+ client = Powerfox(
+ username=user_input[CONF_EMAIL],
+ password=user_input[CONF_PASSWORD],
+ session=async_get_clientsession(self.hass),
+ )
+ try:
+ await client.all_devices()
+ except PowerfoxAuthenticationError:
+ errors["base"] = "invalid_auth"
+ except PowerfoxConnectionError:
+ errors["base"] = "cannot_connect"
+ else:
+ if reconfigure_entry.data[CONF_EMAIL] != user_input[CONF_EMAIL]:
+ self._async_abort_entries_match(
+ {CONF_EMAIL: user_input[CONF_EMAIL]}
+ )
+ return self.async_update_reload_and_abort(
+ reconfigure_entry, data_updates=user_input
+ )
+ return self.async_show_form(
+ step_id="reconfigure",
+ data_schema=STEP_USER_DATA_SCHEMA,
+ errors=errors,
+ )
diff --git a/homeassistant/components/powerfox/const.py b/homeassistant/components/powerfox/const.py
new file mode 100644
index 00000000000..0970e8a1b66
--- /dev/null
+++ b/homeassistant/components/powerfox/const.py
@@ -0,0 +1,11 @@
+"""Constants for the Powerfox integration."""
+
+from __future__ import annotations
+
+from datetime import timedelta
+import logging
+from typing import Final
+
+DOMAIN: Final = "powerfox"
+LOGGER = logging.getLogger(__package__)
+SCAN_INTERVAL = timedelta(minutes=1)
diff --git a/homeassistant/components/powerfox/coordinator.py b/homeassistant/components/powerfox/coordinator.py
new file mode 100644
index 00000000000..a4a26759b69
--- /dev/null
+++ b/homeassistant/components/powerfox/coordinator.py
@@ -0,0 +1,50 @@
+"""Coordinator for Powerfox integration."""
+
+from __future__ import annotations
+
+from powerfox import (
+ Device,
+ Powerfox,
+ PowerfoxAuthenticationError,
+ PowerfoxConnectionError,
+ PowerfoxNoDataError,
+ Poweropti,
+)
+
+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 DOMAIN, LOGGER, SCAN_INTERVAL
+
+
+class PowerfoxDataUpdateCoordinator(DataUpdateCoordinator[Poweropti]):
+ """Class to manage fetching Powerfox data from the API."""
+
+ config_entry: ConfigEntry
+
+ def __init__(
+ self,
+ hass: HomeAssistant,
+ client: Powerfox,
+ device: Device,
+ ) -> None:
+ """Initialize global Powerfox data updater."""
+ super().__init__(
+ hass,
+ LOGGER,
+ name=DOMAIN,
+ update_interval=SCAN_INTERVAL,
+ )
+ self.client = client
+ self.device = device
+
+ async def _async_update_data(self) -> Poweropti:
+ """Fetch data from Powerfox API."""
+ try:
+ return await self.client.device(device_id=self.device.id)
+ except PowerfoxAuthenticationError as err:
+ raise ConfigEntryAuthFailed(err) from err
+ except (PowerfoxConnectionError, PowerfoxNoDataError) as err:
+ raise UpdateFailed(err) from err
diff --git a/homeassistant/components/powerfox/diagnostics.py b/homeassistant/components/powerfox/diagnostics.py
new file mode 100644
index 00000000000..4c6b0f8c6eb
--- /dev/null
+++ b/homeassistant/components/powerfox/diagnostics.py
@@ -0,0 +1,74 @@
+"""Support for Powerfox diagnostics."""
+
+from __future__ import annotations
+
+from datetime import datetime
+from typing import Any
+
+from powerfox import HeatMeter, PowerMeter, WaterMeter
+
+from homeassistant.core import HomeAssistant
+
+from . import PowerfoxConfigEntry, PowerfoxDataUpdateCoordinator
+
+
+async def async_get_config_entry_diagnostics(
+ hass: HomeAssistant, entry: PowerfoxConfigEntry
+) -> dict[str, Any]:
+ """Return diagnostics for Powerfox config entry."""
+ powerfox_data: list[PowerfoxDataUpdateCoordinator] = entry.runtime_data
+
+ return {
+ "devices": [
+ {
+ **(
+ {
+ "power_meter": {
+ "outdated": coordinator.data.outdated,
+ "timestamp": datetime.strftime(
+ coordinator.data.timestamp, "%Y-%m-%d %H:%M:%S"
+ ),
+ "power": coordinator.data.power,
+ "energy_usage": coordinator.data.energy_usage,
+ "energy_return": coordinator.data.energy_return,
+ "energy_usage_high_tariff": coordinator.data.energy_usage_high_tariff,
+ "energy_usage_low_tariff": coordinator.data.energy_usage_low_tariff,
+ }
+ }
+ if isinstance(coordinator.data, PowerMeter)
+ else {}
+ ),
+ **(
+ {
+ "water_meter": {
+ "outdated": coordinator.data.outdated,
+ "timestamp": datetime.strftime(
+ coordinator.data.timestamp, "%Y-%m-%d %H:%M:%S"
+ ),
+ "cold_water": coordinator.data.cold_water,
+ "warm_water": coordinator.data.warm_water,
+ }
+ }
+ 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/entity.py b/homeassistant/components/powerfox/entity.py
new file mode 100644
index 00000000000..0ab7200ffe8
--- /dev/null
+++ b/homeassistant/components/powerfox/entity.py
@@ -0,0 +1,32 @@
+"""Generic entity for Powerfox."""
+
+from __future__ import annotations
+
+from powerfox import Device
+
+from homeassistant.helpers.device_registry import DeviceInfo
+from homeassistant.helpers.update_coordinator import CoordinatorEntity
+
+from .const import DOMAIN
+from .coordinator import PowerfoxDataUpdateCoordinator
+
+
+class PowerfoxEntity(CoordinatorEntity[PowerfoxDataUpdateCoordinator]):
+ """Base entity for Powerfox."""
+
+ _attr_has_entity_name = True
+
+ def __init__(
+ self,
+ coordinator: PowerfoxDataUpdateCoordinator,
+ device: Device,
+ ) -> None:
+ """Initialize Powerfox entity."""
+ super().__init__(coordinator)
+ self._attr_device_info = DeviceInfo(
+ identifiers={(DOMAIN, device.id)},
+ manufacturer="Powerfox",
+ model=device.type.human_readable,
+ name=device.name,
+ serial_number=device.id,
+ )
diff --git a/homeassistant/components/powerfox/manifest.json b/homeassistant/components/powerfox/manifest.json
new file mode 100644
index 00000000000..bb72d73b5a8
--- /dev/null
+++ b/homeassistant/components/powerfox/manifest.json
@@ -0,0 +1,16 @@
+{
+ "domain": "powerfox",
+ "name": "Powerfox",
+ "codeowners": ["@klaasnicolaas"],
+ "config_flow": true,
+ "documentation": "https://www.home-assistant.io/integrations/powerfox",
+ "iot_class": "cloud_polling",
+ "quality_scale": "silver",
+ "requirements": ["powerfox==1.2.0"],
+ "zeroconf": [
+ {
+ "type": "_http._tcp.local.",
+ "name": "powerfox*"
+ }
+ ]
+}
diff --git a/homeassistant/components/powerfox/quality_scale.yaml b/homeassistant/components/powerfox/quality_scale.yaml
new file mode 100644
index 00000000000..f72d25c3684
--- /dev/null
+++ b/homeassistant/components/powerfox/quality_scale.yaml
@@ -0,0 +1,92 @@
+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
+ action-exceptions:
+ status: exempt
+ comment: |
+ This integration does not provide additional actions.
+ config-entry-unloading: done
+ docs-configuration-parameters:
+ status: exempt
+ comment: |
+ This integration does not have an options flow.
+ docs-installation-parameters: done
+ entity-unavailable: done
+ integration-owner: done
+ log-when-unavailable: done
+ parallel-updates:
+ status: exempt
+ comment: |
+ This integration uses a coordinator to handle updates.
+ reauthentication-flow: done
+ test-coverage: done
+
+ # Gold
+ devices: done
+ diagnostics: done
+ discovery-update-info:
+ status: exempt
+ comment: |
+ This integration is connecting to a cloud service.
+ discovery:
+ status: done
+ comment: |
+ It can find poweropti devices via zeroconf, and will start a normal user flow.
+ docs-data-update: done
+ docs-examples: todo
+ docs-known-limitations: done
+ docs-supported-devices: done
+ docs-supported-functions: done
+ docs-troubleshooting: todo
+ docs-use-cases: done
+ dynamic-devices: todo
+ entity-category: done
+ entity-device-class: done
+ entity-disabled-by-default:
+ status: exempt
+ comment: |
+ This integration does not have any entities that should disabled by default.
+ entity-translations: done
+ exception-translations: done
+ icon-translations:
+ status: exempt
+ comment: |
+ There is no need for icon translations.
+ reconfiguration-flow: done
+ repair-issues:
+ status: exempt
+ comment: |
+ This integration doesn't have any cases where raising an issue is needed.
+ stale-devices: todo
+ # Platinum
+ async-dependency: done
+ inject-websession: done
+ strict-typing: done
diff --git a/homeassistant/components/powerfox/sensor.py b/homeassistant/components/powerfox/sensor.py
new file mode 100644
index 00000000000..6505139fcd9
--- /dev/null
+++ b/homeassistant/components/powerfox/sensor.py
@@ -0,0 +1,189 @@
+"""Sensors for Powerfox integration."""
+
+from __future__ import annotations
+
+from collections.abc import Callable
+from dataclasses import dataclass
+
+from powerfox import Device, HeatMeter, PowerMeter, WaterMeter
+
+from homeassistant.components.sensor import (
+ SensorDeviceClass,
+ SensorEntity,
+ SensorEntityDescription,
+ SensorStateClass,
+)
+from homeassistant.const import UnitOfEnergy, UnitOfPower, UnitOfVolume
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+
+from . import PowerfoxConfigEntry
+from .coordinator import PowerfoxDataUpdateCoordinator
+from .entity import PowerfoxEntity
+
+
+@dataclass(frozen=True, kw_only=True)
+class PowerfoxSensorEntityDescription[T: (PowerMeter, WaterMeter, HeatMeter)](
+ SensorEntityDescription
+):
+ """Describes Poweropti sensor entity."""
+
+ value_fn: Callable[[T], float | int | None]
+
+
+SENSORS_POWER: tuple[PowerfoxSensorEntityDescription[PowerMeter], ...] = (
+ PowerfoxSensorEntityDescription[PowerMeter](
+ key="power",
+ native_unit_of_measurement=UnitOfPower.WATT,
+ device_class=SensorDeviceClass.POWER,
+ state_class=SensorStateClass.MEASUREMENT,
+ value_fn=lambda meter: meter.power,
+ ),
+ PowerfoxSensorEntityDescription[PowerMeter](
+ key="energy_usage",
+ translation_key="energy_usage",
+ native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
+ device_class=SensorDeviceClass.ENERGY,
+ state_class=SensorStateClass.TOTAL_INCREASING,
+ value_fn=lambda meter: meter.energy_usage,
+ ),
+ PowerfoxSensorEntityDescription[PowerMeter](
+ key="energy_usage_low_tariff",
+ translation_key="energy_usage_low_tariff",
+ native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
+ device_class=SensorDeviceClass.ENERGY,
+ state_class=SensorStateClass.TOTAL_INCREASING,
+ value_fn=lambda meter: meter.energy_usage_low_tariff,
+ ),
+ PowerfoxSensorEntityDescription[PowerMeter](
+ key="energy_usage_high_tariff",
+ translation_key="energy_usage_high_tariff",
+ native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
+ device_class=SensorDeviceClass.ENERGY,
+ state_class=SensorStateClass.TOTAL_INCREASING,
+ value_fn=lambda meter: meter.energy_usage_high_tariff,
+ ),
+ PowerfoxSensorEntityDescription[PowerMeter](
+ key="energy_return",
+ translation_key="energy_return",
+ native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
+ device_class=SensorDeviceClass.ENERGY,
+ state_class=SensorStateClass.TOTAL_INCREASING,
+ value_fn=lambda meter: meter.energy_return,
+ ),
+)
+
+
+SENSORS_WATER: tuple[PowerfoxSensorEntityDescription[WaterMeter], ...] = (
+ PowerfoxSensorEntityDescription[WaterMeter](
+ key="cold_water",
+ translation_key="cold_water",
+ native_unit_of_measurement=UnitOfVolume.CUBIC_METERS,
+ device_class=SensorDeviceClass.WATER,
+ state_class=SensorStateClass.TOTAL_INCREASING,
+ value_fn=lambda meter: meter.cold_water,
+ ),
+ PowerfoxSensorEntityDescription[WaterMeter](
+ key="warm_water",
+ translation_key="warm_water",
+ native_unit_of_measurement=UnitOfVolume.CUBIC_METERS,
+ device_class=SensorDeviceClass.WATER,
+ state_class=SensorStateClass.TOTAL_INCREASING,
+ value_fn=lambda meter: meter.warm_water,
+ ),
+)
+
+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,
+ entry: PowerfoxConfigEntry,
+ async_add_entities: AddEntitiesCallback,
+) -> None:
+ """Set up Powerfox sensors based on a config entry."""
+ entities: list[SensorEntity] = []
+ for coordinator in entry.runtime_data:
+ if isinstance(coordinator.data, PowerMeter):
+ entities.extend(
+ PowerfoxSensorEntity(
+ coordinator=coordinator,
+ description=description,
+ device=coordinator.device,
+ )
+ for description in SENSORS_POWER
+ if description.value_fn(coordinator.data) is not None
+ )
+ if isinstance(coordinator.data, WaterMeter):
+ entities.extend(
+ PowerfoxSensorEntity(
+ coordinator=coordinator,
+ description=description,
+ device=coordinator.device,
+ )
+ 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)
+
+
+class PowerfoxSensorEntity(PowerfoxEntity, SensorEntity):
+ """Defines a powerfox power meter sensor."""
+
+ entity_description: PowerfoxSensorEntityDescription
+
+ def __init__(
+ self,
+ coordinator: PowerfoxDataUpdateCoordinator,
+ device: Device,
+ description: PowerfoxSensorEntityDescription,
+ ) -> None:
+ """Initialize Powerfox power meter sensor."""
+ super().__init__(coordinator, device)
+ self.entity_description = description
+ self._attr_unique_id = f"{device.id}_{description.key}"
+
+ @property
+ def native_value(self) -> float | int | None:
+ """Return the state of the entity."""
+ return self.entity_description.value_fn(self.coordinator.data)
diff --git a/homeassistant/components/powerfox/strings.json b/homeassistant/components/powerfox/strings.json
new file mode 100644
index 00000000000..cb068a212c2
--- /dev/null
+++ b/homeassistant/components/powerfox/strings.json
@@ -0,0 +1,82 @@
+{
+ "config": {
+ "step": {
+ "user": {
+ "description": "Connect to your Powerfox account to get information about your energy, heat or water consumption.",
+ "data": {
+ "email": "[%key:common::config_flow::data::email%]",
+ "password": "[%key:common::config_flow::data::password%]"
+ },
+ "data_description": {
+ "email": "The email address of your Powerfox account.",
+ "password": "The password of your Powerfox account."
+ }
+ },
+ "reauth_confirm": {
+ "title": "[%key:common::config_flow::title::reauth%]",
+ "description": "The password for {email} is no longer valid.",
+ "data": {
+ "password": "[%key:common::config_flow::data::password%]"
+ },
+ "data_description": {
+ "password": "[%key:component::powerfox::config::step::user::data_description::password%]"
+ }
+ },
+ "reconfigure": {
+ "title": "Reconfigure your Powerfox account",
+ "description": "Powerfox is already configured. Would you like to reconfigure it?",
+ "data": {
+ "email": "[%key:common::config_flow::data::email%]",
+ "password": "[%key:common::config_flow::data::password%]"
+ },
+ "data_description": {
+ "email": "[%key:component::powerfox::config::step::user::data_description::email%]",
+ "password": "[%key:component::powerfox::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%]"
+ },
+ "abort": {
+ "already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
+ "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
+ "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
+ }
+ },
+ "entity": {
+ "sensor": {
+ "energy_usage": {
+ "name": "Energy usage"
+ },
+ "energy_usage_low_tariff": {
+ "name": "Energy usage low tariff"
+ },
+ "energy_usage_high_tariff": {
+ "name": "Energy usage high tariff"
+ },
+ "energy_return": {
+ "name": "Energy return"
+ },
+ "cold_water": {
+ "name": "Cold water"
+ },
+ "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/config_flow.py b/homeassistant/components/powerwall/config_flow.py
index bacbff63211..0c39392ca19 100644
--- a/homeassistant/components/powerwall/config_flow.py
+++ b/homeassistant/components/powerwall/config_flow.py
@@ -251,8 +251,8 @@ class PowerwallConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle reauth confirmation."""
errors: dict[str, str] | None = {}
description_placeholders: dict[str, str] = {}
+ reauth_entry = self._get_reauth_entry()
if user_input is not None:
- reauth_entry = self._get_reauth_entry()
errors, _, description_placeholders = await self._async_try_connect(
{CONF_IP_ADDRESS: reauth_entry.data[CONF_IP_ADDRESS], **user_input}
)
@@ -261,6 +261,10 @@ class PowerwallConfigFlow(ConfigFlow, domain=DOMAIN):
reauth_entry, data_updates=user_input
)
+ self.context["title_placeholders"] = {
+ "name": reauth_entry.title,
+ "ip_address": reauth_entry.data[CONF_IP_ADDRESS],
+ }
return self.async_show_form(
step_id="reauth_confirm",
data_schema=vol.Schema({vol.Optional(CONF_PASSWORD): str}),
diff --git a/homeassistant/components/powerwall/sensor.py b/homeassistant/components/powerwall/sensor.py
index 9423d65b0fc..28506e2a60c 100644
--- a/homeassistant/components/powerwall/sensor.py
+++ b/homeassistant/components/powerwall/sensor.py
@@ -5,7 +5,7 @@ from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from operator import attrgetter, methodcaller
-from typing import TYPE_CHECKING, Generic, TypeVar
+from typing import TYPE_CHECKING
from tesla_powerwall import GridState, MeterResponse, MeterType
@@ -35,14 +35,12 @@ from .models import BatteryResponse, PowerwallConfigEntry, PowerwallRuntimeData
_METER_DIRECTION_EXPORT = "export"
_METER_DIRECTION_IMPORT = "import"
-_ValueParamT = TypeVar("_ValueParamT")
-_ValueT = TypeVar("_ValueT", bound=float | int | str | None)
+type _ValueType = float | int | str | None
@dataclass(frozen=True, kw_only=True)
-class PowerwallSensorEntityDescription(
- SensorEntityDescription,
- Generic[_ValueParamT, _ValueT],
+class PowerwallSensorEntityDescription[_ValueParamT, _ValueT: _ValueType](
+ SensorEntityDescription
):
"""Describes Powerwall entity."""
@@ -389,7 +387,7 @@ class PowerWallImportSensor(PowerWallEnergyDirectionSensor):
return meter.get_energy_imported()
-class PowerWallBatterySensor(BatteryEntity, SensorEntity, Generic[_ValueT]):
+class PowerWallBatterySensor[_ValueT: _ValueType](BatteryEntity, SensorEntity):
"""Representation of an Powerwall Battery sensor."""
entity_description: PowerwallSensorEntityDescription[BatteryResponse, _ValueT]
diff --git a/homeassistant/components/profiler/manifest.json b/homeassistant/components/profiler/manifest.json
index 9f27ee7f7d0..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",
+ "guppy3==3.1.5",
"objgraph==3.5.0"
],
"single_config_entry": true
diff --git a/homeassistant/components/proliphix/climate.py b/homeassistant/components/proliphix/climate.py
index 18b974800a3..be7d394993a 100644
--- a/homeassistant/components/proliphix/climate.py
+++ b/homeassistant/components/proliphix/climate.py
@@ -61,7 +61,6 @@ class ProliphixThermostat(ClimateEntity):
_attr_precision = PRECISION_TENTHS
_attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE
_attr_temperature_unit = UnitOfTemperature.FAHRENHEIT
- _enable_turn_on_off_backwards_compatibility = False
def __init__(self, pdp):
"""Initialize the thermostat."""
diff --git a/homeassistant/components/proliphix/manifest.json b/homeassistant/components/proliphix/manifest.json
index 2b01d5deb46..9cf0b9b0950 100644
--- a/homeassistant/components/proliphix/manifest.json
+++ b/homeassistant/components/proliphix/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/proliphix",
"iot_class": "local_polling",
"loggers": ["proliphix"],
+ "quality_scale": "legacy",
"requirements": ["proliphix==0.4.1"]
}
diff --git a/homeassistant/components/prometheus/__init__.py b/homeassistant/components/prometheus/__init__.py
index c243bf90dc0..ab012847bba 100644
--- a/homeassistant/components/prometheus/__init__.py
+++ b/homeassistant/components/prometheus/__init__.py
@@ -2,8 +2,9 @@
from __future__ import annotations
+from collections import defaultdict
from collections.abc import Callable
-from contextlib import suppress
+from dataclasses import astuple, dataclass
import logging
import string
from typing import Any, cast
@@ -158,6 +159,22 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool:
return True
+@dataclass(frozen=True, slots=True)
+class MetricNameWithLabelValues:
+ """Class to represent a metric with its label values.
+
+ The prometheus client library doesn't easily allow us to get back the
+ information we put into it. Specifically, it is very expensive to query
+ which label values have been set for metrics.
+
+ This class is used to hold a bit of data we need to efficiently remove
+ labelsets from metrics.
+ """
+
+ metric_name: str
+ label_values: tuple[str, ...]
+
+
class PrometheusMetrics:
"""Model all of the metrics which should be exposed to Prometheus."""
@@ -191,6 +208,9 @@ class PrometheusMetrics:
else:
self.metrics_prefix = ""
self._metrics: dict[str, MetricWrapperBase] = {}
+ self._metrics_by_entity_id: dict[str, set[MetricNameWithLabelValues]] = (
+ defaultdict(set)
+ )
self._climate_units = climate_units
def handle_state_changed_event(self, event: Event[EventStateChangedData]) -> None:
@@ -202,10 +222,12 @@ class PrometheusMetrics:
_LOGGER.debug("Filtered out entity %s", state.entity_id)
return
- if (old_state := event.data.get("old_state")) is not None and (
- old_friendly_name := old_state.attributes.get(ATTR_FRIENDLY_NAME)
+ if (
+ old_state := event.data.get("old_state")
+ ) is not None and old_state.attributes.get(
+ ATTR_FRIENDLY_NAME
) != state.attributes.get(ATTR_FRIENDLY_NAME):
- self._remove_labelsets(old_state.entity_id, old_friendly_name)
+ self._remove_labelsets(old_state.entity_id)
self.handle_state(state)
@@ -215,30 +237,32 @@ class PrometheusMetrics:
_LOGGER.debug("Handling state update for %s", entity_id)
labels = self._labels(state)
- state_change = self._metric(
- "state_change", prometheus_client.Counter, "The number of state changes"
- )
- state_change.labels(**labels).inc()
- entity_available = self._metric(
+ self._metric(
+ "state_change",
+ prometheus_client.Counter,
+ "The number of state changes",
+ labels,
+ ).inc()
+
+ self._metric(
"entity_available",
prometheus_client.Gauge,
"Entity is available (not in the unavailable or unknown state)",
- )
- entity_available.labels(**labels).set(float(state.state not in IGNORED_STATES))
+ labels,
+ ).set(float(state.state not in IGNORED_STATES))
- last_updated_time_seconds = self._metric(
+ self._metric(
"last_updated_time_seconds",
prometheus_client.Gauge,
"The last_updated timestamp",
- )
- last_updated_time_seconds.labels(**labels).set(state.last_updated.timestamp())
+ labels,
+ ).set(state.last_updated.timestamp())
if state.state in IGNORED_STATES:
self._remove_labelsets(
entity_id,
- None,
- {state_change, entity_available, last_updated_time_seconds},
+ {"state_change", "entity_available", "last_updated_time_seconds"},
)
else:
domain, _ = hacore.split_entity_id(entity_id)
@@ -274,67 +298,68 @@ class PrometheusMetrics:
def _remove_labelsets(
self,
entity_id: str,
- friendly_name: str | None = None,
- ignored_metrics: set[MetricWrapperBase] | None = None,
+ ignored_metric_names: set[str] | None = None,
) -> None:
"""Remove labelsets matching the given entity id from all non-ignored metrics."""
- if ignored_metrics is None:
- ignored_metrics = set()
- for metric in list(self._metrics.values()):
- if metric in ignored_metrics:
+ if ignored_metric_names is None:
+ ignored_metric_names = set()
+ metric_set = self._metrics_by_entity_id[entity_id]
+ removed_metrics = set()
+ for metric in metric_set:
+ metric_name, label_values = astuple(metric)
+ if metric_name in ignored_metric_names:
continue
- for sample in cast(list[prometheus_client.Metric], metric.collect())[
- 0
- ].samples:
- if sample.labels["entity"] == entity_id and (
- not friendly_name or sample.labels["friendly_name"] == friendly_name
- ):
- _LOGGER.debug(
- "Removing labelset from %s for entity_id: %s",
- sample.name,
- entity_id,
- )
- with suppress(KeyError):
- metric.remove(*sample.labels.values())
+
+ _LOGGER.debug(
+ "Removing labelset %s from %s for entity_id: %s",
+ label_values,
+ metric_name,
+ entity_id,
+ )
+ removed_metrics.add(metric)
+ self._metrics[metric_name].remove(*label_values)
+ metric_set -= removed_metrics
+ if not metric_set:
+ del self._metrics_by_entity_id[entity_id]
def _handle_attributes(self, state: State) -> None:
for key, value in state.attributes.items():
- metric = self._metric(
+ try:
+ value = float(value)
+ except (ValueError, TypeError):
+ continue
+
+ self._metric(
f"{state.domain}_attr_{key.lower()}",
prometheus_client.Gauge,
f"{key} attribute of {state.domain} entity",
- )
-
- try:
- value = float(value)
- metric.labels(**self._labels(state)).set(value)
- except (ValueError, TypeError):
- pass
+ self._labels(state),
+ ).set(value)
def _metric[_MetricBaseT: MetricWrapperBase](
self,
- metric: str,
+ metric_name: str,
factory: type[_MetricBaseT],
documentation: str,
- extra_labels: list[str] | None = None,
+ labels: dict[str, str],
) -> _MetricBaseT:
- labels = ["entity", "friendly_name", "domain"]
- if extra_labels is not None:
- labels.extend(extra_labels)
-
try:
- return cast(_MetricBaseT, self._metrics[metric])
+ metric = cast(_MetricBaseT, self._metrics[metric_name])
except KeyError:
full_metric_name = self._sanitize_metric_name(
- f"{self.metrics_prefix}{metric}"
+ f"{self.metrics_prefix}{metric_name}"
)
- self._metrics[metric] = factory(
+ self._metrics[metric_name] = factory(
full_metric_name,
documentation,
- labels,
+ labels.keys(),
registry=prometheus_client.REGISTRY,
)
- return cast(_MetricBaseT, self._metrics[metric])
+ metric = cast(_MetricBaseT, self._metrics[metric_name])
+ self._metrics_by_entity_id[labels["entity"]].add(
+ MetricNameWithLabelValues(metric_name, tuple(labels.values()))
+ )
+ return metric.labels(**labels)
@staticmethod
def _sanitize_metric_name(metric: str) -> str:
@@ -356,67 +381,90 @@ class PrometheusMetrics:
return value
@staticmethod
- def _labels(state: State) -> dict[str, Any]:
- return {
+ def _labels(
+ state: State,
+ extra_labels: dict[str, str] | None = None,
+ ) -> dict[str, Any]:
+ if extra_labels is None:
+ extra_labels = {}
+ labels = {
"entity": state.entity_id,
"domain": state.domain,
"friendly_name": state.attributes.get(ATTR_FRIENDLY_NAME),
}
+ if not labels.keys().isdisjoint(extra_labels.keys()):
+ conflicting_keys = labels.keys() & extra_labels.keys()
+ raise ValueError(
+ f"extra_labels contains conflicting keys: {conflicting_keys}"
+ )
+ return labels | extra_labels
def _battery(self, state: State) -> None:
- if (battery_level := state.attributes.get(ATTR_BATTERY_LEVEL)) is not None:
- metric = self._metric(
- "battery_level_percent",
- prometheus_client.Gauge,
- "Battery level as a percentage of its capacity",
- )
- try:
- value = float(battery_level)
- metric.labels(**self._labels(state)).set(value)
- except ValueError:
- pass
+ if (battery_level := state.attributes.get(ATTR_BATTERY_LEVEL)) is None:
+ return
+
+ try:
+ value = float(battery_level)
+ except ValueError:
+ return
+
+ self._metric(
+ "battery_level_percent",
+ prometheus_client.Gauge,
+ "Battery level as a percentage of its capacity",
+ self._labels(state),
+ ).set(value)
def _handle_binary_sensor(self, state: State) -> None:
- metric = self._metric(
+ if (value := self.state_as_number(state)) is None:
+ return
+
+ self._metric(
"binary_sensor_state",
prometheus_client.Gauge,
"State of the binary sensor (0/1)",
- )
- if (value := self.state_as_number(state)) is not None:
- metric.labels(**self._labels(state)).set(value)
+ self._labels(state),
+ ).set(value)
def _handle_input_boolean(self, state: State) -> None:
- metric = self._metric(
+ if (value := self.state_as_number(state)) is None:
+ return
+
+ self._metric(
"input_boolean_state",
prometheus_client.Gauge,
"State of the input boolean (0/1)",
- )
- if (value := self.state_as_number(state)) is not None:
- metric.labels(**self._labels(state)).set(value)
+ self._labels(state),
+ ).set(value)
def _numeric_handler(self, state: State, domain: str, title: str) -> None:
+ if (value := self.state_as_number(state)) is None:
+ return
+
if unit := self._unit_string(state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)):
metric = self._metric(
f"{domain}_state_{unit}",
prometheus_client.Gauge,
f"State of the {title} measured in {unit}",
+ self._labels(state),
)
else:
metric = self._metric(
f"{domain}_state",
prometheus_client.Gauge,
f"State of the {title}",
+ self._labels(state),
)
- if (value := self.state_as_number(state)) is not None:
- if (
- state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
- == UnitOfTemperature.FAHRENHEIT
- ):
- value = TemperatureConverter.convert(
- value, UnitOfTemperature.FAHRENHEIT, UnitOfTemperature.CELSIUS
- )
- metric.labels(**self._labels(state)).set(value)
+ if (
+ state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
+ == UnitOfTemperature.FAHRENHEIT
+ ):
+ value = TemperatureConverter.convert(
+ value, UnitOfTemperature.FAHRENHEIT, UnitOfTemperature.CELSIUS
+ )
+
+ metric.set(value)
def _handle_input_number(self, state: State) -> None:
self._numeric_handler(state, "input_number", "input number")
@@ -425,88 +473,99 @@ class PrometheusMetrics:
self._numeric_handler(state, "number", "number")
def _handle_device_tracker(self, state: State) -> None:
- metric = self._metric(
+ if (value := self.state_as_number(state)) is None:
+ return
+
+ self._metric(
"device_tracker_state",
prometheus_client.Gauge,
"State of the device tracker (0/1)",
- )
- if (value := self.state_as_number(state)) is not None:
- metric.labels(**self._labels(state)).set(value)
+ self._labels(state),
+ ).set(value)
def _handle_person(self, state: State) -> None:
- metric = self._metric(
- "person_state", prometheus_client.Gauge, "State of the person (0/1)"
- )
- if (value := self.state_as_number(state)) is not None:
- metric.labels(**self._labels(state)).set(value)
+ if (value := self.state_as_number(state)) is None:
+ return
+
+ self._metric(
+ "person_state",
+ prometheus_client.Gauge,
+ "State of the person (0/1)",
+ self._labels(state),
+ ).set(value)
def _handle_cover(self, state: State) -> None:
- metric = self._metric(
- "cover_state",
- prometheus_client.Gauge,
- "State of the cover (0/1)",
- ["state"],
- )
-
cover_states = [STATE_CLOSED, STATE_CLOSING, STATE_OPEN, STATE_OPENING]
for cover_state in cover_states:
- metric.labels(**dict(self._labels(state), state=cover_state)).set(
- float(cover_state == state.state)
+ metric = self._metric(
+ "cover_state",
+ prometheus_client.Gauge,
+ "State of the cover (0/1)",
+ self._labels(state, {"state": cover_state}),
)
+ metric.set(float(cover_state == state.state))
position = state.attributes.get(ATTR_CURRENT_POSITION)
if position is not None:
- position_metric = self._metric(
+ self._metric(
"cover_position",
prometheus_client.Gauge,
"Position of the cover (0-100)",
- )
- position_metric.labels(**self._labels(state)).set(float(position))
+ self._labels(state),
+ ).set(float(position))
tilt_position = state.attributes.get(ATTR_CURRENT_TILT_POSITION)
if tilt_position is not None:
- tilt_position_metric = self._metric(
+ self._metric(
"cover_tilt_position",
prometheus_client.Gauge,
"Tilt Position of the cover (0-100)",
- )
- tilt_position_metric.labels(**self._labels(state)).set(float(tilt_position))
+ self._labels(state),
+ ).set(float(tilt_position))
def _handle_light(self, state: State) -> None:
- metric = self._metric(
+ if (value := self.state_as_number(state)) is None:
+ return
+
+ brightness = state.attributes.get(ATTR_BRIGHTNESS)
+ if state.state == STATE_ON and brightness is not None:
+ value = float(brightness) / 255.0
+ value = value * 100
+
+ self._metric(
"light_brightness_percent",
prometheus_client.Gauge,
"Light brightness percentage (0..100)",
- )
-
- if (value := self.state_as_number(state)) is not None:
- brightness = state.attributes.get(ATTR_BRIGHTNESS)
- if state.state == STATE_ON and brightness is not None:
- value = float(brightness) / 255.0
- value = value * 100
- metric.labels(**self._labels(state)).set(value)
+ self._labels(state),
+ ).set(value)
def _handle_lock(self, state: State) -> None:
- metric = self._metric(
- "lock_state", prometheus_client.Gauge, "State of the lock (0/1)"
- )
- if (value := self.state_as_number(state)) is not None:
- metric.labels(**self._labels(state)).set(value)
+ if (value := self.state_as_number(state)) is None:
+ return
+
+ self._metric(
+ "lock_state",
+ prometheus_client.Gauge,
+ "State of the lock (0/1)",
+ self._labels(state),
+ ).set(value)
def _handle_climate_temp(
self, state: State, attr: str, metric_name: str, metric_description: str
) -> None:
- if (temp := state.attributes.get(attr)) is not None:
- if self._climate_units == UnitOfTemperature.FAHRENHEIT:
- temp = TemperatureConverter.convert(
- temp, UnitOfTemperature.FAHRENHEIT, UnitOfTemperature.CELSIUS
- )
- metric = self._metric(
- metric_name,
- prometheus_client.Gauge,
- metric_description,
+ if (temp := state.attributes.get(attr)) is None:
+ return
+
+ if self._climate_units == UnitOfTemperature.FAHRENHEIT:
+ temp = TemperatureConverter.convert(
+ temp, UnitOfTemperature.FAHRENHEIT, UnitOfTemperature.CELSIUS
)
- metric.labels(**self._labels(state)).set(temp)
+ self._metric(
+ metric_name,
+ prometheus_client.Gauge,
+ metric_description,
+ self._labels(state),
+ ).set(temp)
def _handle_climate(self, state: State) -> None:
self._handle_climate_temp(
@@ -535,90 +594,75 @@ class PrometheusMetrics:
)
if current_action := state.attributes.get(ATTR_HVAC_ACTION):
- metric = self._metric(
- "climate_action",
- prometheus_client.Gauge,
- "HVAC action",
- ["action"],
- )
for action in HVACAction:
- metric.labels(**dict(self._labels(state), action=action.value)).set(
- float(action == current_action)
- )
+ self._metric(
+ "climate_action",
+ prometheus_client.Gauge,
+ "HVAC action",
+ self._labels(state, {"action": action.value}),
+ ).set(float(action == current_action))
current_mode = state.state
available_modes = state.attributes.get(ATTR_HVAC_MODES)
if current_mode and available_modes:
- metric = self._metric(
- "climate_mode",
- prometheus_client.Gauge,
- "HVAC mode",
- ["mode"],
- )
for mode in available_modes:
- metric.labels(**dict(self._labels(state), mode=mode)).set(
- float(mode == current_mode)
- )
+ self._metric(
+ "climate_mode",
+ prometheus_client.Gauge,
+ "HVAC mode",
+ self._labels(state, {"mode": mode}),
+ ).set(float(mode == current_mode))
preset_mode = state.attributes.get(ATTR_PRESET_MODE)
available_preset_modes = state.attributes.get(ATTR_PRESET_MODES)
if preset_mode and available_preset_modes:
- preset_metric = self._metric(
- "climate_preset_mode",
- prometheus_client.Gauge,
- "Preset mode enum",
- ["mode"],
- )
for mode in available_preset_modes:
- preset_metric.labels(**dict(self._labels(state), mode=mode)).set(
- float(mode == preset_mode)
- )
+ self._metric(
+ "climate_preset_mode",
+ prometheus_client.Gauge,
+ "Preset mode enum",
+ self._labels(state, {"mode": mode}),
+ ).set(float(mode == preset_mode))
fan_mode = state.attributes.get(ATTR_FAN_MODE)
available_fan_modes = state.attributes.get(ATTR_FAN_MODES)
if fan_mode and available_fan_modes:
- fan_mode_metric = self._metric(
- "climate_fan_mode",
- prometheus_client.Gauge,
- "Fan mode enum",
- ["mode"],
- )
for mode in available_fan_modes:
- fan_mode_metric.labels(**dict(self._labels(state), mode=mode)).set(
- float(mode == fan_mode)
- )
+ self._metric(
+ "climate_fan_mode",
+ prometheus_client.Gauge,
+ "Fan mode enum",
+ self._labels(state, {"mode": mode}),
+ ).set(float(mode == fan_mode))
def _handle_humidifier(self, state: State) -> None:
humidifier_target_humidity_percent = state.attributes.get(ATTR_HUMIDITY)
if humidifier_target_humidity_percent:
- metric = self._metric(
+ self._metric(
"humidifier_target_humidity_percent",
prometheus_client.Gauge,
"Target Relative Humidity",
- )
- metric.labels(**self._labels(state)).set(humidifier_target_humidity_percent)
+ self._labels(state),
+ ).set(humidifier_target_humidity_percent)
- metric = self._metric(
- "humidifier_state",
- prometheus_client.Gauge,
- "State of the humidifier (0/1)",
- )
if (value := self.state_as_number(state)) is not None:
- metric.labels(**self._labels(state)).set(value)
+ self._metric(
+ "humidifier_state",
+ prometheus_client.Gauge,
+ "State of the humidifier (0/1)",
+ self._labels(state),
+ ).set(value)
current_mode = state.attributes.get(ATTR_MODE)
available_modes = state.attributes.get(ATTR_AVAILABLE_MODES)
if current_mode and available_modes:
- metric = self._metric(
- "humidifier_mode",
- prometheus_client.Gauge,
- "Humidifier Mode",
- ["mode"],
- )
for mode in available_modes:
- metric.labels(**dict(self._labels(state), mode=mode)).set(
- float(mode == current_mode)
- )
+ self._metric(
+ "humidifier_mode",
+ prometheus_client.Gauge,
+ "Humidifier Mode",
+ self._labels(state, {"mode": mode}),
+ ).set(float(mode == current_mode))
def _handle_sensor(self, state: State) -> None:
unit = self._unit_string(state.attributes.get(ATTR_UNIT_OF_MEASUREMENT))
@@ -628,22 +672,24 @@ class PrometheusMetrics:
if metric is not None:
break
- if metric is not None:
+ if metric is not None and (value := self.state_as_number(state)) is not None:
documentation = "State of the sensor"
if unit:
documentation = f"Sensor data measured in {unit}"
- _metric = self._metric(metric, prometheus_client.Gauge, documentation)
-
- if (value := self.state_as_number(state)) is not None:
- if (
- state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
- == UnitOfTemperature.FAHRENHEIT
- ):
- value = TemperatureConverter.convert(
- value, UnitOfTemperature.FAHRENHEIT, UnitOfTemperature.CELSIUS
- )
- _metric.labels(**self._labels(state)).set(value)
+ if (
+ state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
+ == UnitOfTemperature.FAHRENHEIT
+ ):
+ value = TemperatureConverter.convert(
+ value, UnitOfTemperature.FAHRENHEIT, UnitOfTemperature.CELSIUS
+ )
+ self._metric(
+ metric,
+ prometheus_client.Gauge,
+ documentation,
+ self._labels(state),
+ ).set(value)
self._battery(state)
@@ -702,114 +748,107 @@ class PrometheusMetrics:
return units.get(unit, default)
def _handle_switch(self, state: State) -> None:
- metric = self._metric(
- "switch_state", prometheus_client.Gauge, "State of the switch (0/1)"
- )
-
if (value := self.state_as_number(state)) is not None:
- metric.labels(**self._labels(state)).set(value)
+ self._metric(
+ "switch_state",
+ prometheus_client.Gauge,
+ "State of the switch (0/1)",
+ self._labels(state),
+ ).set(value)
self._handle_attributes(state)
def _handle_fan(self, state: State) -> None:
- metric = self._metric(
- "fan_state", prometheus_client.Gauge, "State of the fan (0/1)"
- )
-
if (value := self.state_as_number(state)) is not None:
- metric.labels(**self._labels(state)).set(value)
+ self._metric(
+ "fan_state",
+ prometheus_client.Gauge,
+ "State of the fan (0/1)",
+ self._labels(state),
+ ).set(value)
fan_speed_percent = state.attributes.get(ATTR_PERCENTAGE)
if fan_speed_percent is not None:
- fan_speed_metric = self._metric(
+ self._metric(
"fan_speed_percent",
prometheus_client.Gauge,
"Fan speed percent (0-100)",
- )
- fan_speed_metric.labels(**self._labels(state)).set(float(fan_speed_percent))
+ self._labels(state),
+ ).set(float(fan_speed_percent))
fan_is_oscillating = state.attributes.get(ATTR_OSCILLATING)
if fan_is_oscillating is not None:
- fan_oscillating_metric = self._metric(
+ self._metric(
"fan_is_oscillating",
prometheus_client.Gauge,
"Whether the fan is oscillating (0/1)",
- )
- fan_oscillating_metric.labels(**self._labels(state)).set(
- float(fan_is_oscillating)
- )
+ self._labels(state),
+ ).set(float(fan_is_oscillating))
fan_preset_mode = state.attributes.get(ATTR_PRESET_MODE)
available_modes = state.attributes.get(ATTR_PRESET_MODES)
if fan_preset_mode and available_modes:
- fan_preset_metric = self._metric(
- "fan_preset_mode",
- prometheus_client.Gauge,
- "Fan preset mode enum",
- ["mode"],
- )
for mode in available_modes:
- fan_preset_metric.labels(**dict(self._labels(state), mode=mode)).set(
- float(mode == fan_preset_mode)
- )
+ self._metric(
+ "fan_preset_mode",
+ prometheus_client.Gauge,
+ "Fan preset mode enum",
+ self._labels(state, {"mode": mode}),
+ ).set(float(mode == fan_preset_mode))
fan_direction = state.attributes.get(ATTR_DIRECTION)
- if fan_direction is not None:
- fan_direction_metric = self._metric(
+ if fan_direction in {DIRECTION_FORWARD, DIRECTION_REVERSE}:
+ self._metric(
"fan_direction_reversed",
prometheus_client.Gauge,
"Fan direction reversed (bool)",
- )
- if fan_direction == DIRECTION_FORWARD:
- fan_direction_metric.labels(**self._labels(state)).set(0)
- elif fan_direction == DIRECTION_REVERSE:
- fan_direction_metric.labels(**self._labels(state)).set(1)
+ self._labels(state),
+ ).set(float(fan_direction == DIRECTION_REVERSE))
def _handle_zwave(self, state: State) -> None:
self._battery(state)
def _handle_automation(self, state: State) -> None:
- metric = self._metric(
+ self._metric(
"automation_triggered_count",
prometheus_client.Counter,
"Count of times an automation has been triggered",
- )
-
- metric.labels(**self._labels(state)).inc()
+ self._labels(state),
+ ).inc()
def _handle_counter(self, state: State) -> None:
- metric = self._metric(
+ if (value := self.state_as_number(state)) is None:
+ return
+
+ self._metric(
"counter_value",
prometheus_client.Gauge,
"Value of counter entities",
- )
- if (value := self.state_as_number(state)) is not None:
- metric.labels(**self._labels(state)).set(value)
+ self._labels(state),
+ ).set(value)
def _handle_update(self, state: State) -> None:
- metric = self._metric(
+ if (value := self.state_as_number(state)) is None:
+ return
+
+ self._metric(
"update_state",
prometheus_client.Gauge,
"Update state, indicating if an update is available (0/1)",
- )
- if (value := self.state_as_number(state)) is not None:
- metric.labels(**self._labels(state)).set(value)
+ self._labels(state),
+ ).set(value)
def _handle_alarm_control_panel(self, state: State) -> None:
current_state = state.state
if current_state:
- metric = self._metric(
- "alarm_control_panel_state",
- prometheus_client.Gauge,
- "State of the alarm control panel (0/1)",
- ["state"],
- )
-
for alarm_state in AlarmControlPanelState:
- metric.labels(**dict(self._labels(state), state=alarm_state.value)).set(
- float(alarm_state.value == current_state)
- )
+ self._metric(
+ "alarm_control_panel_state",
+ prometheus_client.Gauge,
+ "State of the alarm control panel (0/1)",
+ self._labels(state, {"state": alarm_state.value}),
+ ).set(float(alarm_state.value == current_state))
class PrometheusView(HomeAssistantView):
diff --git a/homeassistant/components/prometheus/manifest.json b/homeassistant/components/prometheus/manifest.json
index 8c43be8539d..e747226074c 100644
--- a/homeassistant/components/prometheus/manifest.json
+++ b/homeassistant/components/prometheus/manifest.json
@@ -6,5 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/prometheus",
"iot_class": "assumed_state",
"loggers": ["prometheus_client"],
+ "quality_scale": "legacy",
"requirements": ["prometheus-client==0.21.0"]
}
diff --git a/homeassistant/components/prowl/manifest.json b/homeassistant/components/prowl/manifest.json
index 50decb3f046..049d95fb94c 100644
--- a/homeassistant/components/prowl/manifest.json
+++ b/homeassistant/components/prowl/manifest.json
@@ -3,5 +3,6 @@
"name": "Prowl",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/prowl",
- "iot_class": "cloud_push"
+ "iot_class": "cloud_push",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/proxmoxve/manifest.json b/homeassistant/components/proxmoxve/manifest.json
index 8cf3bc7932d..45ead1330e2 100644
--- a/homeassistant/components/proxmoxve/manifest.json
+++ b/homeassistant/components/proxmoxve/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/proxmoxve",
"iot_class": "local_polling",
"loggers": ["proxmoxer"],
+ "quality_scale": "legacy",
"requirements": ["proxmoxer==2.0.1"]
}
diff --git a/homeassistant/components/proxy/manifest.json b/homeassistant/components/proxy/manifest.json
index 1e70c4d3e10..6925b9e2133 100644
--- a/homeassistant/components/proxy/manifest.json
+++ b/homeassistant/components/proxy/manifest.json
@@ -3,5 +3,6 @@
"name": "Camera Proxy",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/proxy",
- "requirements": ["Pillow==10.4.0"]
+ "quality_scale": "legacy",
+ "requirements": ["Pillow==11.1.0"]
}
diff --git a/homeassistant/components/ps4/strings.json b/homeassistant/components/ps4/strings.json
index 6b1d4cd690b..778fa0215fb 100644
--- a/homeassistant/components/ps4/strings.json
+++ b/homeassistant/components/ps4/strings.json
@@ -21,7 +21,7 @@
"ip_address": "[%key:common::config_flow::data::ip%]"
},
"data_description": {
- "code": "On your PlayStation 4 console, go to **Settings**. Then, go to **Mobile App Connection Settings** and select **Add Device** to get the pin."
+ "code": "On your PlayStation 4 console, go to **Settings**. Then, go to **Mobile App Connection Settings** and select **Add Device** to get the PIN."
}
}
},
diff --git a/homeassistant/components/pulseaudio_loopback/manifest.json b/homeassistant/components/pulseaudio_loopback/manifest.json
index a67dc614c50..90666d18997 100644
--- a/homeassistant/components/pulseaudio_loopback/manifest.json
+++ b/homeassistant/components/pulseaudio_loopback/manifest.json
@@ -4,5 +4,6 @@
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/pulseaudio_loopback",
"iot_class": "local_polling",
+ "quality_scale": "legacy",
"requirements": ["pulsectl==23.5.2"]
}
diff --git a/homeassistant/components/pure_energie/__init__.py b/homeassistant/components/pure_energie/__init__.py
index 459dc5c055c..4de1ce02810 100644
--- a/homeassistant/components/pure_energie/__init__.py
+++ b/homeassistant/components/pure_energie/__init__.py
@@ -7,13 +7,14 @@ from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
-from .const import DOMAIN
from .coordinator import PureEnergieDataUpdateCoordinator
-PLATFORMS = [Platform.SENSOR]
+PLATFORMS: list[Platform] = [Platform.SENSOR]
+
+type PureEnergieConfigEntry = ConfigEntry[PureEnergieDataUpdateCoordinator]
-async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_setup_entry(hass: HomeAssistant, entry: PureEnergieConfigEntry) -> bool:
"""Set up Pure Energie from a config entry."""
coordinator = PureEnergieDataUpdateCoordinator(hass)
@@ -23,14 +24,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
await coordinator.gridnet.close()
raise
- 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: PureEnergieConfigEntry
+) -> bool:
"""Unload Pure Energie 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/pure_energie/diagnostics.py b/homeassistant/components/pure_energie/diagnostics.py
index 6e2b8ee7a35..de9134129ed 100644
--- a/homeassistant/components/pure_energie/diagnostics.py
+++ b/homeassistant/components/pure_energie/diagnostics.py
@@ -6,12 +6,10 @@ from dataclasses import asdict
from typing import Any
from homeassistant.components.diagnostics import async_redact_data
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
-from .const import DOMAIN
-from .coordinator import PureEnergieDataUpdateCoordinator
+from . import PureEnergieConfigEntry
TO_REDACT = {
CONF_HOST,
@@ -20,18 +18,18 @@ TO_REDACT = {
async def async_get_config_entry_diagnostics(
- hass: HomeAssistant, entry: ConfigEntry
+ hass: HomeAssistant, entry: PureEnergieConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
- coordinator: PureEnergieDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
-
return {
"entry": {
"title": entry.title,
"data": async_redact_data(entry.data, TO_REDACT),
},
"data": {
- "device": async_redact_data(asdict(coordinator.data.device), TO_REDACT),
- "smartbridge": asdict(coordinator.data.smartbridge),
+ "device": async_redact_data(
+ asdict(entry.runtime_data.data.device), TO_REDACT
+ ),
+ "smartbridge": asdict(entry.runtime_data.data.smartbridge),
},
}
diff --git a/homeassistant/components/pure_energie/manifest.json b/homeassistant/components/pure_energie/manifest.json
index ff52ec0ecf9..9efb1734f84 100644
--- a/homeassistant/components/pure_energie/manifest.json
+++ b/homeassistant/components/pure_energie/manifest.json
@@ -5,7 +5,6 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/pure_energie",
"iot_class": "local_polling",
- "quality_scale": "platinum",
"requirements": ["gridnet==5.0.1"],
"zeroconf": [
{
diff --git a/homeassistant/components/pure_energie/sensor.py b/homeassistant/components/pure_energie/sensor.py
index 85f4672a618..468858f117f 100644
--- a/homeassistant/components/pure_energie/sensor.py
+++ b/homeassistant/components/pure_energie/sensor.py
@@ -12,13 +12,13 @@ from homeassistant.components.sensor import (
SensorEntityDescription,
SensorStateClass,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, UnitOfEnergy, UnitOfPower
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 PureEnergieConfigEntry
from .const import DOMAIN
from .coordinator import PureEnergieData, PureEnergieDataUpdateCoordinator
@@ -59,12 +59,13 @@ SENSORS: tuple[PureEnergieSensorEntityDescription, ...] = (
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: PureEnergieConfigEntry,
+ async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Pure Energie Sensors based on a config entry."""
async_add_entities(
PureEnergieSensorEntity(
- coordinator=hass.data[DOMAIN][entry.entry_id],
description=description,
entry=entry,
)
@@ -83,21 +84,22 @@ class PureEnergieSensorEntity(
def __init__(
self,
*,
- coordinator: PureEnergieDataUpdateCoordinator,
description: PureEnergieSensorEntityDescription,
- entry: ConfigEntry,
+ entry: PureEnergieConfigEntry,
) -> None:
"""Initialize Pure Energie sensor."""
- super().__init__(coordinator=coordinator)
+ super().__init__(coordinator=entry.runtime_data)
self.entity_id = f"{SENSOR_DOMAIN}.pem_{description.key}"
self.entity_description = description
- self._attr_unique_id = f"{coordinator.data.device.n2g_id}_{description.key}"
+ self._attr_unique_id = (
+ f"{entry.runtime_data.data.device.n2g_id}_{description.key}"
+ )
self._attr_device_info = DeviceInfo(
- identifiers={(DOMAIN, coordinator.data.device.n2g_id)},
- configuration_url=f"http://{coordinator.config_entry.data[CONF_HOST]}",
- sw_version=coordinator.data.device.firmware,
- manufacturer=coordinator.data.device.manufacturer,
- model=coordinator.data.device.model,
+ identifiers={(DOMAIN, entry.runtime_data.data.device.n2g_id)},
+ configuration_url=f"http://{entry.runtime_data.config_entry.data[CONF_HOST]}",
+ sw_version=entry.runtime_data.data.device.firmware,
+ manufacturer=entry.runtime_data.data.device.manufacturer,
+ model=entry.runtime_data.data.device.model,
name=entry.title,
)
diff --git a/homeassistant/components/purpleair/diagnostics.py b/homeassistant/components/purpleair/diagnostics.py
index a3b3af857fb..f7c44b7e9b2 100644
--- a/homeassistant/components/purpleair/diagnostics.py
+++ b/homeassistant/components/purpleair/diagnostics.py
@@ -37,7 +37,7 @@ async def async_get_config_entry_diagnostics(
return async_redact_data(
{
"entry": entry.as_dict(),
- "data": coordinator.data.dict(),
+ "data": coordinator.data.model_dump(),
},
TO_REDACT,
)
diff --git a/homeassistant/components/purpleair/manifest.json b/homeassistant/components/purpleair/manifest.json
index cf74365d6d8..87cb375c347 100644
--- a/homeassistant/components/purpleair/manifest.json
+++ b/homeassistant/components/purpleair/manifest.json
@@ -5,5 +5,5 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/purpleair",
"iot_class": "cloud_polling",
- "requirements": ["aiopurpleair==2022.12.1"]
+ "requirements": ["aiopurpleair==2023.12.0"]
}
diff --git a/homeassistant/components/push/manifest.json b/homeassistant/components/push/manifest.json
index 900ac25edbf..81cb2dce00c 100644
--- a/homeassistant/components/push/manifest.json
+++ b/homeassistant/components/push/manifest.json
@@ -4,5 +4,6 @@
"codeowners": ["@dgomes"],
"dependencies": ["webhook"],
"documentation": "https://www.home-assistant.io/integrations/push",
- "iot_class": "local_push"
+ "iot_class": "local_push",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/pushsafer/manifest.json b/homeassistant/components/pushsafer/manifest.json
index e9018e2a2ba..8b4ec94b9a5 100644
--- a/homeassistant/components/pushsafer/manifest.json
+++ b/homeassistant/components/pushsafer/manifest.json
@@ -3,5 +3,6 @@
"name": "Pushsafer",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/pushsafer",
- "iot_class": "cloud_push"
+ "iot_class": "cloud_push",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/pvoutput/manifest.json b/homeassistant/components/pvoutput/manifest.json
index 61bd6fd6164..9dbdad53bcb 100644
--- a/homeassistant/components/pvoutput/manifest.json
+++ b/homeassistant/components/pvoutput/manifest.json
@@ -6,6 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/pvoutput",
"integration_type": "device",
"iot_class": "cloud_polling",
- "quality_scale": "platinum",
- "requirements": ["pvo==2.1.1"]
+ "requirements": ["pvo==2.2.0"]
}
diff --git a/homeassistant/components/pvpc_hourly_pricing/manifest.json b/homeassistant/components/pvpc_hourly_pricing/manifest.json
index 8db978135f6..ccddbece7e4 100644
--- a/homeassistant/components/pvpc_hourly_pricing/manifest.json
+++ b/homeassistant/components/pvpc_hourly_pricing/manifest.json
@@ -6,6 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/pvpc_hourly_pricing",
"iot_class": "cloud_polling",
"loggers": ["aiopvpc"],
- "quality_scale": "platinum",
"requirements": ["aiopvpc==4.2.2"]
}
diff --git a/homeassistant/components/pyload/config_flow.py b/homeassistant/components/pyload/config_flow.py
index 3e6cbd33bb3..c8d08f997f9 100644
--- a/homeassistant/components/pyload/config_flow.py
+++ b/homeassistant/components/pyload/config_flow.py
@@ -30,7 +30,7 @@ from homeassistant.helpers.selector import (
TextSelectorType,
)
-from .const import DEFAULT_HOST, DEFAULT_NAME, DEFAULT_PORT, DOMAIN
+from .const import DEFAULT_NAME, DEFAULT_PORT, DOMAIN
_LOGGER = logging.getLogger(__name__)
@@ -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/manifest.json b/homeassistant/components/pyload/manifest.json
index 788cdd1eb05..e21167cf10b 100644
--- a/homeassistant/components/pyload/manifest.json
+++ b/homeassistant/components/pyload/manifest.json
@@ -7,6 +7,5 @@
"integration_type": "service",
"iot_class": "local_polling",
"loggers": ["pyloadapi"],
- "quality_scale": "platinum",
"requirements": ["PyLoadAPI==1.3.2"]
}
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 70e9c5b0d29..f9e6a994406 100644
--- a/homeassistant/components/python_script/__init__.py
+++ b/homeassistant/components/python_script/__init__.py
@@ -1,5 +1,6 @@
"""Component to allow running Python scripts."""
+from collections.abc import Callable, Mapping, Sequence
import datetime
import glob
import logging
@@ -7,6 +8,7 @@ from numbers import Number
import operator
import os
import time
+import types
from typing import Any
from RestrictedPython import (
@@ -167,6 +169,20 @@ IOPERATOR_TO_OPERATOR = {
}
+def guarded_import(
+ name: str,
+ globals: Mapping[str, object] | None = None,
+ locals: Mapping[str, object] | None = None,
+ fromlist: Sequence[str] = (),
+ level: int = 0,
+) -> types.ModuleType:
+ """Guard imports."""
+ # Allow import of _strptime needed by datetime.datetime.strptime
+ if name == "_strptime":
+ return __import__(name, globals, locals, fromlist, level)
+ raise ImportError(f"Not allowed to import {name}")
+
+
def guarded_inplacevar(op: str, target: Any, operand: Any) -> Any:
"""Implement augmented-assign (+=, -=, etc.) operators for restricted code.
@@ -181,7 +197,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)
@@ -191,7 +212,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)
@@ -207,7 +234,7 @@ 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")
@@ -232,6 +259,7 @@ def execute(hass, filename, source, data=None, return_response=False):
return getattr(obj, name, default)
extra_builtins = {
+ "__import__": guarded_import,
"datetime": datetime,
"sorted": sorted,
"time": TimeWrapper(),
@@ -299,10 +327,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")
@@ -313,7 +341,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
@@ -323,12 +351,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/qbittorrent/sensor.py b/homeassistant/components/qbittorrent/sensor.py
index abc23f39975..67eb856bb83 100644
--- a/homeassistant/components/qbittorrent/sensor.py
+++ b/homeassistant/components/qbittorrent/sensor.py
@@ -100,13 +100,11 @@ SENSOR_TYPES: tuple[QBittorrentSensorEntityDescription, ...] = (
QBittorrentSensorEntityDescription(
key=SENSOR_TYPE_ALL_TORRENTS,
translation_key="all_torrents",
- native_unit_of_measurement="torrents",
value_fn=lambda coordinator: count_torrents_in_states(coordinator, []),
),
QBittorrentSensorEntityDescription(
key=SENSOR_TYPE_ACTIVE_TORRENTS,
translation_key="active_torrents",
- native_unit_of_measurement="torrents",
value_fn=lambda coordinator: count_torrents_in_states(
coordinator, ["downloading", "uploading"]
),
@@ -114,7 +112,6 @@ SENSOR_TYPES: tuple[QBittorrentSensorEntityDescription, ...] = (
QBittorrentSensorEntityDescription(
key=SENSOR_TYPE_INACTIVE_TORRENTS,
translation_key="inactive_torrents",
- native_unit_of_measurement="torrents",
value_fn=lambda coordinator: count_torrents_in_states(
coordinator, ["stalledDL", "stalledUP"]
),
@@ -122,7 +119,6 @@ SENSOR_TYPES: tuple[QBittorrentSensorEntityDescription, ...] = (
QBittorrentSensorEntityDescription(
key=SENSOR_TYPE_PAUSED_TORRENTS,
translation_key="paused_torrents",
- native_unit_of_measurement="torrents",
value_fn=lambda coordinator: count_torrents_in_states(
coordinator, ["pausedDL", "pausedUP"]
),
diff --git a/homeassistant/components/qbittorrent/strings.json b/homeassistant/components/qbittorrent/strings.json
index 88015dad5c3..9c9ee371737 100644
--- a/homeassistant/components/qbittorrent/strings.json
+++ b/homeassistant/components/qbittorrent/strings.json
@@ -36,16 +36,20 @@
}
},
"active_torrents": {
- "name": "Active torrents"
+ "name": "Active torrents",
+ "unit_of_measurement": "torrents"
},
"inactive_torrents": {
- "name": "Inactive torrents"
+ "name": "Inactive torrents",
+ "unit_of_measurement": "[%key:component::qbittorrent::entity::sensor::active_torrents::unit_of_measurement%]"
},
"paused_torrents": {
- "name": "Paused torrents"
+ "name": "Paused torrents",
+ "unit_of_measurement": "[%key:component::qbittorrent::entity::sensor::active_torrents::unit_of_measurement%]"
},
"all_torrents": {
- "name": "All torrents"
+ "name": "All torrents",
+ "unit_of_measurement": "[%key:component::qbittorrent::entity::sensor::active_torrents::unit_of_measurement%]"
}
},
"switch": {
diff --git a/homeassistant/components/qld_bushfire/manifest.json b/homeassistant/components/qld_bushfire/manifest.json
index 282a931bf05..79a29e6fddb 100644
--- a/homeassistant/components/qld_bushfire/manifest.json
+++ b/homeassistant/components/qld_bushfire/manifest.json
@@ -6,5 +6,6 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["georss_qld_bushfire_alert_client"],
+ "quality_scale": "legacy",
"requirements": ["georss-qld-bushfire-alert-client==0.8"]
}
diff --git a/homeassistant/components/qnap/sensor.py b/homeassistant/components/qnap/sensor.py
index 526516bfcdd..383a4e5f572 100644
--- a/homeassistant/components/qnap/sensor.py
+++ b/homeassistant/components/qnap/sensor.py
@@ -13,7 +13,6 @@ from homeassistant.components.sensor import (
SensorStateClass,
)
from homeassistant.const import (
- ATTR_NAME,
PERCENTAGE,
EntityCategory,
UnitOfDataRate,
@@ -375,17 +374,6 @@ class QNAPMemorySensor(QNAPSensor):
return None
- # Deprecated since Home Assistant 2024.6.0
- # Can be removed completely in 2024.12.0
- @property
- def extra_state_attributes(self) -> dict[str, Any] | None:
- """Return the state attributes."""
- if self.coordinator.data:
- data = self.coordinator.data["system_stats"]["memory"]
- size = round(float(data["total"]) / 1024, 2)
- return {ATTR_MEMORY_SIZE: f"{size} {UnitOfInformation.GIBIBYTES}"}
- return None
-
class QNAPNetworkSensor(QNAPSensor):
"""A QNAP sensor that monitors network stats."""
@@ -414,22 +402,6 @@ class QNAPNetworkSensor(QNAPSensor):
return None
- # Deprecated since Home Assistant 2024.6.0
- # Can be removed completely in 2024.12.0
- @property
- def extra_state_attributes(self) -> dict[str, Any] | None:
- """Return the state attributes."""
- if self.coordinator.data:
- data = self.coordinator.data["system_stats"]["nics"][self.monitor_device]
- return {
- ATTR_IP: data["ip"],
- ATTR_MASK: data["mask"],
- ATTR_MAC: data["mac"],
- ATTR_MAX_SPEED: data["max_speed"],
- ATTR_PACKETS_ERR: data["err_packets"],
- }
- return None
-
class QNAPSystemSensor(QNAPSensor):
"""A QNAP sensor that monitors overall system health."""
@@ -455,25 +427,6 @@ class QNAPSystemSensor(QNAPSensor):
return None
- # Deprecated since Home Assistant 2024.6.0
- # Can be removed completely in 2024.12.0
- @property
- def extra_state_attributes(self) -> dict[str, Any] | None:
- """Return the state attributes."""
- if self.coordinator.data:
- data = self.coordinator.data["system_stats"]
- days = int(data["uptime"]["days"])
- hours = int(data["uptime"]["hours"])
- minutes = int(data["uptime"]["minutes"])
-
- return {
- ATTR_NAME: data["system"]["name"],
- ATTR_MODEL: data["system"]["model"],
- ATTR_SERIAL: data["system"]["serial_number"],
- ATTR_UPTIME: f"{days:0>2d}d {hours:0>2d}h {minutes:0>2d}m",
- }
- return None
-
class QNAPDriveSensor(QNAPSensor):
"""A QNAP sensor that monitors HDD/SSD drive stats."""
@@ -533,17 +486,3 @@ class QNAPVolumeSensor(QNAPSensor):
return used_gb / total_gb * 100
return None
-
- # Deprecated since Home Assistant 2024.6.0
- # Can be removed completely in 2024.12.0
- @property
- def extra_state_attributes(self) -> dict[str, Any] | None:
- """Return the state attributes."""
- if self.coordinator.data:
- data = self.coordinator.data["volumes"][self.monitor_device]
- total_gb = int(data["total_size"]) / 1024 / 1024 / 1024
-
- return {
- ATTR_VOLUME_SIZE: f"{round(total_gb, 1)} {UnitOfInformation.GIBIBYTES}"
- }
- return None
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 14f2d093f37..cd3ee8eca42 100644
--- a/homeassistant/components/qrcode/manifest.json
+++ b/homeassistant/components/qrcode/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/qrcode",
"iot_class": "calculated",
"loggers": ["pyzbar"],
- "requirements": ["Pillow==10.4.0", "pyzbar==0.1.7"]
+ "quality_scale": "legacy",
+ "requirements": ["Pillow==11.1.0", "pyzbar==0.1.7"]
}
diff --git a/homeassistant/components/quantum_gateway/manifest.json b/homeassistant/components/quantum_gateway/manifest.json
index 4494e5a2576..98c6c715417 100644
--- a/homeassistant/components/quantum_gateway/manifest.json
+++ b/homeassistant/components/quantum_gateway/manifest.json
@@ -4,5 +4,6 @@
"codeowners": ["@cisasteelersfan"],
"documentation": "https://www.home-assistant.io/integrations/quantum_gateway",
"iot_class": "local_polling",
+ "quality_scale": "legacy",
"requirements": ["quantum-gateway==0.0.8"]
}
diff --git a/homeassistant/components/qvr_pro/manifest.json b/homeassistant/components/qvr_pro/manifest.json
index 9c0e92698df..2553e1d27c4 100644
--- a/homeassistant/components/qvr_pro/manifest.json
+++ b/homeassistant/components/qvr_pro/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/qvr_pro",
"iot_class": "local_polling",
"loggers": ["pyqvrpro"],
+ "quality_scale": "legacy",
"requirements": ["pyqvrpro==0.52"]
}
diff --git a/homeassistant/components/qwikswitch/manifest.json b/homeassistant/components/qwikswitch/manifest.json
index e30ebffbf2f..750e104d1a3 100644
--- a/homeassistant/components/qwikswitch/manifest.json
+++ b/homeassistant/components/qwikswitch/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/qwikswitch",
"iot_class": "local_push",
"loggers": ["pyqwikswitch"],
+ "quality_scale": "legacy",
"requirements": ["pyqwikswitch==0.93"]
}
diff --git a/homeassistant/components/rabbitair/fan.py b/homeassistant/components/rabbitair/fan.py
index ba1896cba2f..cfbee0be67c 100644
--- a/homeassistant/components/rabbitair/fan.py
+++ b/homeassistant/components/rabbitair/fan.py
@@ -55,7 +55,6 @@ class RabbitAirFanEntity(RabbitAirBaseEntity, FanEntity):
| FanEntityFeature.TURN_ON
| FanEntityFeature.TURN_OFF
)
- _enable_turn_on_off_backwards_compatibility = False
def __init__(
self,
diff --git a/homeassistant/components/rachio/coordinator.py b/homeassistant/components/rachio/coordinator.py
index 25c40bd6656..62d42f2afda 100644
--- a/homeassistant/components/rachio/coordinator.py
+++ b/homeassistant/components/rachio/coordinator.py
@@ -8,6 +8,7 @@ from typing import Any
from rachiopy import Rachio
from requests.exceptions import Timeout
+from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.debounce import Debouncer
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
@@ -38,6 +39,7 @@ class RachioUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
self,
hass: HomeAssistant,
rachio: Rachio,
+ config_entry: ConfigEntry,
base_station,
base_count: int,
) -> None:
@@ -48,6 +50,7 @@ class RachioUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
super().__init__(
hass,
_LOGGER,
+ config_entry=config_entry,
name=f"{DOMAIN} update coordinator",
# To avoid exceeding the rate limit, increase polling interval for
# each additional base station on the account
@@ -76,6 +79,7 @@ class RachioScheduleUpdateCoordinator(DataUpdateCoordinator[list[dict[str, Any]]
self,
hass: HomeAssistant,
rachio: Rachio,
+ config_entry: ConfigEntry,
base_station,
) -> None:
"""Initialize a Rachio schedule coordinator."""
@@ -85,6 +89,7 @@ class RachioScheduleUpdateCoordinator(DataUpdateCoordinator[list[dict[str, Any]]
super().__init__(
hass,
_LOGGER,
+ config_entry=config_entry,
name=f"{DOMAIN} schedule update coordinator",
update_interval=timedelta(minutes=30),
)
diff --git a/homeassistant/components/rachio/device.py b/homeassistant/components/rachio/device.py
index f06910cd505..179e5f5ec0d 100644
--- a/homeassistant/components/rachio/device.py
+++ b/homeassistant/components/rachio/device.py
@@ -189,8 +189,10 @@ class RachioPerson:
RachioBaseStation(
rachio,
base,
- RachioUpdateCoordinator(hass, rachio, base, base_count),
- RachioScheduleUpdateCoordinator(hass, rachio, base),
+ RachioUpdateCoordinator(
+ hass, rachio, self.config_entry, base, base_count
+ ),
+ RachioScheduleUpdateCoordinator(hass, rachio, self.config_entry, base),
)
for base in base_stations
)
diff --git a/homeassistant/components/radiotherm/climate.py b/homeassistant/components/radiotherm/climate.py
index 73ab3644a0b..af52c5fcea3 100644
--- a/homeassistant/components/radiotherm/climate.py
+++ b/homeassistant/components/radiotherm/climate.py
@@ -107,7 +107,6 @@ class RadioThermostat(RadioThermostatEntity, ClimateEntity):
_attr_temperature_unit = UnitOfTemperature.FAHRENHEIT
_attr_precision = PRECISION_HALVES
_attr_name = None
- _enable_turn_on_off_backwards_compatibility = False
def __init__(self, coordinator: RadioThermUpdateCoordinator) -> None:
"""Initialize the thermostat."""
diff --git a/homeassistant/components/rainbird/__init__.py b/homeassistant/components/rainbird/__init__.py
index da2a0e4b475..d8b71e2df0b 100644
--- a/homeassistant/components/rainbird/__init__.py
+++ b/homeassistant/components/rainbird/__init__.py
@@ -7,9 +7,8 @@ from typing import Any
import aiohttp
from pyrainbird.async_client import AsyncRainbirdClient, AsyncRainbirdController
-from pyrainbird.exceptions import RainbirdApiException
+from pyrainbird.exceptions import RainbirdApiException, RainbirdAuthException
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONF_HOST,
CONF_MAC,
@@ -18,12 +17,17 @@ from homeassistant.const import (
Platform,
)
from homeassistant.core import HomeAssistant
-from homeassistant.exceptions import ConfigEntryNotReady
+from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.device_registry import format_mac
from .const import CONF_SERIAL_NUMBER
-from .coordinator import RainbirdData, async_create_clientsession
+from .coordinator import (
+ RainbirdScheduleUpdateCoordinator,
+ RainbirdUpdateCoordinator,
+ async_create_clientsession,
+)
+from .types import RainbirdConfigEntry, RainbirdData
_LOGGER = logging.getLogger(__name__)
@@ -40,7 +44,9 @@ DOMAIN = "rainbird"
def _async_register_clientsession_shutdown(
- hass: HomeAssistant, entry: ConfigEntry, clientsession: aiohttp.ClientSession
+ hass: HomeAssistant,
+ entry: RainbirdConfigEntry,
+ clientsession: aiohttp.ClientSession,
) -> None:
"""Register cleanup hooks for the clientsession."""
@@ -55,11 +61,9 @@ def _async_register_clientsession_shutdown(
entry.async_on_unload(_async_close_websession)
-async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_setup_entry(hass: HomeAssistant, entry: RainbirdConfigEntry) -> bool:
"""Set up the config entry for Rain Bird."""
- hass.data.setdefault(DOMAIN, {})
-
clientsession = async_create_clientsession()
_async_register_clientsession_shutdown(hass, entry, clientsession)
@@ -75,14 +79,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
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),
@@ -91,21 +93,37 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
try:
model_info = await controller.get_model_and_version()
+ except RainbirdAuthException as err:
+ raise ConfigEntryAuthFailed from err
except RainbirdApiException as err:
raise ConfigEntryNotReady from err
- data = RainbirdData(hass, entry, controller, model_info)
+ data = RainbirdData(
+ controller,
+ model_info,
+ coordinator=RainbirdUpdateCoordinator(
+ hass,
+ name=entry.title,
+ controller=controller,
+ unique_id=entry.unique_id,
+ model_info=model_info,
+ ),
+ schedule_coordinator=RainbirdScheduleUpdateCoordinator(
+ hass,
+ name=f"{entry.title} Schedule",
+ controller=controller,
+ ),
+ )
await data.coordinator.async_config_entry_first_refresh()
- hass.data[DOMAIN][entry.entry_id] = data
-
+ entry.runtime_data = data
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def _async_fix_unique_id(
- hass: HomeAssistant, controller: AsyncRainbirdController, entry: ConfigEntry
+ hass: HomeAssistant, controller: AsyncRainbirdController, entry: RainbirdConfigEntry
) -> bool:
"""Update the config entry with a unique id based on the mac address."""
_LOGGER.debug("Checking for migration of config entry (%s)", entry.unique_id)
@@ -150,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,
@@ -194,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,
@@ -234,10 +250,6 @@ def _async_fix_device_id(
)
-async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_unload_entry(hass: HomeAssistant, entry: RainbirdConfigEntry) -> 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/rainbird/binary_sensor.py b/homeassistant/components/rainbird/binary_sensor.py
index d44022b0a2d..5722b8852dd 100644
--- a/homeassistant/components/rainbird/binary_sensor.py
+++ b/homeassistant/components/rainbird/binary_sensor.py
@@ -8,13 +8,12 @@ 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 homeassistant.helpers.update_coordinator import CoordinatorEntity
-from .const import DOMAIN
from .coordinator import RainbirdUpdateCoordinator
+from .types import RainbirdConfigEntry
_LOGGER = logging.getLogger(__name__)
@@ -27,11 +26,11 @@ RAIN_SENSOR_ENTITY_DESCRIPTION = BinarySensorEntityDescription(
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ config_entry: RainbirdConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up entry for a Rain Bird binary_sensor."""
- coordinator = hass.data[DOMAIN][config_entry.entry_id].coordinator
+ coordinator = config_entry.runtime_data.coordinator
async_add_entities([RainBirdSensor(coordinator, RAIN_SENSOR_ENTITY_DESCRIPTION)])
diff --git a/homeassistant/components/rainbird/calendar.py b/homeassistant/components/rainbird/calendar.py
index 42c1cce69d3..160fe70c61e 100644
--- a/homeassistant/components/rainbird/calendar.py
+++ b/homeassistant/components/rainbird/calendar.py
@@ -6,7 +6,6 @@ from datetime import datetime
import logging
from homeassistant.components.calendar import CalendarEntity, CalendarEvent
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import DeviceInfo
@@ -14,19 +13,19 @@ 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 DOMAIN
from .coordinator import RainbirdScheduleUpdateCoordinator
+from .types import RainbirdConfigEntry
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ config_entry: RainbirdConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up entry for a Rain Bird irrigation calendar."""
- data = hass.data[DOMAIN][config_entry.entry_id]
+ data = config_entry.runtime_data
if not data.model_info.model_info.max_programs:
return
diff --git a/homeassistant/components/rainbird/config_flow.py b/homeassistant/components/rainbird/config_flow.py
index abeb1b5da15..1390650ea02 100644
--- a/homeassistant/components/rainbird/config_flow.py
+++ b/homeassistant/components/rainbird/config_flow.py
@@ -3,28 +3,22 @@
from __future__ import annotations
import asyncio
+from collections.abc import Mapping
import logging
from typing import Any
-from pyrainbird.async_client import (
- AsyncRainbirdClient,
- AsyncRainbirdController,
- RainbirdApiException,
-)
+from pyrainbird.async_client import AsyncRainbirdClient, AsyncRainbirdController
from pyrainbird.data import WifiParams
+from pyrainbird.exceptions import RainbirdApiException, RainbirdAuthException
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_MAC, CONF_PASSWORD
from homeassistant.core import callback
from homeassistant.helpers import config_validation as cv, selector
from homeassistant.helpers.device_registry import format_mac
+from . import RainbirdConfigEntry
from .const import (
ATTR_DURATION,
CONF_SERIAL_NUMBER,
@@ -45,6 +39,13 @@ DATA_SCHEMA = vol.Schema(
),
}
)
+REAUTH_SCHEMA = vol.Schema(
+ {
+ vol.Required(CONF_PASSWORD): selector.TextSelector(
+ selector.TextSelectorConfig(type=selector.TextSelectorType.PASSWORD)
+ ),
+ }
+)
class ConfigFlowError(Exception):
@@ -59,14 +60,45 @@ class ConfigFlowError(Exception):
class RainbirdConfigFlowHandler(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Rain Bird."""
+ host: str
+
@staticmethod
@callback
def async_get_options_flow(
- config_entry: ConfigEntry,
+ config_entry: RainbirdConfigEntry,
) -> RainBirdOptionsFlowHandler:
"""Define the config flow to handle options."""
return RainBirdOptionsFlowHandler()
+ async def async_step_reauth(
+ self, entry_data: Mapping[str, Any]
+ ) -> ConfigFlowResult:
+ """Perform reauthentication upon an API authentication error."""
+ self.host = entry_data[CONF_HOST]
+ return await self.async_step_reauth_confirm()
+
+ async def async_step_reauth_confirm(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Confirm reauthentication dialog."""
+ errors: dict[str, str] = {}
+ if user_input:
+ try:
+ await self._test_connection(self.host, user_input[CONF_PASSWORD])
+ except ConfigFlowError as err:
+ _LOGGER.error("Error during config flow: %s", err)
+ errors["base"] = err.error_code
+ else:
+ return self.async_update_reload_and_abort(
+ self._get_reauth_entry(),
+ data_updates={CONF_PASSWORD: user_input[CONF_PASSWORD]},
+ )
+ return self.async_show_form(
+ step_id="reauth_confirm",
+ data_schema=REAUTH_SCHEMA,
+ errors=errors,
+ )
+
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
@@ -123,6 +155,11 @@ class RainbirdConfigFlowHandler(ConfigFlow, domain=DOMAIN):
f"Timeout connecting to Rain Bird controller: {err!s}",
"timeout_connect",
) from err
+ except RainbirdAuthException as err:
+ raise ConfigFlowError(
+ f"Authentication error connecting from Rain Bird controller: {err!s}",
+ "invalid_auth",
+ ) from err
except RainbirdApiException as err:
raise ConfigFlowError(
f"Error connecting to Rain Bird controller: {err!s}",
diff --git a/homeassistant/components/rainbird/coordinator.py b/homeassistant/components/rainbird/coordinator.py
index 2657fd6433e..2ccfa0af62a 100644
--- a/homeassistant/components/rainbird/coordinator.py
+++ b/homeassistant/components/rainbird/coordinator.py
@@ -8,7 +8,6 @@ import datetime
import logging
import aiohttp
-from propcache import cached_property
from pyrainbird.async_client import (
AsyncRainbirdController,
RainbirdApiException,
@@ -16,13 +15,13 @@ from pyrainbird.async_client import (
)
from pyrainbird.data import ModelAndVersion, Schedule
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.debounce import Debouncer
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN, MANUFACTURER, TIMEOUT_SECONDS
+from .types import RainbirdConfigEntry
UPDATE_INTERVAL = datetime.timedelta(minutes=1)
# The calendar data requires RPCs for each program/zone, and the data rarely
@@ -141,7 +140,7 @@ class RainbirdUpdateCoordinator(DataUpdateCoordinator[RainbirdDeviceState]):
class RainbirdScheduleUpdateCoordinator(DataUpdateCoordinator[Schedule]):
"""Coordinator for rainbird irrigation schedule calls."""
- config_entry: ConfigEntry
+ config_entry: RainbirdConfigEntry
def __init__(
self,
@@ -166,36 +165,3 @@ class RainbirdScheduleUpdateCoordinator(DataUpdateCoordinator[Schedule]):
return await self._controller.get_schedule()
except RainbirdApiException as err:
raise UpdateFailed(f"Error communicating with Device: {err}") from err
-
-
-@dataclass
-class RainbirdData:
- """Holder for shared integration data.
-
- The coordinators are lazy since they may only be used by some platforms when needed.
- """
-
- hass: HomeAssistant
- entry: ConfigEntry
- controller: AsyncRainbirdController
- model_info: ModelAndVersion
-
- @cached_property
- def coordinator(self) -> RainbirdUpdateCoordinator:
- """Return RainbirdUpdateCoordinator."""
- return RainbirdUpdateCoordinator(
- self.hass,
- name=self.entry.title,
- controller=self.controller,
- unique_id=self.entry.unique_id,
- model_info=self.model_info,
- )
-
- @cached_property
- def schedule_coordinator(self) -> RainbirdScheduleUpdateCoordinator:
- """Return RainbirdScheduleUpdateCoordinator."""
- return RainbirdScheduleUpdateCoordinator(
- self.hass,
- name=f"{self.entry.title} Schedule",
- controller=self.controller,
- )
diff --git a/homeassistant/components/rainbird/number.py b/homeassistant/components/rainbird/number.py
index 507a31e59a4..d8081a796b9 100644
--- a/homeassistant/components/rainbird/number.py
+++ b/homeassistant/components/rainbird/number.py
@@ -7,29 +7,28 @@ import logging
from pyrainbird.exceptions import RainbirdApiException, RainbirdDeviceBusyException
from homeassistant.components.number import NumberEntity
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import UnitOfTime
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
-from .const import DOMAIN
from .coordinator import RainbirdUpdateCoordinator
+from .types import RainbirdConfigEntry
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ config_entry: RainbirdConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up entry for a Rain Bird number platform."""
async_add_entities(
[
RainDelayNumber(
- hass.data[DOMAIN][config_entry.entry_id].coordinator,
+ config_entry.runtime_data.coordinator,
)
]
)
diff --git a/homeassistant/components/rainbird/quality_scale.yaml b/homeassistant/components/rainbird/quality_scale.yaml
new file mode 100644
index 00000000000..8b4805a9b0e
--- /dev/null
+++ b/homeassistant/components/rainbird/quality_scale.yaml
@@ -0,0 +1,89 @@
+rules:
+ # Bronze
+ config-flow: done
+ brands: done
+ dependency-transparency: done
+ common-modules: done
+ has-entity-name: done
+ action-setup:
+ status: done
+ comment: |
+ The integration only has an entity service, registered in the platform.
+ appropriate-polling:
+ status: done
+ comment: |
+ Rainbird devices are local. Irrigation valve/controller status is polled
+ once per minute to get fast updates when turning on/off the valves.
+ The irrigation schedule uses a 15 minute poll interval since it rarely
+ changes.
+
+ Rainbird devices can only accept a single http connection, so this uses a
+ an aiohttp.ClientSession with a connection limit, and also uses a request
+ debouncer.
+ test-before-configure: done
+ entity-event-setup:
+ status: exempt
+ comment: Integration is polling and does not subscribe to events.
+ unique-config-entry: done
+ entity-unique-id: done
+ docs-installation-instructions:
+ status: todo
+ comment: |
+ The introduction can be improved and is missing pre-requisites such as
+ installing the app.
+ docs-removal-instructions: todo
+ test-before-setup: done
+ docs-high-level-description: done
+ config-flow-test-coverage:
+ status: todo
+ comment: |
+ All config flow tests should finish with CREATE_ENTRY and ABORT to
+ test they are able to recover from errors
+ docs-actions: done
+ runtime-data: done
+
+ # Silver
+ log-when-unavailable: done
+ config-entry-unloading: done
+ reauthentication-flow: done
+ action-exceptions: done
+ docs-installation-parameters:
+ status: todo
+ comment: The documentation does not mention installation parameters
+ integration-owner: done
+ parallel-updates:
+ status: todo
+ comment: The integration does not explicitly set a number of parallel updates.
+ test-coverage: done
+ docs-configuration-parameters:
+ status: todo
+ comment: The documentation for configuration parameters could be improved.
+ entity-unavailable: done
+
+ # Gold
+ docs-examples: todo
+ discovery-update-info: todo
+ entity-device-class: todo
+ entity-translations: todo
+ docs-data-update: todo
+ entity-disabled-by-default: todo
+ discovery: todo
+ exception-translations: todo
+ devices: todo
+ docs-supported-devices: todo
+ icon-translations: todo
+ docs-known-limitations: todo
+ stale-devices: todo
+ docs-supported-functions: todo
+ repair-issues: todo
+ reconfiguration-flow: todo
+ entity-category: todo
+ dynamic-devices: todo
+ docs-troubleshooting: todo
+ diagnostics: todo
+ docs-use-cases: todo
+
+ # Platinum
+ async-dependency: todo
+ strict-typing: todo
+ inject-websession: todo
diff --git a/homeassistant/components/rainbird/sensor.py b/homeassistant/components/rainbird/sensor.py
index 649d643a20c..4725a33bc9a 100644
--- a/homeassistant/components/rainbird/sensor.py
+++ b/homeassistant/components/rainbird/sensor.py
@@ -5,14 +5,13 @@ from __future__ import annotations
import logging
from homeassistant.components.sensor import SensorEntity, SensorEntityDescription
-from homeassistant.config_entries import ConfigEntry
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 RainbirdUpdateCoordinator
+from .types import RainbirdConfigEntry
_LOGGER = logging.getLogger(__name__)
@@ -25,14 +24,14 @@ RAIN_DELAY_ENTITY_DESCRIPTION = SensorEntityDescription(
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ config_entry: RainbirdConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up entry for a Rain Bird sensor."""
async_add_entities(
[
RainBirdSensor(
- hass.data[DOMAIN][config_entry.entry_id].coordinator,
+ config_entry.runtime_data.coordinator,
RAIN_DELAY_ENTITY_DESCRIPTION,
)
]
diff --git a/homeassistant/components/rainbird/strings.json b/homeassistant/components/rainbird/strings.json
index ea0d64f6208..6f92b1bdb97 100644
--- a/homeassistant/components/rainbird/strings.json
+++ b/homeassistant/components/rainbird/strings.json
@@ -9,16 +9,29 @@
"password": "[%key:common::config_flow::data::password%]"
},
"data_description": {
- "host": "The hostname or IP address of your Rain Bird device."
+ "host": "The hostname or IP address of your Rain Bird device.",
+ "password": "The password used to authenticate with the Rain Bird device."
+ }
+ },
+ "reauth_confirm": {
+ "title": "[%key:common::config_flow::title::reauth%]",
+ "description": "The Rain Bird integration needs to re-authenticate with the device.",
+ "data": {
+ "password": "[%key:common::config_flow::data::password%]"
+ },
+ "data_description": {
+ "password": "The password to authenticate with your Rain Bird device."
}
}
},
"abort": {
- "already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
+ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
+ "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
- "timeout_connect": "[%key:common::config_flow::error::timeout_connect%]"
+ "timeout_connect": "[%key:common::config_flow::error::timeout_connect%]",
+ "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]"
}
},
"options": {
@@ -27,6 +40,9 @@
"title": "[%key:component::rainbird::config::step::user::title%]",
"data": {
"duration": "Default irrigation time in minutes"
+ },
+ "data_description": {
+ "duration": "The default duration the sprinkler will run when turned on."
}
}
}
diff --git a/homeassistant/components/rainbird/switch.py b/homeassistant/components/rainbird/switch.py
index 62a2a7c4a32..f622a1b9b2c 100644
--- a/homeassistant/components/rainbird/switch.py
+++ b/homeassistant/components/rainbird/switch.py
@@ -8,7 +8,6 @@ from pyrainbird.exceptions import RainbirdApiException, RainbirdDeviceBusyExcept
import voluptuous as vol
from homeassistant.components.switch import SwitchEntity
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv, entity_platform
@@ -19,6 +18,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import ATTR_DURATION, CONF_IMPORTED_NAMES, DOMAIN, MANUFACTURER
from .coordinator import RainbirdUpdateCoordinator
+from .types import RainbirdConfigEntry
_LOGGER = logging.getLogger(__name__)
@@ -31,11 +31,11 @@ SERVICE_SCHEMA_IRRIGATION: VolDictType = {
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ config_entry: RainbirdConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up entry for a Rain Bird irrigation switches."""
- coordinator = hass.data[DOMAIN][config_entry.entry_id].coordinator
+ coordinator = config_entry.runtime_data.coordinator
async_add_entities(
RainBirdSwitch(
coordinator,
diff --git a/homeassistant/components/rainbird/types.py b/homeassistant/components/rainbird/types.py
new file mode 100644
index 00000000000..cc43353ac17
--- /dev/null
+++ b/homeassistant/components/rainbird/types.py
@@ -0,0 +1,33 @@
+"""Types for Rain Bird integration."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import TYPE_CHECKING
+
+from pyrainbird.async_client import AsyncRainbirdController
+from pyrainbird.data import ModelAndVersion
+
+from homeassistant.config_entries import ConfigEntry
+
+if TYPE_CHECKING:
+ from .coordinator import (
+ RainbirdScheduleUpdateCoordinator,
+ RainbirdUpdateCoordinator,
+ )
+
+
+@dataclass
+class RainbirdData:
+ """Holder for shared integration data.
+
+ The coordinators are lazy since they may only be used by some platforms when needed.
+ """
+
+ controller: AsyncRainbirdController
+ model_info: ModelAndVersion
+ coordinator: RainbirdUpdateCoordinator
+ schedule_coordinator: RainbirdScheduleUpdateCoordinator
+
+
+type RainbirdConfigEntry = ConfigEntry[RainbirdData]
diff --git a/homeassistant/components/raincloud/manifest.json b/homeassistant/components/raincloud/manifest.json
index 70f62d2beee..b5179622441 100644
--- a/homeassistant/components/raincloud/manifest.json
+++ b/homeassistant/components/raincloud/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/raincloud",
"iot_class": "cloud_polling",
"loggers": ["raincloudy"],
+ "quality_scale": "legacy",
"requirements": ["raincloudy==0.0.7"]
}
diff --git a/homeassistant/components/random/config_flow.py b/homeassistant/components/random/config_flow.py
index 00314169260..35b7757580e 100644
--- a/homeassistant/components/random/config_flow.py
+++ b/homeassistant/components/random/config_flow.py
@@ -106,8 +106,12 @@ def _validate_unit(options: dict[str, Any]) -> None:
and (units := DEVICE_CLASS_UNITS.get(device_class))
and (unit := options.get(CONF_UNIT_OF_MEASUREMENT)) not in units
):
+ # Sort twice to make sure strings with same case-insensitive order of
+ # letters are sorted consistently still (sorted() is guaranteed stable).
sorted_units = sorted(
- [f"'{unit!s}'" if unit else "no unit of measurement" for unit in units],
+ sorted(
+ [f"'{unit!s}'" if unit else "no unit of measurement" for unit in units],
+ ),
key=str.casefold,
)
if len(sorted_units) == 1:
diff --git a/homeassistant/components/random/strings.json b/homeassistant/components/random/strings.json
index ef19dd6dd67..e5c5543e39f 100644
--- a/homeassistant/components/random/strings.json
+++ b/homeassistant/components/random/strings.json
@@ -20,12 +20,12 @@
"title": "Random sensor"
},
"user": {
- "description": "This helper allows you to create a helper that emits a random value.",
+ "description": "This helper allows you to create an entity that emits a random value.",
"menu_options": {
"binary_sensor": "Random binary sensor",
"sensor": "Random sensor"
},
- "title": "Random helper"
+ "title": "Create Random helper"
}
}
},
diff --git a/homeassistant/components/raspberry_pi/manifest.json b/homeassistant/components/raspberry_pi/manifest.json
index 5ed68154ce1..c8317f7ef1e 100644
--- a/homeassistant/components/raspberry_pi/manifest.json
+++ b/homeassistant/components/raspberry_pi/manifest.json
@@ -6,5 +6,6 @@
"config_flow": false,
"dependencies": ["hardware"],
"documentation": "https://www.home-assistant.io/integrations/raspberry_pi",
- "integration_type": "hardware"
+ "integration_type": "hardware",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/raspyrfm/manifest.json b/homeassistant/components/raspyrfm/manifest.json
index 0fa4ce77200..d001e2b1118 100644
--- a/homeassistant/components/raspyrfm/manifest.json
+++ b/homeassistant/components/raspyrfm/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/raspyrfm",
"iot_class": "assumed_state",
"loggers": ["raspyrfm_client"],
+ "quality_scale": "legacy",
"requirements": ["raspyrfm-client==1.2.8"]
}
diff --git a/homeassistant/components/rdw/manifest.json b/homeassistant/components/rdw/manifest.json
index 7af3e861347..2ab90e55ef0 100644
--- a/homeassistant/components/rdw/manifest.json
+++ b/homeassistant/components/rdw/manifest.json
@@ -6,6 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/rdw",
"integration_type": "service",
"iot_class": "cloud_polling",
- "quality_scale": "platinum",
"requirements": ["vehicle==2.2.2"]
}
diff --git a/homeassistant/components/recorder/__init__.py b/homeassistant/components/recorder/__init__.py
index 8564827d839..a40760c67f4 100644
--- a/homeassistant/components/recorder/__init__.py
+++ b/homeassistant/components/recorder/__init__.py
@@ -28,7 +28,14 @@ from homeassistant.helpers.typing import ConfigType
from homeassistant.loader import bind_hass
from homeassistant.util.event_type import EventType
-from . import entity_registry, websocket_api
+# Pre-import backup to avoid it being imported
+# later when the import executor is busy and delaying
+# startup
+from . import (
+ backup, # noqa: F401
+ entity_registry,
+ websocket_api,
+)
from .const import ( # noqa: F401
CONF_DB_INTEGRITY_CHECK,
DOMAIN,
diff --git a/homeassistant/components/recorder/core.py b/homeassistant/components/recorder/core.py
index 6ba64d4a571..fee72ce273f 100644
--- a/homeassistant/components/recorder/core.py
+++ b/homeassistant/components/recorder/core.py
@@ -712,12 +712,24 @@ class Recorder(threading.Thread):
setup_result = self._setup_recorder()
if not setup_result:
+ _LOGGER.error("Recorder setup failed, recorder shutting down")
# Give up if we could not connect
return
schema_status = migration.validate_db_schema(self.hass, self, self.get_session)
if schema_status is None:
# Give up if we could not validate the schema
+ _LOGGER.error("Failed to validate schema, recorder shutting down")
+ return
+ if schema_status.current_version > SCHEMA_VERSION:
+ _LOGGER.error(
+ "The database schema version %s is newer than %s which is the maximum "
+ "database schema version supported by the installed version of "
+ "Home Assistant Core, either upgrade Home Assistant Core or restore "
+ "the database from a backup compatible with this version",
+ schema_status.current_version,
+ SCHEMA_VERSION,
+ )
return
self.schema_version = schema_status.current_version
@@ -740,7 +752,7 @@ class Recorder(threading.Thread):
self.schema_version = schema_status.current_version
# Do non-live data migration
- migration.migrate_data_non_live(self, self.get_session, schema_status)
+ self._migrate_data_offline(schema_status)
# Non-live migration is now completed, remaining steps are live
self.migration_is_live = True
@@ -916,6 +928,13 @@ class Recorder(threading.Thread):
return False
+ def _migrate_data_offline(
+ self, schema_status: migration.SchemaValidationStatus
+ ) -> None:
+ """Migrate data."""
+ with self.hass.timeout.freeze(DOMAIN):
+ migration.migrate_data_non_live(self, self.get_session, schema_status)
+
def _migrate_schema_offline(
self, schema_status: migration.SchemaValidationStatus
) -> tuple[bool, migration.SchemaValidationStatus]:
@@ -963,6 +982,7 @@ class Recorder(threading.Thread):
# which does not need migration or repair.
new_schema_status = migration.SchemaValidationStatus(
current_version=SCHEMA_VERSION,
+ initial_version=SCHEMA_VERSION,
migration_needed=False,
non_live_data_migration_needed=False,
schema_errors=set(),
@@ -1121,7 +1141,6 @@ class Recorder(threading.Thread):
# Map the event data to the StateAttributes table
shared_attrs = shared_attrs_bytes.decode("utf-8")
- dbstate.attributes = None
# Matching attributes found in the pending commit
if pending_event_data := state_attributes_manager.get_pending(shared_attrs):
dbstate.state_attributes = pending_event_data
@@ -1424,6 +1443,7 @@ class Recorder(threading.Thread):
with session_scope(session=self.get_session()) as session:
end_incomplete_runs(session, self.recorder_runs_manager.recording_start)
self.recorder_runs_manager.start(session)
+ self.states_manager.load_from_db(session)
self._open_event_session()
diff --git a/homeassistant/components/recorder/db_schema.py b/homeassistant/components/recorder/db_schema.py
index 7e8343321c3..cefce9c4e72 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, Self, cast
+from typing import Any, Final, Protocol, Self, cast
import ciso8601
from fnv_hash_fast import fnv1a_32
@@ -77,7 +77,7 @@ class LegacyBase(DeclarativeBase):
"""Base class for tables, used for schema migration."""
-SCHEMA_VERSION = 47
+SCHEMA_VERSION = 48
_LOGGER = logging.getLogger(__name__)
@@ -130,7 +130,8 @@ METADATA_ID_LAST_UPDATED_INDEX_TS = "ix_states_metadata_id_last_updated_ts"
EVENTS_CONTEXT_ID_BIN_INDEX = "ix_events_context_id_bin"
STATES_CONTEXT_ID_BIN_INDEX = "ix_states_context_id_bin"
LEGACY_STATES_EVENT_ID_INDEX = "ix_states_event_id"
-LEGACY_STATES_ENTITY_ID_LAST_UPDATED_INDEX = "ix_states_entity_id_last_updated_ts"
+LEGACY_STATES_ENTITY_ID_LAST_UPDATED_TS_INDEX = "ix_states_entity_id_last_updated_ts"
+LEGACY_MAX_LENGTH_EVENT_CONTEXT_ID: Final = 36
CONTEXT_ID_BIN_MAX_LENGTH = 16
MYSQL_COLLATE = "utf8mb4_unicode_ci"
@@ -162,14 +163,14 @@ class Unused(CHAR):
"""An unused column type that behaves like a string."""
-@compiles(UnusedDateTime, "mysql", "mariadb", "sqlite") # type: ignore[misc,no-untyped-call]
-@compiles(Unused, "mysql", "mariadb", "sqlite") # type: ignore[misc,no-untyped-call]
+@compiles(UnusedDateTime, "mysql", "mariadb", "sqlite")
+@compiles(Unused, "mysql", "mariadb", "sqlite")
def compile_char_zero(type_: TypeDecorator, compiler: Any, **kw: Any) -> str:
"""Compile UnusedDateTime and Unused as CHAR(0) on mysql, mariadb, and sqlite."""
return "CHAR(0)" # Uses 1 byte on MySQL (no change on sqlite)
-@compiles(Unused, "postgresql") # type: ignore[misc,no-untyped-call]
+@compiles(Unused, "postgresql")
def compile_char_one(type_: TypeDecorator, compiler: Any, **kw: Any) -> str:
"""Compile Unused as CHAR(1) on postgresql."""
return "CHAR(1)" # Uses 1 byte
@@ -232,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:
@@ -350,6 +355,17 @@ class Events(Base):
return None
+class LegacyEvents(LegacyBase):
+ """Event history data with event_id, used for schema migration."""
+
+ __table_args__ = (_DEFAULT_TABLE_ARGS,)
+ __tablename__ = TABLE_EVENTS
+ event_id: Mapped[int] = mapped_column(ID_TYPE, Identity(), primary_key=True)
+ context_id: Mapped[str | None] = mapped_column(
+ String(LEGACY_MAX_LENGTH_EVENT_CONTEXT_ID), index=True
+ )
+
+
class EventData(Base):
"""Event data history."""
@@ -575,6 +591,28 @@ class States(Base):
)
+class LegacyStates(LegacyBase):
+ """State change history with entity_id, used for schema migration."""
+
+ __table_args__ = (
+ Index(
+ LEGACY_STATES_ENTITY_ID_LAST_UPDATED_TS_INDEX,
+ "entity_id",
+ "last_updated_ts",
+ ),
+ _DEFAULT_TABLE_ARGS,
+ )
+ __tablename__ = TABLE_STATES
+ state_id: Mapped[int] = mapped_column(ID_TYPE, Identity(), primary_key=True)
+ entity_id: Mapped[str | None] = mapped_column(UNUSED_LEGACY_COLUMN)
+ last_updated_ts: Mapped[float | None] = mapped_column(
+ TIMESTAMP_TYPE, default=time.time, index=True
+ )
+ context_id: Mapped[str | None] = mapped_column(
+ String(LEGACY_MAX_LENGTH_EVENT_CONTEXT_ID), index=True
+ )
+
+
class StateAttributes(Base):
"""State attribute change history."""
@@ -691,12 +729,14 @@ class StatisticsBase:
duration: timedelta
@classmethod
- def from_stats(cls, metadata_id: int, stats: StatisticData) -> Self:
+ def from_stats(
+ cls, metadata_id: int, stats: StatisticData, now_timestamp: float | None = None
+ ) -> Self:
"""Create object from a statistics with datetime objects."""
return cls( # type: ignore[call-arg]
metadata_id=metadata_id,
created=None,
- created_ts=time.time(),
+ created_ts=now_timestamp or time.time(),
start=None,
start_ts=stats["start"].timestamp(),
mean=stats.get("mean"),
@@ -709,12 +749,17 @@ class StatisticsBase:
)
@classmethod
- def from_stats_ts(cls, metadata_id: int, stats: StatisticDataTimestamp) -> Self:
+ def from_stats_ts(
+ cls,
+ metadata_id: int,
+ stats: StatisticDataTimestamp,
+ now_timestamp: float | None = None,
+ ) -> Self:
"""Create object from a statistics with timestamps."""
return cls( # type: ignore[call-arg]
metadata_id=metadata_id,
created=None,
- created_ts=time.time(),
+ created_ts=now_timestamp or time.time(),
start=None,
start_ts=stats["start_ts"],
mean=stats.get("mean"),
diff --git a/homeassistant/components/recorder/history/legacy.py b/homeassistant/components/recorder/history/legacy.py
index b59fc43c3d0..dc49ebb9768 100644
--- a/homeassistant/components/recorder/history/legacy.py
+++ b/homeassistant/components/recorder/history/legacy.py
@@ -22,9 +22,9 @@ from homeassistant.core import HomeAssistant, State, split_entity_id
from homeassistant.helpers.recorder import get_instance
import homeassistant.util.dt as dt_util
-from ..db_schema import RecorderRuns, StateAttributes, States
+from ..db_schema import StateAttributes, States
from ..filters import Filters
-from ..models import process_timestamp, process_timestamp_to_utc_isoformat
+from ..models import process_timestamp_to_utc_isoformat
from ..models.legacy import LegacyLazyState, legacy_row_to_compressed_state
from ..util import execute_stmt_lambda_element, session_scope
from .const import (
@@ -436,7 +436,7 @@ def get_last_state_changes(
def _get_states_for_entities_stmt(
- run_start: datetime,
+ run_start_ts: float,
utc_point_in_time: datetime,
entity_ids: list[str],
no_attributes: bool,
@@ -447,8 +447,7 @@ def _get_states_for_entities_stmt(
)
# We got an include-list of entities, accelerate the query by filtering already
# in the inner query.
- run_start_ts = process_timestamp(run_start).timestamp()
- utc_point_in_time_ts = dt_util.utc_to_timestamp(utc_point_in_time)
+ utc_point_in_time_ts = utc_point_in_time.timestamp()
stmt += lambda q: q.join(
(
most_recent_states_for_entities_by_date := (
@@ -483,7 +482,7 @@ def _get_rows_with_session(
session: Session,
utc_point_in_time: datetime,
entity_ids: list[str],
- run: RecorderRuns | None = None,
+ *,
no_attributes: bool = False,
) -> Iterable[Row]:
"""Return the states at a specific point in time."""
@@ -495,17 +494,16 @@ def _get_rows_with_session(
),
)
- if run is None:
- run = get_instance(hass).recorder_runs_manager.get(utc_point_in_time)
+ oldest_ts = get_instance(hass).states_manager.oldest_ts
- if run is None or process_timestamp(run.start) > utc_point_in_time:
- # History did not run before utc_point_in_time
+ if oldest_ts is None or oldest_ts > utc_point_in_time.timestamp():
+ # We don't have any states for the requested time
return []
# We have more than one entity to look at so we need to do a query on states
# since the last recorder run started.
stmt = _get_states_for_entities_stmt(
- run.start, utc_point_in_time, entity_ids, no_attributes
+ oldest_ts, utc_point_in_time, entity_ids, no_attributes
)
return execute_stmt_lambda_element(session, stmt)
@@ -520,7 +518,7 @@ def _get_single_entity_states_stmt(
stmt, join_attributes = _lambda_stmt_and_join_attributes(
no_attributes, include_last_changed=True
)
- utc_point_in_time_ts = dt_util.utc_to_timestamp(utc_point_in_time)
+ utc_point_in_time_ts = utc_point_in_time.timestamp()
stmt += (
lambda q: q.filter(
States.last_updated_ts < utc_point_in_time_ts,
diff --git a/homeassistant/components/recorder/history/modern.py b/homeassistant/components/recorder/history/modern.py
index b44bec0d0ee..2d8f4da5f38 100644
--- a/homeassistant/components/recorder/history/modern.py
+++ b/homeassistant/components/recorder/history/modern.py
@@ -28,13 +28,17 @@ from homeassistant.helpers.recorder import get_instance
import homeassistant.util.dt as dt_util
from ..const import LAST_REPORTED_SCHEMA_VERSION
-from ..db_schema import SHARED_ATTR_OR_LEGACY_ATTRIBUTES, StateAttributes, States
+from ..db_schema import (
+ SHARED_ATTR_OR_LEGACY_ATTRIBUTES,
+ StateAttributes,
+ States,
+ StatesMeta,
+)
from ..filters import Filters
from ..models import (
LazyState,
datetime_to_timestamp_or_none,
extract_metadata_ids,
- process_timestamp,
row_to_compressed_state,
)
from ..util import execute_stmt_lambda_element, session_scope
@@ -178,7 +182,6 @@ def _significant_states_stmt(
unioned_subquery = union_all(
_select_from_subquery(
_get_start_time_state_stmt(
- run_start_ts,
start_time_ts,
single_metadata_id,
metadata_ids,
@@ -246,12 +249,12 @@ def get_significant_states_with_session(
if metadata_id is not None
and split_entity_id(entity_id)[0] in SIGNIFICANT_DOMAINS
]
- run_start_ts: float | None = None
+ oldest_ts: float | None = None
if include_start_time_state and not (
- run_start_ts := _get_run_start_ts_for_utc_point_in_time(hass, start_time)
+ oldest_ts := _get_oldest_possible_ts(hass, start_time)
):
include_start_time_state = False
- start_time_ts = dt_util.utc_to_timestamp(start_time)
+ start_time_ts = start_time.timestamp()
end_time_ts = datetime_to_timestamp_or_none(end_time)
single_metadata_id = metadata_ids[0] if len(metadata_ids) == 1 else None
stmt = lambda_stmt(
@@ -264,7 +267,7 @@ def get_significant_states_with_session(
significant_changes_only,
no_attributes,
include_start_time_state,
- run_start_ts,
+ oldest_ts,
),
track_on=[
bool(single_metadata_id),
@@ -348,11 +351,12 @@ def _state_changed_during_period_stmt(
)
if limit:
stmt = stmt.limit(limit)
- stmt = stmt.order_by(
- States.metadata_id,
- States.last_updated_ts,
- )
+ stmt = stmt.order_by(States.metadata_id, States.last_updated_ts)
if not include_start_time_state or not run_start_ts:
+ # If we do not need the start time state or the
+ # oldest possible timestamp is newer than the start time
+ # we can return the statement as is as there will
+ # never be a start time state.
return stmt
return _select_from_subquery(
union_all(
@@ -411,12 +415,12 @@ def state_changes_during_period(
entity_id_to_metadata_id: dict[str, int | None] = {
entity_id: single_metadata_id
}
- run_start_ts: float | None = None
+ oldest_ts: float | None = None
if include_start_time_state and not (
- run_start_ts := _get_run_start_ts_for_utc_point_in_time(hass, start_time)
+ oldest_ts := _get_oldest_possible_ts(hass, start_time)
):
include_start_time_state = False
- start_time_ts = dt_util.utc_to_timestamp(start_time)
+ start_time_ts = start_time.timestamp()
end_time_ts = datetime_to_timestamp_or_none(end_time)
stmt = lambda_stmt(
lambda: _state_changed_during_period_stmt(
@@ -426,7 +430,7 @@ def state_changes_during_period(
no_attributes,
limit,
include_start_time_state,
- run_start_ts,
+ oldest_ts,
has_last_reported,
),
track_on=[
@@ -551,47 +555,43 @@ def get_last_state_changes(
def _get_start_time_state_for_entities_stmt(
- run_start_ts: float,
epoch_time: float,
metadata_ids: list[int],
no_attributes: bool,
include_last_changed: bool,
) -> Select:
"""Baked query to get states for specific entities."""
- # We got an include-list of entities, accelerate the query by filtering already
- # in the inner and the outer query.
+ # This query is the result of significant research in
+ # https://github.com/home-assistant/core/issues/132865
+ # A reverse index scan with a limit 1 is the fastest way to get the
+ # last state change before a specific point in time for all supported
+ # databases. Since all databases support this query as a join
+ # condition we can use it as a subquery to get the last state change
+ # before a specific point in time for all entities.
stmt = (
_stmt_and_join_attributes_for_start_state(
no_attributes, include_last_changed, False
)
+ .select_from(StatesMeta)
.join(
- (
- most_recent_states_for_entities_by_date := (
- select(
- States.metadata_id.label("max_metadata_id"),
- func.max(States.last_updated_ts).label("max_last_updated"),
- )
- .filter(
- (States.last_updated_ts >= run_start_ts)
- & (States.last_updated_ts < epoch_time)
- & States.metadata_id.in_(metadata_ids)
- )
- .group_by(States.metadata_id)
- .subquery()
- )
- ),
+ States,
and_(
- States.metadata_id
- == most_recent_states_for_entities_by_date.c.max_metadata_id,
States.last_updated_ts
- == most_recent_states_for_entities_by_date.c.max_last_updated,
+ == (
+ select(States.last_updated_ts)
+ .where(
+ (StatesMeta.metadata_id == States.metadata_id)
+ & (States.last_updated_ts < epoch_time)
+ )
+ .order_by(States.last_updated_ts.desc())
+ .limit(1)
+ )
+ .scalar_subquery()
+ .correlate(StatesMeta),
+ States.metadata_id == StatesMeta.metadata_id,
),
)
- .filter(
- (States.last_updated_ts >= run_start_ts)
- & (States.last_updated_ts < epoch_time)
- & States.metadata_id.in_(metadata_ids)
- )
+ .where(StatesMeta.metadata_id.in_(metadata_ids))
)
if no_attributes:
return stmt
@@ -600,22 +600,21 @@ def _get_start_time_state_for_entities_stmt(
)
-def _get_run_start_ts_for_utc_point_in_time(
+def _get_oldest_possible_ts(
hass: HomeAssistant, utc_point_in_time: datetime
) -> float | None:
- """Return the start time of a run."""
- run = get_instance(hass).recorder_runs_manager.get(utc_point_in_time)
- if (
- run is not None
- and (run_start := process_timestamp(run.start)) < utc_point_in_time
- ):
- return run_start.timestamp()
- # History did not run before utc_point_in_time but we still
+ """Return the oldest possible timestamp.
+
+ Returns None if there are no states as old as utc_point_in_time.
+ """
+
+ oldest_ts = get_instance(hass).states_manager.oldest_ts
+ if oldest_ts is not None and oldest_ts < utc_point_in_time.timestamp():
+ return oldest_ts
return None
def _get_start_time_state_stmt(
- run_start_ts: float,
epoch_time: float,
single_metadata_id: int | None,
metadata_ids: list[int],
@@ -636,7 +635,6 @@ def _get_start_time_state_stmt(
# We have more than one entity to look at so we need to do a query on states
# since the last recorder run started.
return _get_start_time_state_for_entities_stmt(
- run_start_ts,
epoch_time,
metadata_ids,
no_attributes,
diff --git a/homeassistant/components/recorder/manifest.json b/homeassistant/components/recorder/manifest.json
index 2be4b6862ba..93ffb12d18c 100644
--- a/homeassistant/components/recorder/manifest.json
+++ b/homeassistant/components/recorder/manifest.json
@@ -7,7 +7,7 @@
"iot_class": "local_push",
"quality_scale": "internal",
"requirements": [
- "SQLAlchemy==2.0.31",
+ "SQLAlchemy==2.0.36",
"fnv-hash-fast==1.0.2",
"psutil-home-assistant==0.0.1"
]
diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py
index 02ab05288c5..6ae1e265901 100644
--- a/homeassistant/components/recorder/migration.py
+++ b/homeassistant/components/recorder/migration.py
@@ -23,6 +23,7 @@ from sqlalchemy.exc import (
ProgrammingError,
SQLAlchemyError,
)
+from sqlalchemy.orm import DeclarativeBase
from sqlalchemy.orm.session import Session
from sqlalchemy.schema import AddConstraint, CreateTable, DropConstraint
from sqlalchemy.sql.expression import true
@@ -59,7 +60,7 @@ from .db_schema import (
BIG_INTEGER_SQL,
CONTEXT_ID_BIN_MAX_LENGTH,
DOUBLE_PRECISION_TYPE_SQL,
- LEGACY_STATES_ENTITY_ID_LAST_UPDATED_INDEX,
+ LEGACY_STATES_ENTITY_ID_LAST_UPDATED_TS_INDEX,
LEGACY_STATES_EVENT_ID_INDEX,
MYSQL_COLLATE,
MYSQL_DEFAULT_CHARSET,
@@ -169,6 +170,24 @@ _COLUMN_TYPES_FOR_DIALECT: dict[SupportedDialect | None, _ColumnTypesForDialect]
}
+def _unindexable_legacy_column(
+ instance: Recorder, base: type[DeclarativeBase], err: Exception
+) -> bool:
+ """Ignore index errors on char(0) columns."""
+ # The error code is hard coded because the PyMySQL library may not be
+ # installed when using database engines other than MySQL or MariaDB.
+ # 1167: The used storage engine can't index column '%s'
+ return bool(
+ base == LegacyBase
+ and isinstance(err, OperationalError)
+ and instance.engine
+ and instance.engine.dialect.name == SupportedDialect.MYSQL
+ and isinstance(err.orig, BaseException)
+ and err.orig.args
+ and err.orig.args[0] == 1167
+ )
+
+
def raise_if_exception_missing_str(ex: Exception, match_substrs: Iterable[str]) -> None:
"""Raise if the exception and cause do not contain the match substrs."""
lower_ex_strs = [str(ex).lower(), str(ex.__cause__).lower()]
@@ -180,7 +199,27 @@ def raise_if_exception_missing_str(ex: Exception, match_substrs: Iterable[str])
raise ex
-def _get_schema_version(session: Session) -> int | None:
+def _get_initial_schema_version(session: Session) -> int | None:
+ """Get the schema version the database was created with."""
+ res = (
+ session.query(SchemaChanges.schema_version)
+ .order_by(SchemaChanges.change_id.asc())
+ .first()
+ )
+ return getattr(res, "schema_version", None)
+
+
+def get_initial_schema_version(session_maker: Callable[[], Session]) -> int | None:
+ """Get the schema version the database was created with."""
+ try:
+ with session_scope(session=session_maker(), read_only=True) as session:
+ return _get_initial_schema_version(session)
+ except Exception:
+ _LOGGER.exception("Error when determining DB schema version")
+ return None
+
+
+def _get_current_schema_version(session: Session) -> int | None:
"""Get the schema version."""
res = (
session.query(SchemaChanges.schema_version)
@@ -190,11 +229,11 @@ def _get_schema_version(session: Session) -> int | None:
return getattr(res, "schema_version", None)
-def get_schema_version(session_maker: Callable[[], Session]) -> int | None:
+def get_current_schema_version(session_maker: Callable[[], Session]) -> int | None:
"""Get the schema version."""
try:
with session_scope(session=session_maker(), read_only=True) as session:
- return _get_schema_version(session)
+ return _get_current_schema_version(session)
except Exception:
_LOGGER.exception("Error when determining DB schema version")
return None
@@ -205,6 +244,7 @@ class SchemaValidationStatus:
"""Store schema validation status."""
current_version: int
+ initial_version: int
migration_needed: bool
non_live_data_migration_needed: bool
schema_errors: set[str]
@@ -227,8 +267,9 @@ def validate_db_schema(
"""
schema_errors: set[str] = set()
- current_version = get_schema_version(session_maker)
- if current_version is None:
+ current_version = get_current_schema_version(session_maker)
+ initial_version = get_initial_schema_version(session_maker)
+ if current_version is None or initial_version is None:
return None
if is_current := _schema_is_current(current_version):
@@ -238,11 +279,15 @@ def validate_db_schema(
schema_migration_needed = not is_current
_non_live_data_migration_needed = non_live_data_migration_needed(
- instance, session_maker, current_version
+ instance,
+ session_maker,
+ initial_schema_version=initial_version,
+ start_schema_version=current_version,
)
return SchemaValidationStatus(
current_version=current_version,
+ initial_version=initial_version,
non_live_data_migration_needed=_non_live_data_migration_needed,
migration_needed=schema_migration_needed or _non_live_data_migration_needed,
schema_errors=schema_errors,
@@ -313,7 +358,7 @@ def _migrate_schema(
for version in range(current_version, end_version):
new_version = version + 1
- _LOGGER.info("Upgrading recorder db schema to version %s", new_version)
+ _LOGGER.warning("Upgrading recorder db schema to version %s", new_version)
_apply_update(instance, hass, engine, session_maker, new_version, start_version)
with session_scope(session=session_maker()) as session:
session.add(SchemaChanges(schema_version=new_version))
@@ -377,17 +422,26 @@ def _get_migration_changes(session: Session) -> dict[str, int]:
def non_live_data_migration_needed(
instance: Recorder,
session_maker: Callable[[], Session],
- schema_version: int,
+ *,
+ initial_schema_version: int,
+ start_schema_version: int,
) -> bool:
"""Return True if non-live data migration is needed.
+ :param initial_schema_version: The schema version the database was created with.
+ :param start_schema_version: The schema version when starting the migration.
+
This must only be called if database schema is current.
"""
migration_needed = False
with session_scope(session=session_maker()) as session:
migration_changes = _get_migration_changes(session)
for migrator_cls in NON_LIVE_DATA_MIGRATORS:
- migrator = migrator_cls(schema_version, migration_changes)
+ migrator = migrator_cls(
+ initial_schema_version=initial_schema_version,
+ start_schema_version=start_schema_version,
+ migration_changes=migration_changes,
+ )
migration_needed |= migrator.needs_migrate(instance, session)
return migration_needed
@@ -406,7 +460,11 @@ def migrate_data_non_live(
migration_changes = _get_migration_changes(session)
for migrator_cls in NON_LIVE_DATA_MIGRATORS:
- migrator = migrator_cls(schema_status.start_version, migration_changes)
+ migrator = migrator_cls(
+ initial_schema_version=schema_status.initial_version,
+ start_schema_version=schema_status.start_version,
+ migration_changes=migration_changes,
+ )
migrator.migrate_all(instance, session_maker)
@@ -423,19 +481,28 @@ def migrate_data_live(
migration_changes = _get_migration_changes(session)
for migrator_cls in LIVE_DATA_MIGRATORS:
- migrator = migrator_cls(schema_status.start_version, migration_changes)
+ migrator = migrator_cls(
+ initial_schema_version=schema_status.initial_version,
+ start_schema_version=schema_status.start_version,
+ migration_changes=migration_changes,
+ )
migrator.queue_migration(instance, session)
def _create_index(
- session_maker: Callable[[], Session], table_name: str, index_name: str
+ instance: Recorder,
+ session_maker: Callable[[], Session],
+ table_name: str,
+ index_name: str,
+ *,
+ base: type[DeclarativeBase] = Base,
) -> None:
"""Create an index for the specified table.
The index name should match the name given for the index
within the table definition described in the models
"""
- table = Table(table_name, Base.metadata)
+ table = Table(table_name, base.metadata)
_LOGGER.debug("Looking up index %s for table %s", index_name, table_name)
# Look up the index object by name from the table is the models
index_list = [idx for idx in table.indexes if idx.name == index_name]
@@ -455,10 +522,18 @@ def _create_index(
connection = session.connection()
index.create(connection)
except (InternalError, OperationalError, ProgrammingError) as err:
+ if _unindexable_legacy_column(instance, base, err):
+ _LOGGER.debug(
+ "Can't add legacy index %s to column %s, continuing",
+ index_name,
+ table_name,
+ )
+ return
raise_if_exception_missing_str(err, ["already exists", "duplicate"])
_LOGGER.warning(
"Index %s already exists on %s, continuing", index_name, table_name
)
+ return
_LOGGER.warning("Finished adding index `%s` to table `%s`", index_name, table_name)
@@ -997,7 +1072,12 @@ class _SchemaVersion2Migrator(_SchemaVersionMigrator, target_version=2):
def _apply_update(self) -> None:
"""Version specific update method."""
# Create compound start/end index for recorder_runs
- _create_index(self.session_maker, "recorder_runs", "ix_recorder_runs_start_end")
+ _create_index(
+ self.instance,
+ self.session_maker,
+ "recorder_runs",
+ "ix_recorder_runs_start_end",
+ )
# This used to create ix_states_last_updated bit it was removed in version 32
@@ -1032,7 +1112,9 @@ class _SchemaVersion5Migrator(_SchemaVersionMigrator, target_version=5):
def _apply_update(self) -> None:
"""Version specific update method."""
# Create supporting index for States.event_id foreign key
- _create_index(self.session_maker, "states", LEGACY_STATES_EVENT_ID_INDEX)
+ _create_index(
+ self.instance, self.session_maker, "states", LEGACY_STATES_EVENT_ID_INDEX
+ )
class _SchemaVersion6Migrator(_SchemaVersionMigrator, target_version=6):
@@ -1043,7 +1125,9 @@ class _SchemaVersion6Migrator(_SchemaVersionMigrator, target_version=6):
"events",
["context_id CHARACTER(36)", "context_user_id CHARACTER(36)"],
)
- _create_index(self.session_maker, "events", "ix_events_context_id")
+ _create_index(
+ self.instance, self.session_maker, "events", "ix_events_context_id"
+ )
# This used to create ix_events_context_user_id,
# but it was removed in version 28
_add_columns(
@@ -1051,7 +1135,9 @@ class _SchemaVersion6Migrator(_SchemaVersionMigrator, target_version=6):
"states",
["context_id CHARACTER(36)", "context_user_id CHARACTER(36)"],
)
- _create_index(self.session_maker, "states", "ix_states_context_id")
+ _create_index(
+ self.instance, self.session_maker, "states", "ix_states_context_id"
+ )
# This used to create ix_states_context_user_id,
# but it was removed in version 28
@@ -1105,7 +1191,9 @@ class _SchemaVersion10Migrator(_SchemaVersionMigrator, target_version=10):
class _SchemaVersion11Migrator(_SchemaVersionMigrator, target_version=11):
def _apply_update(self) -> None:
"""Version specific update method."""
- _create_index(self.session_maker, "states", "ix_states_old_state_id")
+ _create_index(
+ self.instance, self.session_maker, "states", "ix_states_old_state_id"
+ )
# _update_states_table_with_foreign_key_options first drops foreign
# key constraints, and then re-adds them with the correct settings.
@@ -1347,13 +1435,20 @@ class _SchemaVersion25Migrator(_SchemaVersionMigrator, target_version=25):
"states",
[f"attributes_id {self.column_types.big_int_type}"],
)
- _create_index(self.session_maker, "states", "ix_states_attributes_id")
+ _create_index(
+ self.instance, self.session_maker, "states", "ix_states_attributes_id"
+ )
class _SchemaVersion26Migrator(_SchemaVersionMigrator, target_version=26):
def _apply_update(self) -> None:
"""Version specific update method."""
- _create_index(self.session_maker, "statistics_runs", "ix_statistics_runs_start")
+ _create_index(
+ self.instance,
+ self.session_maker,
+ "statistics_runs",
+ "ix_statistics_runs_start",
+ )
class _SchemaVersion27Migrator(_SchemaVersionMigrator, target_version=27):
@@ -1362,7 +1457,7 @@ class _SchemaVersion27Migrator(_SchemaVersionMigrator, target_version=27):
_add_columns(
self.session_maker, "events", [f"data_id {self.column_types.big_int_type}"]
)
- _create_index(self.session_maker, "events", "ix_events_data_id")
+ _create_index(self.instance, self.session_maker, "events", "ix_events_data_id")
class _SchemaVersion28Migrator(_SchemaVersionMigrator, target_version=28):
@@ -1382,7 +1477,9 @@ class _SchemaVersion28Migrator(_SchemaVersionMigrator, target_version=28):
"context_parent_id VARCHAR(36)",
],
)
- _create_index(self.session_maker, "states", "ix_states_context_id")
+ _create_index(
+ self.instance, self.session_maker, "states", "ix_states_context_id"
+ )
# Once there are no longer any state_changed events
# in the events table we can drop the index on states.event_id
@@ -1409,7 +1506,10 @@ class _SchemaVersion29Migrator(_SchemaVersionMigrator, target_version=29):
)
try:
_create_index(
- self.session_maker, "statistics_meta", "ix_statistics_meta_statistic_id"
+ self.instance,
+ self.session_maker,
+ "statistics_meta",
+ "ix_statistics_meta_statistic_id",
)
except DatabaseError:
# There may be duplicated statistics_meta entries, delete duplicates
@@ -1417,7 +1517,10 @@ class _SchemaVersion29Migrator(_SchemaVersionMigrator, target_version=29):
with session_scope(session=self.session_maker()) as session:
delete_statistics_meta_duplicates(self.instance, session)
_create_index(
- self.session_maker, "statistics_meta", "ix_statistics_meta_statistic_id"
+ self.instance,
+ self.session_maker,
+ "statistics_meta",
+ "ix_statistics_meta_statistic_id",
)
@@ -1451,14 +1554,24 @@ class _SchemaVersion31Migrator(_SchemaVersionMigrator, target_version=31):
f"last_changed_ts {self.column_types.timestamp_type}",
],
)
- _create_index(self.session_maker, "events", "ix_events_time_fired_ts")
_create_index(
- self.session_maker, "events", "ix_events_event_type_time_fired_ts"
+ self.instance, self.session_maker, "events", "ix_events_time_fired_ts"
)
_create_index(
- self.session_maker, "states", "ix_states_entity_id_last_updated_ts"
+ self.instance,
+ self.session_maker,
+ "events",
+ "ix_events_event_type_time_fired_ts",
+ )
+ _create_index(
+ self.instance,
+ self.session_maker,
+ "states",
+ "ix_states_entity_id_last_updated_ts",
+ )
+ _create_index(
+ self.instance, self.session_maker, "states", "ix_states_last_updated_ts"
)
- _create_index(self.session_maker, "states", "ix_states_last_updated_ts")
_migrate_columns_to_timestamp(self.instance, self.session_maker, self.engine)
@@ -1516,16 +1629,23 @@ class _SchemaVersion34Migrator(_SchemaVersionMigrator, target_version=34):
f"last_reset_ts {self.column_types.timestamp_type}",
],
)
- _create_index(self.session_maker, "statistics", "ix_statistics_start_ts")
_create_index(
- self.session_maker, "statistics", "ix_statistics_statistic_id_start_ts"
+ self.instance, self.session_maker, "statistics", "ix_statistics_start_ts"
)
_create_index(
+ self.instance,
+ self.session_maker,
+ "statistics",
+ "ix_statistics_statistic_id_start_ts",
+ )
+ _create_index(
+ self.instance,
self.session_maker,
"statistics_short_term",
"ix_statistics_short_term_start_ts",
)
_create_index(
+ self.instance,
self.session_maker,
"statistics_short_term",
"ix_statistics_short_term_statistic_id_start_ts",
@@ -1575,8 +1695,12 @@ class _SchemaVersion36Migrator(_SchemaVersionMigrator, target_version=36):
f"context_parent_id_bin {self.column_types.context_bin_type}",
],
)
- _create_index(self.session_maker, "events", "ix_events_context_id_bin")
- _create_index(self.session_maker, "states", "ix_states_context_id_bin")
+ _create_index(
+ self.instance, self.session_maker, "events", "ix_events_context_id_bin"
+ )
+ _create_index(
+ self.instance, self.session_maker, "states", "ix_states_context_id_bin"
+ )
class _SchemaVersion37Migrator(_SchemaVersionMigrator, target_version=37):
@@ -1587,10 +1711,15 @@ class _SchemaVersion37Migrator(_SchemaVersionMigrator, target_version=37):
"events",
[f"event_type_id {self.column_types.big_int_type}"],
)
- _create_index(self.session_maker, "events", "ix_events_event_type_id")
+ _create_index(
+ self.instance, self.session_maker, "events", "ix_events_event_type_id"
+ )
_drop_index(self.session_maker, "events", "ix_events_event_type_time_fired_ts")
_create_index(
- self.session_maker, "events", "ix_events_event_type_id_time_fired_ts"
+ self.instance,
+ self.session_maker,
+ "events",
+ "ix_events_event_type_id_time_fired_ts",
)
@@ -1602,9 +1731,14 @@ class _SchemaVersion38Migrator(_SchemaVersionMigrator, target_version=38):
"states",
[f"metadata_id {self.column_types.big_int_type}"],
)
- _create_index(self.session_maker, "states", "ix_states_metadata_id")
_create_index(
- self.session_maker, "states", "ix_states_metadata_id_last_updated_ts"
+ self.instance, self.session_maker, "states", "ix_states_metadata_id"
+ )
+ _create_index(
+ self.instance,
+ self.session_maker,
+ "states",
+ "ix_states_metadata_id_last_updated_ts",
)
@@ -1688,8 +1822,15 @@ class _SchemaVersion40Migrator(_SchemaVersionMigrator, target_version=40):
class _SchemaVersion41Migrator(_SchemaVersionMigrator, target_version=41):
def _apply_update(self) -> None:
"""Version specific update method."""
- _create_index(self.session_maker, "event_types", "ix_event_types_event_type")
- _create_index(self.session_maker, "states_meta", "ix_states_meta_entity_id")
+ _create_index(
+ self.instance,
+ self.session_maker,
+ "event_types",
+ "ix_event_types_event_type",
+ )
+ _create_index(
+ self.instance, self.session_maker, "states_meta", "ix_states_meta_entity_id"
+ )
class _SchemaVersion42Migrator(_SchemaVersionMigrator, target_version=42):
@@ -1835,6 +1976,17 @@ class _SchemaVersion47Migrator(_SchemaVersionMigrator, target_version=47):
)
+class _SchemaVersion48Migrator(_SchemaVersionMigrator, target_version=48):
+ def _apply_update(self) -> None:
+ """Version specific update method."""
+ # https://github.com/home-assistant/core/issues/134002
+ # If the system has unmigrated states rows, we need to
+ # ensure they are migrated now so the new optimized
+ # queries can be used. For most systems, this should
+ # be very fast and nothing will be migrated.
+ _migrate_columns_to_timestamp(self.instance, self.session_maker, self.engine)
+
+
def _migrate_statistics_columns_to_timestamp_removing_duplicates(
hass: HomeAssistant,
instance: Recorder,
@@ -1968,7 +2120,8 @@ def _migrate_columns_to_timestamp(
connection.execute(
text(
'UPDATE events set time_fired_ts=strftime("%s",time_fired) + '
- "cast(substr(time_fired,-7) AS FLOAT);"
+ "cast(substr(time_fired,-7) AS FLOAT) "
+ "WHERE time_fired_ts is NULL;"
)
)
connection.execute(
@@ -1976,7 +2129,8 @@ def _migrate_columns_to_timestamp(
'UPDATE states set last_updated_ts=strftime("%s",last_updated) + '
"cast(substr(last_updated,-7) AS FLOAT), "
'last_changed_ts=strftime("%s",last_changed) + '
- "cast(substr(last_changed,-7) AS FLOAT);"
+ "cast(substr(last_changed,-7) AS FLOAT) "
+ " WHERE last_updated_ts is NULL;"
)
)
elif engine.dialect.name == SupportedDialect.MYSQL:
@@ -2233,7 +2387,7 @@ def initialize_database(session_maker: Callable[[], Session]) -> bool:
"""Initialize a new database."""
try:
with session_scope(session=session_maker(), read_only=True) as session:
- if _get_schema_version(session) is not None:
+ if _get_current_schema_version(session) is not None:
return True
with session_scope(session=session_maker()) as session:
@@ -2276,30 +2430,42 @@ class DataMigrationStatus:
class BaseMigration(ABC):
"""Base class for migrations."""
- index_to_drop: tuple[str, str] | None = None
- required_schema_version = 0
+ index_to_drop: tuple[str, str, type[DeclarativeBase]] | None = None
+ required_schema_version = 0 # Schema version required to run migration queries
+ max_initial_schema_version: int # Skip migration if db created after this version
migration_version = 1
migration_id: str
- def __init__(self, schema_version: int, migration_changes: dict[str, int]) -> None:
- """Initialize a new BaseRunTimeMigration."""
- self.schema_version = schema_version
+ def __init__(
+ self,
+ *,
+ initial_schema_version: int,
+ start_schema_version: int,
+ migration_changes: dict[str, int],
+ ) -> None:
+ """Initialize a new BaseRunTimeMigration.
+
+ :param initial_schema_version: The schema version the database was created with.
+ :param start_schema_version: The schema version when starting the migration.
+ """
+ self.initial_schema_version = initial_schema_version
+ self.start_schema_version = start_schema_version
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:
"""Migrate some data, returns True if migration is completed."""
status = self.migrate_data_impl(instance)
if status.migration_done:
- if self.index_to_drop is not None:
- table, index = self.index_to_drop
- _drop_index(instance.get_session, table, index)
with session_scope(session=instance.get_session()) as session:
self.migration_done(instance, session)
_mark_migration_done(session, self.__class__)
+ if self.index_to_drop is not None:
+ table, index, _ = self.index_to_drop
+ _drop_index(instance.get_session, table, index)
return not status.needs_migrate
@abstractmethod
@@ -2324,26 +2490,62 @@ class BaseMigration(ABC):
mark the migration as done in the database if its not already
marked as done.
"""
- if self.schema_version < self.required_schema_version:
+ if self.initial_schema_version > self.max_initial_schema_version:
+ _LOGGER.debug(
+ "Data migration '%s' not needed, database created with version %s "
+ "after migrator was added",
+ self.migration_id,
+ self.initial_schema_version,
+ )
+ return False
+ if self.start_schema_version < self.required_schema_version:
# Schema is too old, we must have to migrate
+ _LOGGER.info(
+ "Data migration '%s' needed, schema too old", self.migration_id
+ )
+ return True
+ has_needed_index = self._has_needed_index(session)
+ if has_needed_index is True:
+ # The index to be removed by the migration still exists
+ _LOGGER.info(
+ "Data migration '%s' needed, index to drop still exists",
+ self.migration_id,
+ )
return True
if self.migration_changes.get(self.migration_id, -1) >= self.migration_version:
# The migration changes table indicates that the migration has been done
+ _LOGGER.debug(
+ "Data migration '%s' not needed, already completed", self.migration_id
+ )
return False
- # We do not know if the migration is done from the
- # migration changes table so we must check the index and data
- # This is the slow path
- if (
- self.index_to_drop is not None
- and get_index_by_name(session, self.index_to_drop[0], self.index_to_drop[1])
- is not None
- ):
+ if has_needed_index is False:
+ # The index to be removed by the migration does not exist, but the migration
+ # changes table indicates that the migration has not been done
+ _LOGGER.info(
+ "Data migration '%s' needed, index to drop does not exist",
+ self.migration_id,
+ )
return True
+ # We do not know if the migration is done from the
+ # migration changes table or the index so we must check the data
+ # This is the slow path
needs_migrate = self.needs_migrate_impl(instance, session)
if needs_migrate.migration_done:
_mark_migration_done(session, self.__class__)
+ _LOGGER.info(
+ "Data migration '%s' needed: %s",
+ self.migration_id,
+ needs_migrate.needs_migrate,
+ )
return needs_migrate.needs_migrate
+ def _has_needed_index(self, session: Session) -> bool | None:
+ """Check if the index needed by the migration exists."""
+ if self.index_to_drop is None:
+ return None
+ table_name, index_name, _ = self.index_to_drop
+ return get_index_by_name(session, table_name, index_name) is not None
+
class BaseOffLineMigration(BaseMigration):
"""Base class for off line migrations."""
@@ -2354,16 +2556,43 @@ class BaseOffLineMigration(BaseMigration):
"""Migrate all data."""
with session_scope(session=session_maker()) as session:
if not self.needs_migrate(instance, session):
+ _LOGGER.debug("Migration not needed for '%s'", self.migration_id)
self.migration_done(instance, session)
return
+ self._ensure_index_exists(instance)
+ _LOGGER.warning(
+ "The database is about to do data migration step '%s', %s",
+ self.migration_id,
+ MIGRATION_NOTE_OFFLINE,
+ )
while not self.migrate_data(instance):
pass
+ _LOGGER.warning("Data migration step '%s' completed", self.migration_id)
@database_job_retry_wrapper_method("migrate data", 10)
def migrate_data(self, instance: Recorder) -> bool:
"""Migrate some data, returns True if migration is completed."""
return self._migrate_data(instance)
+ def _ensure_index_exists(self, instance: Recorder) -> None:
+ """Ensure the index needed by the migration exists."""
+ if not self.index_to_drop:
+ return
+ table_name, index_name, base = self.index_to_drop
+ with session_scope(session=instance.get_session()) as session:
+ if get_index_by_name(session, table_name, index_name) is not None:
+ return
+ _LOGGER.warning(
+ (
+ "Data migration step '%s' needs index `%s` on table `%s`, but "
+ "it does not exist and will be added now"
+ ),
+ self.migration_id,
+ index_name,
+ table_name,
+ )
+ _create_index(instance, instance.get_session, table_name, index_name, base=base)
+
class BaseRunTimeMigration(BaseMigration):
"""Base class for run time migrations."""
@@ -2404,9 +2633,10 @@ class StatesContextIDMigration(BaseMigrationWithQuery, BaseOffLineMigration):
"""Migration to migrate states context_ids to binary format."""
required_schema_version = CONTEXT_ID_AS_BINARY_SCHEMA_VERSION
+ max_initial_schema_version = CONTEXT_ID_AS_BINARY_SCHEMA_VERSION - 1
migration_id = "state_context_id_as_binary"
migration_version = 2
- index_to_drop = ("states", "ix_states_context_id")
+ index_to_drop = ("states", "ix_states_context_id", LegacyBase)
def migrate_data_impl(self, instance: Recorder) -> DataMigrationStatus:
"""Migrate states context_ids to use binary format, return True if completed."""
@@ -2447,9 +2677,10 @@ class EventsContextIDMigration(BaseMigrationWithQuery, BaseOffLineMigration):
"""Migration to migrate events context_ids to binary format."""
required_schema_version = CONTEXT_ID_AS_BINARY_SCHEMA_VERSION
+ max_initial_schema_version = CONTEXT_ID_AS_BINARY_SCHEMA_VERSION - 1
migration_id = "event_context_id_as_binary"
migration_version = 2
- index_to_drop = ("events", "ix_events_context_id")
+ index_to_drop = ("events", "ix_events_context_id", LegacyBase)
def migrate_data_impl(self, instance: Recorder) -> DataMigrationStatus:
"""Migrate events context_ids to use binary format, return True if completed."""
@@ -2486,15 +2717,12 @@ class EventsContextIDMigration(BaseMigrationWithQuery, BaseOffLineMigration):
return has_events_context_ids_to_migrate()
-class EventTypeIDMigration(BaseMigrationWithQuery, BaseRunTimeMigration):
+class EventTypeIDMigration(BaseMigrationWithQuery, BaseOffLineMigration):
"""Migration to migrate event_type to event_type_ids."""
required_schema_version = EVENT_TYPE_IDS_SCHEMA_VERSION
+ max_initial_schema_version = EVENT_TYPE_IDS_SCHEMA_VERSION - 1
migration_id = "event_type_id_migration"
- task = CommitBeforeMigrationTask
- # We have to commit before to make sure there are
- # no new pending event_types about to be added to
- # the db since this happens live
def migrate_data_impl(self, instance: Recorder) -> DataMigrationStatus:
"""Migrate event_type to event_type_ids, return True if completed."""
@@ -2554,25 +2782,17 @@ class EventTypeIDMigration(BaseMigrationWithQuery, BaseRunTimeMigration):
_LOGGER.debug("Migrating event_types done=%s", is_done)
return DataMigrationStatus(needs_migrate=not is_done, migration_done=is_done)
- def migration_done(self, instance: Recorder, session: Session) -> None:
- """Will be called after migrate returns True."""
- _LOGGER.debug("Activating event_types manager as all data is migrated")
- instance.event_type_manager.active = True
-
def needs_migrate_query(self) -> StatementLambdaElement:
"""Check if the data is migrated."""
return has_event_type_to_migrate()
-class EntityIDMigration(BaseMigrationWithQuery, BaseRunTimeMigration):
+class EntityIDMigration(BaseMigrationWithQuery, BaseOffLineMigration):
"""Migration to migrate entity_ids to states_meta."""
required_schema_version = STATES_META_SCHEMA_VERSION
+ max_initial_schema_version = STATES_META_SCHEMA_VERSION - 1
migration_id = "entity_id_migration"
- task = CommitBeforeMigrationTask
- # We have to commit before to make sure there are
- # no new pending states_meta about to be added to
- # the db since this happens live
def migrate_data_impl(self, instance: Recorder) -> DataMigrationStatus:
"""Migrate entity_ids to states_meta, return True if completed.
@@ -2642,18 +2862,6 @@ class EntityIDMigration(BaseMigrationWithQuery, BaseRunTimeMigration):
_LOGGER.debug("Migrating entity_ids done=%s", is_done)
return DataMigrationStatus(needs_migrate=not is_done, migration_done=is_done)
- def migration_done(self, instance: Recorder, session: Session) -> None:
- """Will be called after migrate returns True."""
- # The migration has finished, now we start the post migration
- # to remove the old entity_id data from the states table
- # at this point we can also start using the StatesMeta table
- # so we set active to True
- _LOGGER.debug("Activating states_meta manager as all data is migrated")
- instance.states_meta_manager.active = True
- with contextlib.suppress(SQLAlchemyError):
- migrate = EntityIDPostMigration(self.schema_version, self.migration_changes)
- migrate.queue_migration(instance, session)
-
def needs_migrate_query(self) -> StatementLambdaElement:
"""Check if the data is migrated."""
return has_entity_ids_to_migrate()
@@ -2663,6 +2871,7 @@ class EventIDPostMigration(BaseRunTimeMigration):
"""Migration to remove old event_id index from states."""
migration_id = "event_id_post_migration"
+ max_initial_schema_version = LEGACY_STATES_EVENT_ID_INDEX_SCHEMA_VERSION - 1
task = MigrationTask
migration_version = 2
@@ -2731,7 +2940,7 @@ class EventIDPostMigration(BaseRunTimeMigration):
self, instance: Recorder, session: Session
) -> DataMigrationStatus:
"""Return if the migration needs to run."""
- if self.schema_version <= LEGACY_STATES_EVENT_ID_INDEX_SCHEMA_VERSION:
+ if self.start_schema_version <= LEGACY_STATES_EVENT_ID_INDEX_SCHEMA_VERSION:
return DataMigrationStatus(needs_migrate=False, migration_done=False)
if get_index_by_name(
session, TABLE_STATES, LEGACY_STATES_EVENT_ID_INDEX
@@ -2741,12 +2950,19 @@ class EventIDPostMigration(BaseRunTimeMigration):
return DataMigrationStatus(needs_migrate=False, migration_done=True)
-class EntityIDPostMigration(BaseMigrationWithQuery, BaseRunTimeMigration):
- """Migration to remove old entity_id strings from states."""
+class EntityIDPostMigration(BaseMigrationWithQuery, BaseOffLineMigration):
+ """Migration to remove old entity_id strings from states.
+
+ Introduced in HA Core 2023.4 by PR #89557.
+ """
migration_id = "entity_id_post_migration"
- task = MigrationTask
- index_to_drop = (TABLE_STATES, LEGACY_STATES_ENTITY_ID_LAST_UPDATED_INDEX)
+ max_initial_schema_version = STATES_META_SCHEMA_VERSION - 1
+ index_to_drop = (
+ TABLE_STATES,
+ LEGACY_STATES_ENTITY_ID_LAST_UPDATED_TS_INDEX,
+ LegacyBase,
+ )
def migrate_data_impl(self, instance: Recorder) -> DataMigrationStatus:
"""Migrate some data, returns True if migration is completed."""
@@ -2758,15 +2974,16 @@ class EntityIDPostMigration(BaseMigrationWithQuery, BaseRunTimeMigration):
return has_used_states_entity_ids()
-NON_LIVE_DATA_MIGRATORS = (
- StatesContextIDMigration, # Introduced in HA Core 2023.4
- EventsContextIDMigration, # Introduced in HA Core 2023.4
+NON_LIVE_DATA_MIGRATORS: tuple[type[BaseOffLineMigration], ...] = (
+ StatesContextIDMigration, # Introduced in HA Core 2023.4 by PR #88942
+ EventsContextIDMigration, # Introduced in HA Core 2023.4 by PR #88942
+ EventTypeIDMigration, # Introduced in HA Core 2023.4 by PR #89465
+ EntityIDMigration, # Introduced in HA Core 2023.4 by PR #89557
+ EntityIDPostMigration, # Introduced in HA Core 2023.4 by PR #89557
)
-LIVE_DATA_MIGRATORS = (
- EventTypeIDMigration,
- EntityIDMigration,
- EventIDPostMigration,
+LIVE_DATA_MIGRATORS: tuple[type[BaseRunTimeMigration], ...] = (
+ EventIDPostMigration, # Introduced in HA Core 2023.4 by PR #89901
)
diff --git a/homeassistant/components/recorder/models/database.py b/homeassistant/components/recorder/models/database.py
index 94c5a7cc027..b86fd299793 100644
--- a/homeassistant/components/recorder/models/database.py
+++ b/homeassistant/components/recorder/models/database.py
@@ -32,4 +32,8 @@ class DatabaseOptimizer:
#
# https://jira.mariadb.org/browse/MDEV-25020
#
+ # PostgreSQL does not support a skip/loose index scan so its
+ # also slow for large distinct queries:
+ # https://wiki.postgresql.org/wiki/Loose_indexscan
+ # https://github.com/home-assistant/core/issues/126084
slow_range_in_select: bool
diff --git a/homeassistant/components/recorder/models/legacy.py b/homeassistant/components/recorder/models/legacy.py
index 21a8a39ba0f..a469aa49ab2 100644
--- a/homeassistant/components/recorder/models/legacy.py
+++ b/homeassistant/components/recorder/models/legacy.py
@@ -46,7 +46,7 @@ class LegacyLazyState(State):
self.state = self._row.state or ""
self._attributes: dict[str, Any] | None = None
self._last_updated_ts: float | None = self._row.last_updated_ts or (
- dt_util.utc_to_timestamp(start_time) if start_time else None
+ start_time.timestamp() if start_time else None
)
self._last_changed_ts: float | None = (
self._row.last_changed_ts or self._last_updated_ts
@@ -146,7 +146,7 @@ def legacy_row_to_compressed_state(
COMPRESSED_STATE_ATTRIBUTES: decode_attributes_from_row_legacy(row, attr_cache),
}
if start_time:
- comp_state[COMPRESSED_STATE_LAST_UPDATED] = dt_util.utc_to_timestamp(start_time)
+ comp_state[COMPRESSED_STATE_LAST_UPDATED] = start_time.timestamp()
else:
row_last_updated_ts: float = row.last_updated_ts
comp_state[COMPRESSED_STATE_LAST_UPDATED] = row_last_updated_ts
diff --git a/homeassistant/components/recorder/models/state.py b/homeassistant/components/recorder/models/state.py
index 89281a85c15..fbf73e75025 100644
--- a/homeassistant/components/recorder/models/state.py
+++ b/homeassistant/components/recorder/models/state.py
@@ -96,6 +96,29 @@ class LazyState(State):
assert self._last_updated_ts is not None
return dt_util.utc_from_timestamp(self._last_updated_ts)
+ @cached_property
+ def last_updated_timestamp(self) -> float: # type: ignore[override]
+ """Last updated timestamp."""
+ if TYPE_CHECKING:
+ assert self._last_updated_ts is not None
+ return self._last_updated_ts
+
+ @cached_property
+ def last_changed_timestamp(self) -> float: # type: ignore[override]
+ """Last changed timestamp."""
+ ts = self._last_changed_ts or self._last_updated_ts
+ if TYPE_CHECKING:
+ assert ts is not None
+ return ts
+
+ @cached_property
+ def last_reported_timestamp(self) -> float: # type: ignore[override]
+ """Last reported timestamp."""
+ ts = self._last_reported_ts or self._last_updated_ts
+ if TYPE_CHECKING:
+ assert ts is not None
+ return ts
+
def as_dict(self) -> dict[str, Any]: # type: ignore[override]
"""Return a dict representation of the LazyState.
diff --git a/homeassistant/components/recorder/pool.py b/homeassistant/components/recorder/pool.py
index 30f8fa8d07a..fc2a8ccb1cc 100644
--- a/homeassistant/components/recorder/pool.py
+++ b/homeassistant/components/recorder/pool.py
@@ -16,7 +16,7 @@ from sqlalchemy.pool import (
StaticPool,
)
-from homeassistant.helpers.frame import report
+from homeassistant.helpers.frame import ReportBehavior, report_usage
from homeassistant.util.loop import raise_for_blocking_call
_LOGGER = logging.getLogger(__name__)
@@ -108,14 +108,14 @@ class RecorderPool(SingletonThreadPool, NullPool):
# raise_for_blocking_call will raise an exception
def _do_get_db_connection_protected(self) -> ConnectionPoolEntry:
- report(
+ report_usage(
(
"accesses the database without the database executor; "
f"{ADVISE_MSG} "
"for faster database operations"
),
exclude_integrations={"recorder"},
- error_if_core=False,
+ core_behavior=ReportBehavior.LOG,
)
return NullPool._create_connection(self) # noqa: SLF001
diff --git a/homeassistant/components/recorder/purge.py b/homeassistant/components/recorder/purge.py
index d28e7e2a547..ea2b93efba7 100644
--- a/homeassistant/components/recorder/purge.py
+++ b/homeassistant/components/recorder/purge.py
@@ -4,7 +4,6 @@ from __future__ import annotations
from collections.abc import Callable
from datetime import datetime
-from itertools import zip_longest
import logging
import time
from typing import TYPE_CHECKING
@@ -110,19 +109,21 @@ def purge_old_data(
_LOGGER.debug("Purging hasn't fully completed yet")
return False
- if apply_filter and _purge_filtered_data(instance, session) is False:
+ if apply_filter and not _purge_filtered_data(instance, session):
_LOGGER.debug("Cleanup filtered data hasn't fully completed yet")
return False
# This purge cycle is finished, clean up old event types and
# recorder runs
- if instance.event_type_manager.active:
- _purge_old_event_types(instance, session)
+ _purge_old_event_types(instance, session)
if instance.states_meta_manager.active:
_purge_old_entity_ids(instance, session)
_purge_old_recorder_runs(instance, session, purge_before)
+ with session_scope(session=instance.get_session(), read_only=True) as session:
+ instance.recorder_runs_manager.load_from_db(session)
+ instance.states_manager.load_from_db(session)
if repack:
repack_database(instance)
return True
@@ -295,64 +296,18 @@ def _select_unused_attributes_ids(
seen_ids: set[int] = set()
if not database_engine.optimizer.slow_range_in_select:
- #
+ query = attributes_ids_exist_in_states_with_fast_in_distinct
# SQLite has a superior query optimizer for the distinct query below as it uses
# the covering index without having to examine the rows directly for both of the
# queries below.
- #
- # We use the distinct query for SQLite since the query in the other branch can
- # generate more than 500 unions which SQLite does not support.
- #
- # How MariaDB's query optimizer handles this query:
- # > explain select distinct attributes_id from states where attributes_id in
- # (136723);
- # ...Using index
- #
- for attributes_ids_chunk in chunked_or_all(
- attributes_ids, instance.max_bind_vars
- ):
- seen_ids.update(
- state[0]
- for state in session.execute(
- attributes_ids_exist_in_states_with_fast_in_distinct(
- attributes_ids_chunk
- )
- ).all()
- )
else:
- #
+ query = attributes_ids_exist_in_states
# This branch is for DBMS that cannot optimize the distinct query well and has
# to examine all the rows that match.
- #
- # This branch uses a union of simple queries, as each query is optimized away
- # as the answer to the query can be found in the index.
- #
- # The below query works for SQLite as long as there are no more than 500
- # attributes_id to be selected. We currently do not have MySQL or PostgreSQL
- # servers running in the test suite; we test this path using SQLite when there
- # are less than 500 attributes_id.
- #
- # How MariaDB's query optimizer handles this query:
- # > explain select min(attributes_id) from states where attributes_id = 136723;
- # ...Select tables optimized away
- #
- # We used to generate a query based on how many attribute_ids to find but
- # that meant sqlalchemy Transparent SQL Compilation Caching was working against
- # us by cached up to max_bind_vars different statements which could be
- # up to 500MB for large database due to the complexity of the ORM objects.
- #
- # We now break the query into groups of 100 and use a lambda_stmt to ensure
- # that the query is only cached once.
- #
- groups = [iter(attributes_ids)] * 100
- for attr_ids in zip_longest(*groups, fillvalue=None):
- seen_ids |= {
- attrs_id[0]
- for attrs_id in session.execute(
- attributes_ids_exist_in_states(*attr_ids) # type: ignore[arg-type]
- ).all()
- if attrs_id[0] is not None
- }
+ for attributes_ids_chunk in chunked_or_all(attributes_ids, instance.max_bind_vars):
+ seen_ids.update(
+ state[0] for state in session.execute(query(attributes_ids_chunk)).all()
+ )
to_remove = attributes_ids - seen_ids
_LOGGER.debug(
"Selected %s shared attributes to remove",
@@ -389,23 +344,13 @@ def _select_unused_event_data_ids(
# See _select_unused_attributes_ids for why this function
# branches for non-sqlite databases.
if not database_engine.optimizer.slow_range_in_select:
- for data_ids_chunk in chunked_or_all(data_ids, instance.max_bind_vars):
- seen_ids.update(
- state[0]
- for state in session.execute(
- data_ids_exist_in_events_with_fast_in_distinct(data_ids_chunk)
- ).all()
- )
+ query = data_ids_exist_in_events_with_fast_in_distinct
else:
- groups = [iter(data_ids)] * 100
- for data_ids_group in zip_longest(*groups, fillvalue=None):
- seen_ids |= {
- data_id[0]
- for data_id in session.execute(
- data_ids_exist_in_events(*data_ids_group) # type: ignore[arg-type]
- ).all()
- if data_id[0] is not None
- }
+ query = data_ids_exist_in_events
+ for data_ids_chunk in chunked_or_all(data_ids, instance.max_bind_vars):
+ seen_ids.update(
+ state[0] for state in session.execute(query(data_ids_chunk)).all()
+ )
to_remove = data_ids - seen_ids
_LOGGER.debug("Selected %s shared event data to remove", len(to_remove))
return to_remove
@@ -631,7 +576,10 @@ def _purge_old_entity_ids(instance: Recorder, session: Session) -> None:
def _purge_filtered_data(instance: Recorder, session: Session) -> bool:
- """Remove filtered states and events that shouldn't be in the database."""
+ """Remove filtered states and events that shouldn't be in the database.
+
+ Returns true if all states and events are purged.
+ """
_LOGGER.debug("Cleanup filtered data")
database_engine = instance.database_engine
assert database_engine is not None
@@ -639,7 +587,7 @@ def _purge_filtered_data(instance: Recorder, session: Session) -> bool:
# Check if excluded entity_ids are in database
entity_filter = instance.entity_filter
- has_more_states_to_purge = False
+ has_more_to_purge = False
excluded_metadata_ids: list[str] = [
metadata_id
for (metadata_id, entity_id) in session.query(
@@ -648,12 +596,11 @@ def _purge_filtered_data(instance: Recorder, session: Session) -> bool:
if entity_filter and not entity_filter(entity_id)
]
if excluded_metadata_ids:
- has_more_states_to_purge = _purge_filtered_states(
+ has_more_to_purge |= not _purge_filtered_states(
instance, session, excluded_metadata_ids, database_engine, now_timestamp
)
# Check if excluded event_types are in database
- has_more_events_to_purge = False
if (
event_type_to_event_type_ids := instance.event_type_manager.get_many(
instance.exclude_event_types, session
@@ -665,12 +612,12 @@ def _purge_filtered_data(instance: Recorder, session: Session) -> bool:
if event_type_id is not None
]
):
- has_more_events_to_purge = _purge_filtered_events(
+ has_more_to_purge |= not _purge_filtered_events(
instance, session, excluded_event_type_ids, now_timestamp
)
# Purge has completed if there are not more state or events to purge
- return not (has_more_states_to_purge or has_more_events_to_purge)
+ return not has_more_to_purge
def _purge_filtered_states(
diff --git a/homeassistant/components/recorder/queries.py b/homeassistant/components/recorder/queries.py
index 4acf43a491e..eb681f86702 100644
--- a/homeassistant/components/recorder/queries.py
+++ b/homeassistant/components/recorder/queries.py
@@ -5,7 +5,7 @@ from __future__ import annotations
from collections.abc import Iterable
from datetime import datetime
-from sqlalchemy import delete, distinct, func, lambda_stmt, select, union_all, update
+from sqlalchemy import and_, delete, distinct, func, lambda_stmt, select, update
from sqlalchemy.sql.lambdas import StatementLambdaElement
from sqlalchemy.sql.selectable import Select
@@ -76,11 +76,6 @@ def find_states_metadata_ids(entity_ids: Iterable[str]) -> StatementLambdaElemen
)
-def _state_attrs_exist(attr: int | None) -> Select:
- """Check if a state attributes id exists in the states table."""
- return select(func.min(States.attributes_id)).where(States.attributes_id == attr)
-
-
def attributes_ids_exist_in_states_with_fast_in_distinct(
attributes_ids: Iterable[int],
) -> StatementLambdaElement:
@@ -93,214 +88,35 @@ def attributes_ids_exist_in_states_with_fast_in_distinct(
def attributes_ids_exist_in_states(
- attr1: int,
- attr2: int | None,
- attr3: int | None,
- attr4: int | None,
- attr5: int | None,
- attr6: int | None,
- attr7: int | None,
- attr8: int | None,
- attr9: int | None,
- attr10: int | None,
- attr11: int | None,
- attr12: int | None,
- attr13: int | None,
- attr14: int | None,
- attr15: int | None,
- attr16: int | None,
- attr17: int | None,
- attr18: int | None,
- attr19: int | None,
- attr20: int | None,
- attr21: int | None,
- attr22: int | None,
- attr23: int | None,
- attr24: int | None,
- attr25: int | None,
- attr26: int | None,
- attr27: int | None,
- attr28: int | None,
- attr29: int | None,
- attr30: int | None,
- attr31: int | None,
- attr32: int | None,
- attr33: int | None,
- attr34: int | None,
- attr35: int | None,
- attr36: int | None,
- attr37: int | None,
- attr38: int | None,
- attr39: int | None,
- attr40: int | None,
- attr41: int | None,
- attr42: int | None,
- attr43: int | None,
- attr44: int | None,
- attr45: int | None,
- attr46: int | None,
- attr47: int | None,
- attr48: int | None,
- attr49: int | None,
- attr50: int | None,
- attr51: int | None,
- attr52: int | None,
- attr53: int | None,
- attr54: int | None,
- attr55: int | None,
- attr56: int | None,
- attr57: int | None,
- attr58: int | None,
- attr59: int | None,
- attr60: int | None,
- attr61: int | None,
- attr62: int | None,
- attr63: int | None,
- attr64: int | None,
- attr65: int | None,
- attr66: int | None,
- attr67: int | None,
- attr68: int | None,
- attr69: int | None,
- attr70: int | None,
- attr71: int | None,
- attr72: int | None,
- attr73: int | None,
- attr74: int | None,
- attr75: int | None,
- attr76: int | None,
- attr77: int | None,
- attr78: int | None,
- attr79: int | None,
- attr80: int | None,
- attr81: int | None,
- attr82: int | None,
- attr83: int | None,
- attr84: int | None,
- attr85: int | None,
- attr86: int | None,
- attr87: int | None,
- attr88: int | None,
- attr89: int | None,
- attr90: int | None,
- attr91: int | None,
- attr92: int | None,
- attr93: int | None,
- attr94: int | None,
- attr95: int | None,
- attr96: int | None,
- attr97: int | None,
- attr98: int | None,
- attr99: int | None,
- attr100: int | None,
+ attributes_ids: Iterable[int],
) -> StatementLambdaElement:
- """Generate the find attributes select only once.
+ """Find attributes ids that exist in the states table.
- https://docs.sqlalchemy.org/en/14/core/connections.html#quick-guidelines-for-lambdas
+ PostgreSQL does not support skip/loose index scan
+ https://wiki.postgresql.org/wiki/Loose_indexscan
+
+ To avoid using distinct, we use a subquery to get the latest last_updated_ts
+ for each attributes_id. This is then used to filter out the attributes_id
+ that no longer exist in the States table.
+
+ This query is fast for older MariaDB, older MySQL, and PostgreSQL.
"""
return lambda_stmt(
- lambda: union_all(
- _state_attrs_exist(attr1),
- _state_attrs_exist(attr2),
- _state_attrs_exist(attr3),
- _state_attrs_exist(attr4),
- _state_attrs_exist(attr5),
- _state_attrs_exist(attr6),
- _state_attrs_exist(attr7),
- _state_attrs_exist(attr8),
- _state_attrs_exist(attr9),
- _state_attrs_exist(attr10),
- _state_attrs_exist(attr11),
- _state_attrs_exist(attr12),
- _state_attrs_exist(attr13),
- _state_attrs_exist(attr14),
- _state_attrs_exist(attr15),
- _state_attrs_exist(attr16),
- _state_attrs_exist(attr17),
- _state_attrs_exist(attr18),
- _state_attrs_exist(attr19),
- _state_attrs_exist(attr20),
- _state_attrs_exist(attr21),
- _state_attrs_exist(attr22),
- _state_attrs_exist(attr23),
- _state_attrs_exist(attr24),
- _state_attrs_exist(attr25),
- _state_attrs_exist(attr26),
- _state_attrs_exist(attr27),
- _state_attrs_exist(attr28),
- _state_attrs_exist(attr29),
- _state_attrs_exist(attr30),
- _state_attrs_exist(attr31),
- _state_attrs_exist(attr32),
- _state_attrs_exist(attr33),
- _state_attrs_exist(attr34),
- _state_attrs_exist(attr35),
- _state_attrs_exist(attr36),
- _state_attrs_exist(attr37),
- _state_attrs_exist(attr38),
- _state_attrs_exist(attr39),
- _state_attrs_exist(attr40),
- _state_attrs_exist(attr41),
- _state_attrs_exist(attr42),
- _state_attrs_exist(attr43),
- _state_attrs_exist(attr44),
- _state_attrs_exist(attr45),
- _state_attrs_exist(attr46),
- _state_attrs_exist(attr47),
- _state_attrs_exist(attr48),
- _state_attrs_exist(attr49),
- _state_attrs_exist(attr50),
- _state_attrs_exist(attr51),
- _state_attrs_exist(attr52),
- _state_attrs_exist(attr53),
- _state_attrs_exist(attr54),
- _state_attrs_exist(attr55),
- _state_attrs_exist(attr56),
- _state_attrs_exist(attr57),
- _state_attrs_exist(attr58),
- _state_attrs_exist(attr59),
- _state_attrs_exist(attr60),
- _state_attrs_exist(attr61),
- _state_attrs_exist(attr62),
- _state_attrs_exist(attr63),
- _state_attrs_exist(attr64),
- _state_attrs_exist(attr65),
- _state_attrs_exist(attr66),
- _state_attrs_exist(attr67),
- _state_attrs_exist(attr68),
- _state_attrs_exist(attr69),
- _state_attrs_exist(attr70),
- _state_attrs_exist(attr71),
- _state_attrs_exist(attr72),
- _state_attrs_exist(attr73),
- _state_attrs_exist(attr74),
- _state_attrs_exist(attr75),
- _state_attrs_exist(attr76),
- _state_attrs_exist(attr77),
- _state_attrs_exist(attr78),
- _state_attrs_exist(attr79),
- _state_attrs_exist(attr80),
- _state_attrs_exist(attr81),
- _state_attrs_exist(attr82),
- _state_attrs_exist(attr83),
- _state_attrs_exist(attr84),
- _state_attrs_exist(attr85),
- _state_attrs_exist(attr86),
- _state_attrs_exist(attr87),
- _state_attrs_exist(attr88),
- _state_attrs_exist(attr89),
- _state_attrs_exist(attr90),
- _state_attrs_exist(attr91),
- _state_attrs_exist(attr92),
- _state_attrs_exist(attr93),
- _state_attrs_exist(attr94),
- _state_attrs_exist(attr95),
- _state_attrs_exist(attr96),
- _state_attrs_exist(attr97),
- _state_attrs_exist(attr98),
- _state_attrs_exist(attr99),
- _state_attrs_exist(attr100),
+ lambda: select(StateAttributes.attributes_id)
+ .select_from(StateAttributes)
+ .join(
+ States,
+ and_(
+ States.attributes_id == StateAttributes.attributes_id,
+ States.last_updated_ts
+ == select(States.last_updated_ts)
+ .where(States.attributes_id == StateAttributes.attributes_id)
+ .limit(1)
+ .scalar_subquery()
+ .correlate(StateAttributes),
+ ),
)
+ .where(StateAttributes.attributes_id.in_(attributes_ids))
)
@@ -313,220 +129,36 @@ def data_ids_exist_in_events_with_fast_in_distinct(
)
-def _event_data_id_exist(data_id: int | None) -> Select:
- """Check if a event data id exists in the events table."""
- return select(func.min(Events.data_id)).where(Events.data_id == data_id)
-
-
def data_ids_exist_in_events(
- id1: int,
- id2: int | None,
- id3: int | None,
- id4: int | None,
- id5: int | None,
- id6: int | None,
- id7: int | None,
- id8: int | None,
- id9: int | None,
- id10: int | None,
- id11: int | None,
- id12: int | None,
- id13: int | None,
- id14: int | None,
- id15: int | None,
- id16: int | None,
- id17: int | None,
- id18: int | None,
- id19: int | None,
- id20: int | None,
- id21: int | None,
- id22: int | None,
- id23: int | None,
- id24: int | None,
- id25: int | None,
- id26: int | None,
- id27: int | None,
- id28: int | None,
- id29: int | None,
- id30: int | None,
- id31: int | None,
- id32: int | None,
- id33: int | None,
- id34: int | None,
- id35: int | None,
- id36: int | None,
- id37: int | None,
- id38: int | None,
- id39: int | None,
- id40: int | None,
- id41: int | None,
- id42: int | None,
- id43: int | None,
- id44: int | None,
- id45: int | None,
- id46: int | None,
- id47: int | None,
- id48: int | None,
- id49: int | None,
- id50: int | None,
- id51: int | None,
- id52: int | None,
- id53: int | None,
- id54: int | None,
- id55: int | None,
- id56: int | None,
- id57: int | None,
- id58: int | None,
- id59: int | None,
- id60: int | None,
- id61: int | None,
- id62: int | None,
- id63: int | None,
- id64: int | None,
- id65: int | None,
- id66: int | None,
- id67: int | None,
- id68: int | None,
- id69: int | None,
- id70: int | None,
- id71: int | None,
- id72: int | None,
- id73: int | None,
- id74: int | None,
- id75: int | None,
- id76: int | None,
- id77: int | None,
- id78: int | None,
- id79: int | None,
- id80: int | None,
- id81: int | None,
- id82: int | None,
- id83: int | None,
- id84: int | None,
- id85: int | None,
- id86: int | None,
- id87: int | None,
- id88: int | None,
- id89: int | None,
- id90: int | None,
- id91: int | None,
- id92: int | None,
- id93: int | None,
- id94: int | None,
- id95: int | None,
- id96: int | None,
- id97: int | None,
- id98: int | None,
- id99: int | None,
- id100: int | None,
+ data_ids: Iterable[int],
) -> StatementLambdaElement:
- """Generate the find event data select only once.
+ """Find data ids that exist in the events table.
- https://docs.sqlalchemy.org/en/14/core/connections.html#quick-guidelines-for-lambdas
+ PostgreSQL does not support skip/loose index scan
+ https://wiki.postgresql.org/wiki/Loose_indexscan
+
+ To avoid using distinct, we use a subquery to get the latest time_fired_ts
+ for each data_id. This is then used to filter out the data_id
+ that no longer exist in the Events table.
+
+ This query is fast for older MariaDB, older MySQL, and PostgreSQL.
"""
return lambda_stmt(
- lambda: union_all(
- _event_data_id_exist(id1),
- _event_data_id_exist(id2),
- _event_data_id_exist(id3),
- _event_data_id_exist(id4),
- _event_data_id_exist(id5),
- _event_data_id_exist(id6),
- _event_data_id_exist(id7),
- _event_data_id_exist(id8),
- _event_data_id_exist(id9),
- _event_data_id_exist(id10),
- _event_data_id_exist(id11),
- _event_data_id_exist(id12),
- _event_data_id_exist(id13),
- _event_data_id_exist(id14),
- _event_data_id_exist(id15),
- _event_data_id_exist(id16),
- _event_data_id_exist(id17),
- _event_data_id_exist(id18),
- _event_data_id_exist(id19),
- _event_data_id_exist(id20),
- _event_data_id_exist(id21),
- _event_data_id_exist(id22),
- _event_data_id_exist(id23),
- _event_data_id_exist(id24),
- _event_data_id_exist(id25),
- _event_data_id_exist(id26),
- _event_data_id_exist(id27),
- _event_data_id_exist(id28),
- _event_data_id_exist(id29),
- _event_data_id_exist(id30),
- _event_data_id_exist(id31),
- _event_data_id_exist(id32),
- _event_data_id_exist(id33),
- _event_data_id_exist(id34),
- _event_data_id_exist(id35),
- _event_data_id_exist(id36),
- _event_data_id_exist(id37),
- _event_data_id_exist(id38),
- _event_data_id_exist(id39),
- _event_data_id_exist(id40),
- _event_data_id_exist(id41),
- _event_data_id_exist(id42),
- _event_data_id_exist(id43),
- _event_data_id_exist(id44),
- _event_data_id_exist(id45),
- _event_data_id_exist(id46),
- _event_data_id_exist(id47),
- _event_data_id_exist(id48),
- _event_data_id_exist(id49),
- _event_data_id_exist(id50),
- _event_data_id_exist(id51),
- _event_data_id_exist(id52),
- _event_data_id_exist(id53),
- _event_data_id_exist(id54),
- _event_data_id_exist(id55),
- _event_data_id_exist(id56),
- _event_data_id_exist(id57),
- _event_data_id_exist(id58),
- _event_data_id_exist(id59),
- _event_data_id_exist(id60),
- _event_data_id_exist(id61),
- _event_data_id_exist(id62),
- _event_data_id_exist(id63),
- _event_data_id_exist(id64),
- _event_data_id_exist(id65),
- _event_data_id_exist(id66),
- _event_data_id_exist(id67),
- _event_data_id_exist(id68),
- _event_data_id_exist(id69),
- _event_data_id_exist(id70),
- _event_data_id_exist(id71),
- _event_data_id_exist(id72),
- _event_data_id_exist(id73),
- _event_data_id_exist(id74),
- _event_data_id_exist(id75),
- _event_data_id_exist(id76),
- _event_data_id_exist(id77),
- _event_data_id_exist(id78),
- _event_data_id_exist(id79),
- _event_data_id_exist(id80),
- _event_data_id_exist(id81),
- _event_data_id_exist(id82),
- _event_data_id_exist(id83),
- _event_data_id_exist(id84),
- _event_data_id_exist(id85),
- _event_data_id_exist(id86),
- _event_data_id_exist(id87),
- _event_data_id_exist(id88),
- _event_data_id_exist(id89),
- _event_data_id_exist(id90),
- _event_data_id_exist(id91),
- _event_data_id_exist(id92),
- _event_data_id_exist(id93),
- _event_data_id_exist(id94),
- _event_data_id_exist(id95),
- _event_data_id_exist(id96),
- _event_data_id_exist(id97),
- _event_data_id_exist(id98),
- _event_data_id_exist(id99),
- _event_data_id_exist(id100),
+ lambda: select(EventData.data_id)
+ .select_from(EventData)
+ .join(
+ Events,
+ and_(
+ Events.data_id == EventData.data_id,
+ Events.time_fired_ts
+ == select(Events.time_fired_ts)
+ .where(Events.data_id == EventData.data_id)
+ .limit(1)
+ .scalar_subquery()
+ .correlate(EventData),
+ ),
)
+ .where(EventData.data_id.in_(data_ids))
)
@@ -608,7 +240,8 @@ def delete_recorder_runs_rows(
"""Delete recorder_runs rows."""
return lambda_stmt(
lambda: delete(RecorderRuns)
- .filter(RecorderRuns.start < purge_before)
+ .filter(RecorderRuns.end.is_not(None))
+ .filter(RecorderRuns.end < purge_before)
.filter(RecorderRuns.run_id != current_run_id)
.execution_options(synchronize_session=False)
)
@@ -636,6 +269,15 @@ def find_states_to_purge(
)
+def find_oldest_state() -> StatementLambdaElement:
+ """Find the last_updated_ts of the oldest state."""
+ return lambda_stmt(
+ lambda: select(States.last_updated_ts)
+ .order_by(States.last_updated_ts.asc())
+ .limit(1)
+ )
+
+
def find_short_term_statistics_to_purge(
purge_before: datetime, max_bind_vars: int
) -> StatementLambdaElement:
@@ -828,16 +470,33 @@ def get_migration_changes() -> StatementLambdaElement:
def find_event_types_to_purge() -> StatementLambdaElement:
- """Find event_type_ids to purge."""
+ """Find event_type_ids to purge.
+
+ PostgreSQL does not support skip/loose index scan
+ https://wiki.postgresql.org/wiki/Loose_indexscan
+
+ To avoid using distinct, we use a subquery to get the latest time_fired_ts
+ for each event_type. This is then used to filter out the event_type_ids
+ that no longer exist in the Events table.
+
+ This query is fast for SQLite, MariaDB, MySQL, and PostgreSQL.
+ """
return lambda_stmt(
lambda: select(EventTypes.event_type_id, EventTypes.event_type).where(
EventTypes.event_type_id.not_in(
- select(EventTypes.event_type_id).join(
- used_event_type_ids := select(
- distinct(Events.event_type_id).label("used_event_type_id")
- ).subquery(),
- EventTypes.event_type_id
- == used_event_type_ids.c.used_event_type_id,
+ select(EventTypes.event_type_id)
+ .select_from(EventTypes)
+ .join(
+ Events,
+ and_(
+ EventTypes.event_type_id == Events.event_type_id,
+ Events.time_fired_ts
+ == select(Events.time_fired_ts)
+ .where(Events.event_type_id == EventTypes.event_type_id)
+ .limit(1)
+ .scalar_subquery()
+ .correlate(EventTypes),
+ ),
)
)
)
@@ -845,16 +504,33 @@ def find_event_types_to_purge() -> StatementLambdaElement:
def find_entity_ids_to_purge() -> StatementLambdaElement:
- """Find entity_ids to purge."""
+ """Find metadata_ids for each entity_id to purge.
+
+ PostgreSQL does not support skip/loose index scan
+ https://wiki.postgresql.org/wiki/Loose_indexscan
+
+ To avoid using distinct, we use a subquery to get the latest last_updated_ts
+ for each entity_id. This is then used to filter out the metadata_ids
+ that no longer exist in the States table.
+
+ This query is fast for SQLite, MariaDB, MySQL, and PostgreSQL.
+ """
return lambda_stmt(
lambda: select(StatesMeta.metadata_id, StatesMeta.entity_id).where(
StatesMeta.metadata_id.not_in(
- select(StatesMeta.metadata_id).join(
- used_states_metadata_id := select(
- distinct(States.metadata_id).label("used_states_metadata_id")
- ).subquery(),
- StatesMeta.metadata_id
- == used_states_metadata_id.c.used_states_metadata_id,
+ select(StatesMeta.metadata_id)
+ .select_from(StatesMeta)
+ .join(
+ States,
+ and_(
+ StatesMeta.metadata_id == States.metadata_id,
+ States.last_updated_ts
+ == select(States.last_updated_ts)
+ .where(States.metadata_id == StatesMeta.metadata_id)
+ .limit(1)
+ .scalar_subquery()
+ .correlate(StatesMeta),
+ ),
)
)
)
diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py
index 4ffe7c72971..c6783a5cbc2 100644
--- a/homeassistant/components/recorder/statistics.py
+++ b/homeassistant/components/recorder/statistics.py
@@ -11,6 +11,7 @@ from itertools import chain, groupby
import logging
from operator import itemgetter
import re
+from time import time as time_time
from typing import TYPE_CHECKING, Any, Literal, TypedDict, cast
from sqlalchemy import Select, and_, bindparam, func, lambda_stmt, select, text
@@ -27,7 +28,9 @@ from homeassistant.helpers.singleton import singleton
from homeassistant.helpers.typing import UNDEFINED, UndefinedType
from homeassistant.util import dt as dt_util
from homeassistant.util.unit_conversion import (
+ AreaConverter,
BaseUnitConverter,
+ BloodGlucoseConcentrationConverter,
ConductivityConverter,
DataRateConverter,
DistanceConverter,
@@ -60,6 +63,7 @@ from .db_schema import (
STATISTICS_TABLES,
Statistics,
StatisticsBase,
+ StatisticsMeta,
StatisticsRuns,
StatisticsShortTerm,
)
@@ -128,6 +132,11 @@ QUERY_STATISTICS_SUMMARY_SUM = (
STATISTIC_UNIT_TO_UNIT_CONVERTER: dict[str | None, type[BaseUnitConverter]] = {
+ **{unit: AreaConverter for unit in AreaConverter.VALID_UNITS},
+ **{
+ unit: BloodGlucoseConcentrationConverter
+ for unit in BloodGlucoseConcentrationConverter.VALID_UNITS
+ },
**{unit: ConductivityConverter for unit in ConductivityConverter.VALID_UNITS},
**{unit: DataRateConverter for unit in DataRateConverter.VALID_UNITS},
**{unit: DistanceConverter for unit in DistanceConverter.VALID_UNITS},
@@ -439,8 +448,9 @@ def _compile_hourly_statistics(session: Session, start: datetime) -> None:
}
# Insert compiled hourly statistics in the database
+ now_timestamp = time_time()
session.add_all(
- Statistics.from_stats_ts(metadata_id, summary_item)
+ Statistics.from_stats_ts(metadata_id, summary_item, now_timestamp)
for metadata_id, summary_item in summary.items()
)
@@ -571,6 +581,7 @@ def _compile_statistics(
new_short_term_stats: list[StatisticsBase] = []
updated_metadata_ids: set[int] = set()
+ now_timestamp = time_time()
# Insert collected statistics in the database
for stats in platform_stats:
modified_statistic_id, metadata_id = statistics_meta_manager.update_or_add(
@@ -580,10 +591,7 @@ def _compile_statistics(
modified_statistic_ids.add(modified_statistic_id)
updated_metadata_ids.add(metadata_id)
if new_stat := _insert_statistics(
- session,
- StatisticsShortTerm,
- metadata_id,
- stats["stat"],
+ session, StatisticsShortTerm, metadata_id, stats["stat"], now_timestamp
):
new_short_term_stats.append(new_stat)
@@ -659,10 +667,11 @@ def _insert_statistics(
table: type[StatisticsBase],
metadata_id: int,
statistic: StatisticData,
+ now_timestamp: float,
) -> StatisticsBase | None:
"""Insert statistics in the database."""
try:
- stat = table.from_stats(metadata_id, statistic)
+ stat = table.from_stats(metadata_id, statistic, now_timestamp)
session.add(stat)
except SQLAlchemyError:
_LOGGER.exception(
@@ -2026,24 +2035,35 @@ def _generate_statistics_at_time_stmt(
types: set[Literal["last_reset", "max", "mean", "min", "state", "sum"]],
) -> StatementLambdaElement:
"""Create the statement for finding the statistics for a given time."""
+ # This query is the result of significant research in
+ # https://github.com/home-assistant/core/issues/132865
+ # A reverse index scan with a limit 1 is the fastest way to get the
+ # last start_time_ts before a specific point in time for all supported
+ # databases. Since all databases support this query as a join
+ # condition we can use it as a subquery to get the last start_time_ts
+ # before a specific point in time for all entities.
stmt = _generate_select_columns_for_types_stmt(table, types)
- stmt += lambda q: q.join(
- (
- most_recent_statistic_ids := (
- select(
- func.max(table.start_ts).label("max_start_ts"),
- table.metadata_id.label("max_metadata_id"),
+ stmt += (
+ lambda q: q.select_from(StatisticsMeta)
+ .join(
+ table,
+ and_(
+ table.start_ts
+ == (
+ select(table.start_ts)
+ .where(
+ (StatisticsMeta.id == table.metadata_id)
+ & (table.start_ts < start_time_ts)
+ )
+ .order_by(table.start_ts.desc())
+ .limit(1)
)
- .filter(table.start_ts < start_time_ts)
- .filter(table.metadata_id.in_(metadata_ids))
- .group_by(table.metadata_id)
- .subquery()
- )
- ),
- and_(
- table.start_ts == most_recent_statistic_ids.c.max_start_ts,
- table.metadata_id == most_recent_statistic_ids.c.max_metadata_id,
- ),
+ .scalar_subquery()
+ .correlate(StatisticsMeta),
+ table.metadata_id == StatisticsMeta.id,
+ ),
+ )
+ .where(table.metadata_id.in_(metadata_ids))
)
return stmt
@@ -2340,11 +2360,12 @@ def _import_statistics_with_session(
_, metadata_id = statistics_meta_manager.update_or_add(
session, metadata, old_metadata_dict
)
+ now_timestamp = time_time()
for stat in statistics:
if stat_id := _statistics_exists(session, table, metadata_id, stat["start"]):
_update_statistics(session, table, stat_id, stat)
else:
- _insert_statistics(session, table, metadata_id, stat)
+ _insert_statistics(session, table, metadata_id, stat, now_timestamp)
if table != StatisticsShortTerm:
return True
diff --git a/homeassistant/components/recorder/table_managers/event_types.py b/homeassistant/components/recorder/table_managers/event_types.py
index 81bddce948d..266c970fe1f 100644
--- a/homeassistant/components/recorder/table_managers/event_types.py
+++ b/homeassistant/components/recorder/table_managers/event_types.py
@@ -28,8 +28,6 @@ CACHE_SIZE = 2048
class EventTypeManager(BaseLRUTableManager[EventTypes]):
"""Manage the EventTypes table."""
- active = False
-
def __init__(self, recorder: Recorder) -> None:
"""Initialize the event type manager."""
super().__init__(recorder, CACHE_SIZE)
diff --git a/homeassistant/components/recorder/table_managers/recorder_runs.py b/homeassistant/components/recorder/table_managers/recorder_runs.py
index b0b9818118b..4ca0aa18b88 100644
--- a/homeassistant/components/recorder/table_managers/recorder_runs.py
+++ b/homeassistant/components/recorder/table_managers/recorder_runs.py
@@ -2,8 +2,6 @@
from __future__ import annotations
-import bisect
-from dataclasses import dataclass
from datetime import datetime
from sqlalchemy.orm.session import Session
@@ -11,34 +9,6 @@ from sqlalchemy.orm.session import Session
import homeassistant.util.dt as dt_util
from ..db_schema import RecorderRuns
-from ..models import process_timestamp
-
-
-def _find_recorder_run_for_start_time(
- run_history: _RecorderRunsHistory, start: datetime
-) -> RecorderRuns | None:
- """Find the recorder run for a start time in _RecorderRunsHistory."""
- run_timestamps = run_history.run_timestamps
- runs_by_timestamp = run_history.runs_by_timestamp
-
- # bisect_left tells us were we would insert
- # a value in the list of runs after the start timestamp.
- #
- # The run before that (idx-1) is when the run started
- #
- # If idx is 0, history never ran before the start timestamp
- #
- if idx := bisect.bisect_left(run_timestamps, start.timestamp()):
- return runs_by_timestamp[run_timestamps[idx - 1]]
- return None
-
-
-@dataclass(frozen=True)
-class _RecorderRunsHistory:
- """Bisectable history of RecorderRuns."""
-
- run_timestamps: list[int]
- runs_by_timestamp: dict[int, RecorderRuns]
class RecorderRunsManager:
@@ -48,7 +18,7 @@ class RecorderRunsManager:
"""Track recorder run history."""
self._recording_start = dt_util.utcnow()
self._current_run_info: RecorderRuns | None = None
- self._run_history = _RecorderRunsHistory([], {})
+ self._first_run: RecorderRuns | None = None
@property
def recording_start(self) -> datetime:
@@ -58,9 +28,7 @@ class RecorderRunsManager:
@property
def first(self) -> RecorderRuns:
"""Get the first run."""
- if runs_by_timestamp := self._run_history.runs_by_timestamp:
- return next(iter(runs_by_timestamp.values()))
- return self.current
+ return self._first_run or self.current
@property
def current(self) -> RecorderRuns:
@@ -78,15 +46,6 @@ class RecorderRunsManager:
"""Return if a run is active."""
return self._current_run_info is not None
- def get(self, start: datetime) -> RecorderRuns | None:
- """Return the recorder run that started before or at start.
-
- If the first run started after the start, return None
- """
- if start >= self.recording_start:
- return self.current
- return _find_recorder_run_for_start_time(self._run_history, start)
-
def start(self, session: Session) -> None:
"""Start a new run.
@@ -122,31 +81,17 @@ class RecorderRunsManager:
Must run in the recorder thread.
"""
- run_timestamps: list[int] = []
- runs_by_timestamp: dict[int, RecorderRuns] = {}
-
- for run in session.query(RecorderRuns).order_by(RecorderRuns.start.asc()).all():
+ if (
+ run := session.query(RecorderRuns)
+ .order_by(RecorderRuns.start.asc())
+ .first()
+ ):
session.expunge(run)
- if run_dt := process_timestamp(run.start):
- # Not sure if this is correct or runs_by_timestamp annotation should be changed
- timestamp = int(run_dt.timestamp())
- run_timestamps.append(timestamp)
- runs_by_timestamp[timestamp] = run
-
- #
- # self._run_history is accessed in get()
- # which is allowed to be called from any thread
- #
- # We use a dataclass to ensure that when we update
- # run_timestamps and runs_by_timestamp
- # are never out of sync with each other.
- #
- self._run_history = _RecorderRunsHistory(run_timestamps, runs_by_timestamp)
+ self._first_run = run
def clear(self) -> None:
"""Clear the current run after ending it.
Must run in the recorder thread.
"""
- if self._current_run_info:
- self._current_run_info = None
+ self._current_run_info = None
diff --git a/homeassistant/components/recorder/table_managers/states.py b/homeassistant/components/recorder/table_managers/states.py
index d5cef759c54..fafcfa0ea61 100644
--- a/homeassistant/components/recorder/table_managers/states.py
+++ b/homeassistant/components/recorder/table_managers/states.py
@@ -2,7 +2,15 @@
from __future__ import annotations
+from collections.abc import Sequence
+from typing import Any, cast
+
+from sqlalchemy.engine.row import Row
+from sqlalchemy.orm.session import Session
+
from ..db_schema import States
+from ..queries import find_oldest_state
+from ..util import execute_stmt_lambda_element
class StatesManager:
@@ -13,6 +21,12 @@ class StatesManager:
self._pending: dict[str, States] = {}
self._last_committed_id: dict[str, int] = {}
self._last_reported: dict[int, float] = {}
+ self._oldest_ts: float | None = None
+
+ @property
+ def oldest_ts(self) -> float | None:
+ """Return the oldest timestamp."""
+ return self._oldest_ts
def pop_pending(self, entity_id: str) -> States | None:
"""Pop a pending state.
@@ -44,6 +58,8 @@ class StatesManager:
recorder thread.
"""
self._pending[entity_id] = state
+ if self._oldest_ts is None:
+ self._oldest_ts = state.last_updated_ts
def update_pending_last_reported(
self, state_id: int, last_reported_timestamp: float
@@ -74,6 +90,22 @@ class StatesManager:
"""
self._last_committed_id.clear()
self._pending.clear()
+ self._oldest_ts = None
+
+ def load_from_db(self, session: Session) -> None:
+ """Update the cache.
+
+ Must run in the recorder thread.
+ """
+ result = cast(
+ Sequence[Row[Any]],
+ execute_stmt_lambda_element(session, find_oldest_state()),
+ )
+ if not result:
+ ts = None
+ else:
+ ts = result[0].last_updated_ts
+ self._oldest_ts = ts
def evict_purged_state_ids(self, purged_state_ids: set[int]) -> None:
"""Evict purged states from the committed states.
diff --git a/homeassistant/components/recorder/table_managers/states_meta.py b/homeassistant/components/recorder/table_managers/states_meta.py
index 80d20dbec94..75afb6589a1 100644
--- a/homeassistant/components/recorder/table_managers/states_meta.py
+++ b/homeassistant/components/recorder/table_managers/states_meta.py
@@ -24,7 +24,7 @@ CACHE_SIZE = 8192
class StatesMetaManager(BaseLRUTableManager[StatesMeta]):
"""Manage the StatesMeta table."""
- active = False
+ active = True
def __init__(self, recorder: Recorder) -> None:
"""Initialize the states meta manager."""
diff --git a/homeassistant/components/recorder/tasks.py b/homeassistant/components/recorder/tasks.py
index 783f0a80b8e..fa10c12aa68 100644
--- a/homeassistant/components/recorder/tasks.py
+++ b/homeassistant/components/recorder/tasks.py
@@ -120,8 +120,6 @@ class PurgeTask(RecorderTask):
if purge.purge_old_data(
instance, self.purge_before, self.repack, self.apply_filter
):
- with instance.get_session() as session:
- instance.recorder_runs_manager.load_from_db(session)
# We always need to do the db cleanups after a purge
# is finished to ensure the WAL checkpoint and other
# tasks happen after a vacuum.
diff --git a/homeassistant/components/recorder/util.py b/homeassistant/components/recorder/util.py
index a59519ef38d..4cf24eb79c5 100644
--- a/homeassistant/components/recorder/util.py
+++ b/homeassistant/components/recorder/util.py
@@ -107,6 +107,8 @@ MAX_RESTART_TIME = timedelta(minutes=10)
# Retry when one of the following MySQL errors occurred:
RETRYABLE_MYSQL_ERRORS = (1205, 1206, 1213)
+# The error codes are hard coded because the PyMySQL library may not be
+# installed when using database engines other than MySQL or MariaDB.
# 1205: Lock wait timeout exceeded; try restarting transaction
# 1206: The total number of locks exceeds the lock table size
# 1213: Deadlock found when trying to get lock; try restarting transaction
@@ -598,6 +600,12 @@ def setup_connection_for_dialect(
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
+ # https://github.com/home-assistant/core/issues/126084
+ # so we set slow_range_in_select to True
+ slow_range_in_select = True
if first_connection:
# server_version_num was added in 2006
result = query_on_connection(dbapi_connection, "SHOW server_version")
@@ -892,17 +900,16 @@ def resolve_period(
start_time += timedelta(days=cal_offset * 7)
end_time = start_time + timedelta(weeks=1)
elif calendar_period == "month":
- start_time = start_of_day.replace(day=28)
- # This works for up to 48 months of offset
- start_time = (start_time + timedelta(days=cal_offset * 31)).replace(day=1)
+ month_now = start_of_day.month
+ new_month = (month_now - 1 + cal_offset) % 12 + 1
+ new_year = start_of_day.year + (month_now - 1 + cal_offset) // 12
+ start_time = start_of_day.replace(year=new_year, month=new_month, day=1)
end_time = (start_time + timedelta(days=31)).replace(day=1)
else: # calendar_period = "year"
- start_time = start_of_day.replace(month=12, day=31)
- # This works for 100+ years of offset
- start_time = (start_time + timedelta(days=cal_offset * 366)).replace(
- month=1, day=1
+ start_time = start_of_day.replace(
+ year=start_of_day.year + cal_offset, month=1, day=1
)
- end_time = (start_time + timedelta(days=365)).replace(day=1)
+ end_time = (start_time + timedelta(days=366)).replace(day=1)
start_time = dt_util.as_utc(start_time)
end_time = dt_util.as_utc(end_time)
diff --git a/homeassistant/components/recorder/websocket_api.py b/homeassistant/components/recorder/websocket_api.py
index ac917e903df..ee5c5dd6d75 100644
--- a/homeassistant/components/recorder/websocket_api.py
+++ b/homeassistant/components/recorder/websocket_api.py
@@ -16,6 +16,8 @@ from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.json import json_bytes
from homeassistant.util import dt as dt_util
from homeassistant.util.unit_conversion import (
+ AreaConverter,
+ BloodGlucoseConcentrationConverter,
ConductivityConverter,
DataRateConverter,
DistanceConverter,
@@ -54,6 +56,10 @@ UPDATE_STATISTICS_METADATA_TIME_OUT = 10
UNIT_SCHEMA = vol.Schema(
{
+ vol.Optional("area"): vol.In(AreaConverter.VALID_UNITS),
+ vol.Optional("blood_glucose_concentration"): vol.In(
+ BloodGlucoseConcentrationConverter.VALID_UNITS
+ ),
vol.Optional("conductivity"): vol.In(ConductivityConverter.VALID_UNITS),
vol.Optional("data_rate"): vol.In(DataRateConverter.VALID_UNITS),
vol.Optional("distance"): vol.In(DistanceConverter.VALID_UNITS),
diff --git a/homeassistant/components/recswitch/manifest.json b/homeassistant/components/recswitch/manifest.json
index 3e243d8f0d2..1273d498efd 100644
--- a/homeassistant/components/recswitch/manifest.json
+++ b/homeassistant/components/recswitch/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/recswitch",
"iot_class": "local_polling",
"loggers": ["pyrecswitch"],
+ "quality_scale": "legacy",
"requirements": ["pyrecswitch==1.0.2"]
}
diff --git a/homeassistant/components/reddit/manifest.json b/homeassistant/components/reddit/manifest.json
index beb2b168e88..a2e20329be0 100644
--- a/homeassistant/components/reddit/manifest.json
+++ b/homeassistant/components/reddit/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/reddit",
"iot_class": "cloud_polling",
"loggers": ["praw", "prawcore"],
+ "quality_scale": "legacy",
"requirements": ["praw==7.5.0"]
}
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 bf046e954d1..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.4"]
+ "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/manifest.json b/homeassistant/components/rejseplanen/manifest.json
index 72da7a65f45..6d0642cc996 100644
--- a/homeassistant/components/rejseplanen/manifest.json
+++ b/homeassistant/components/rejseplanen/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/rejseplanen",
"iot_class": "cloud_polling",
"loggers": ["rjpl"],
+ "quality_scale": "legacy",
"requirements": ["rjpl==0.3.6"]
}
diff --git a/homeassistant/components/remember_the_milk/manifest.json b/homeassistant/components/remember_the_milk/manifest.json
index ab309c765fc..13c37d56dba 100644
--- a/homeassistant/components/remember_the_milk/manifest.json
+++ b/homeassistant/components/remember_the_milk/manifest.json
@@ -6,5 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/remember_the_milk",
"iot_class": "cloud_push",
"loggers": ["rtmapi"],
+ "quality_scale": "legacy",
"requirements": ["RtmAPI==0.7.2", "httplib2==0.20.4"]
}
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 6a007bde0b4..36e482f0a29 100644
--- a/homeassistant/components/remote/__init__.py
+++ b/homeassistant/components/remote/__init__.py
@@ -22,12 +22,6 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
-from homeassistant.helpers.deprecation import (
- DeprecatedConstantEnum,
- all_with_deprecated_constants,
- check_if_deprecated_constant,
- dir_with_deprecated_constants,
-)
from homeassistant.helpers.entity import ToggleEntity, ToggleEntityDescription
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.typing import ConfigType
@@ -74,19 +68,6 @@ class RemoteEntityFeature(IntFlag):
ACTIVITY = 4
-# These SUPPORT_* constants are deprecated as of Home Assistant 2022.5.
-# Please use the RemoteEntityFeature enum instead.
-_DEPRECATED_SUPPORT_LEARN_COMMAND = DeprecatedConstantEnum(
- RemoteEntityFeature.LEARN_COMMAND, "2025.1"
-)
-_DEPRECATED_SUPPORT_DELETE_COMMAND = DeprecatedConstantEnum(
- RemoteEntityFeature.DELETE_COMMAND, "2025.1"
-)
-_DEPRECATED_SUPPORT_ACTIVITY = DeprecatedConstantEnum(
- RemoteEntityFeature.ACTIVITY, "2025.1"
-)
-
-
REMOTE_SERVICE_ACTIVITY_SCHEMA = cv.make_entity_service_schema(
{vol.Optional(ATTR_ACTIVITY): cv.string}
)
@@ -189,19 +170,6 @@ class RemoteEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_)
"""Flag supported features."""
return self._attr_supported_features
- @property
- def supported_features_compat(self) -> RemoteEntityFeature:
- """Return the supported features as RemoteEntityFeature.
-
- Remove this compatibility shim in 2025.1 or later.
- """
- features = self.supported_features
- if type(features) is int: # noqa: E721
- new_features = RemoteEntityFeature(features)
- self._report_deprecated_supported_features_values(new_features)
- return new_features
- return features
-
@cached_property
def current_activity(self) -> str | None:
"""Active activity."""
@@ -216,7 +184,7 @@ class RemoteEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_)
@property
def state_attributes(self) -> dict[str, Any] | None:
"""Return optional state attributes."""
- if RemoteEntityFeature.ACTIVITY not in self.supported_features_compat:
+ if RemoteEntityFeature.ACTIVITY not in self.supported_features:
return None
return {
@@ -251,11 +219,3 @@ class RemoteEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_)
await self.hass.async_add_executor_job(
ft.partial(self.delete_command, **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/remote/strings.json b/homeassistant/components/remote/strings.json
index e3df487a57b..09b270b9687 100644
--- a/homeassistant/components/remote/strings.json
+++ b/homeassistant/components/remote/strings.json
@@ -28,7 +28,7 @@
"services": {
"turn_on": {
"name": "[%key:common::action::turn_on%]",
- "description": "Sends the power on command.",
+ "description": "Sends the turn on command.",
"fields": {
"activity": {
"name": "Activity",
@@ -38,11 +38,11 @@
},
"toggle": {
"name": "[%key:common::action::toggle%]",
- "description": "Toggles a device on/off."
+ "description": "Sends the toggle command."
},
"turn_off": {
"name": "[%key:common::action::turn_off%]",
- "description": "Turns the device off."
+ "description": "Sends the turn off command."
},
"send_command": {
"name": "Send command",
diff --git a/homeassistant/components/remote_rpi_gpio/manifest.json b/homeassistant/components/remote_rpi_gpio/manifest.json
index 3a369d859f8..b7e3b55d564 100644
--- a/homeassistant/components/remote_rpi_gpio/manifest.json
+++ b/homeassistant/components/remote_rpi_gpio/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/remote_rpi_gpio",
"iot_class": "local_push",
"loggers": ["gpiozero", "pigpio"],
+ "quality_scale": "legacy",
"requirements": ["gpiozero==1.6.2", "pigpio==1.78"]
}
diff --git a/homeassistant/components/renault/binary_sensor.py b/homeassistant/components/renault/binary_sensor.py
index 98c298761ce..a8fdf324f1c 100644
--- a/homeassistant/components/renault/binary_sensor.py
+++ b/homeassistant/components/renault/binary_sensor.py
@@ -19,6 +19,9 @@ from homeassistant.helpers.typing import StateType
from . import RenaultConfigEntry
from .entity import RenaultDataEntity, RenaultDataEntityDescription
+# Coordinator is used to centralize the data updates
+PARALLEL_UPDATES = 0
+
@dataclass(frozen=True, kw_only=True)
class RenaultBinarySensorEntityDescription(
diff --git a/homeassistant/components/renault/button.py b/homeassistant/components/renault/button.py
index d3666388fbb..6a9f5e05a38 100644
--- a/homeassistant/components/renault/button.py
+++ b/homeassistant/components/renault/button.py
@@ -13,6 +13,10 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import RenaultConfigEntry
from .entity import RenaultEntity
+# Coordinator is used to centralize the data updates
+# but renault servers are unreliable and it's safer to queue action calls
+PARALLEL_UPDATES = 1
+
@dataclass(frozen=True, kw_only=True)
class RenaultButtonEntityDescription(ButtonEntityDescription):
diff --git a/homeassistant/components/renault/config_flow.py b/homeassistant/components/renault/config_flow.py
index 82429dd146c..70544a5637f 100644
--- a/homeassistant/components/renault/config_flow.py
+++ b/homeassistant/components/renault/config_flow.py
@@ -3,9 +3,11 @@
from __future__ import annotations
from collections.abc import Mapping
-from typing import TYPE_CHECKING, Any
+from typing import Any
+import aiohttp
from renault_api.const import AVAILABLE_LOCALES
+from renault_api.gigya.exceptions import GigyaException
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
@@ -14,17 +16,24 @@ from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from .const import CONF_KAMEREON_ACCOUNT_ID, CONF_LOCALE, DOMAIN
from .renault_hub import RenaultHub
+USER_SCHEMA = vol.Schema(
+ {
+ vol.Required(CONF_LOCALE): vol.In(AVAILABLE_LOCALES.keys()),
+ vol.Required(CONF_USERNAME): str,
+ vol.Required(CONF_PASSWORD): str,
+ }
+)
+REAUTH_SCHEMA = vol.Schema({vol.Required(CONF_PASSWORD): str})
+
class RenaultFlowHandler(ConfigFlow, domain=DOMAIN):
"""Handle a Renault config flow."""
- VERSION = 1
+ renault_hub: RenaultHub
def __init__(self) -> None:
"""Initialize the Renault config flow."""
- self._original_data: Mapping[str, Any] | None = None
self.renault_config: dict[str, Any] = {}
- self.renault_hub: RenaultHub | None = None
async def async_step_user(
self, user_input: dict[str, Any] | None = None
@@ -33,30 +42,28 @@ class RenaultFlowHandler(ConfigFlow, domain=DOMAIN):
Ask the user for API keys.
"""
+ errors: dict[str, str] = {}
if user_input:
locale = user_input[CONF_LOCALE]
self.renault_config.update(user_input)
self.renault_config.update(AVAILABLE_LOCALES[locale])
self.renault_hub = RenaultHub(self.hass, locale)
- if not await self.renault_hub.attempt_login(
- user_input[CONF_USERNAME], user_input[CONF_PASSWORD]
- ):
- return self._show_user_form({"base": "invalid_credentials"})
- return await self.async_step_kamereon()
- return self._show_user_form()
-
- def _show_user_form(self, errors: dict[str, Any] | None = None) -> ConfigFlowResult:
- """Show the API keys form."""
+ try:
+ login_success = await self.renault_hub.attempt_login(
+ user_input[CONF_USERNAME], user_input[CONF_PASSWORD]
+ )
+ except (aiohttp.ClientConnectionError, GigyaException):
+ errors["base"] = "cannot_connect"
+ except Exception: # noqa: BLE001
+ errors["base"] = "unknown"
+ else:
+ if login_success:
+ return await self.async_step_kamereon()
+ errors["base"] = "invalid_credentials"
return self.async_show_form(
step_id="user",
- data_schema=vol.Schema(
- {
- vol.Required(CONF_LOCALE): vol.In(AVAILABLE_LOCALES.keys()),
- vol.Required(CONF_USERNAME): str,
- vol.Required(CONF_PASSWORD): str,
- }
- ),
- errors=errors or {},
+ data_schema=USER_SCHEMA,
+ errors=errors,
)
async def async_step_kamereon(
@@ -72,18 +79,12 @@ class RenaultFlowHandler(ConfigFlow, domain=DOMAIN):
title=user_input[CONF_KAMEREON_ACCOUNT_ID], data=self.renault_config
)
- assert self.renault_hub
accounts = await self.renault_hub.get_account_ids()
if len(accounts) == 0:
return self.async_abort(reason="kamereon_no_account")
if len(accounts) == 1:
- await self.async_set_unique_id(accounts[0])
- self._abort_if_unique_id_configured()
-
- self.renault_config[CONF_KAMEREON_ACCOUNT_ID] = accounts[0]
- return self.async_create_entry(
- title=self.renault_config[CONF_KAMEREON_ACCOUNT_ID],
- data=self.renault_config,
+ return await self.async_step_kamereon(
+ user_input={CONF_KAMEREON_ACCOUNT_ID: accounts[0]}
)
return self.async_show_form(
@@ -97,48 +98,29 @@ class RenaultFlowHandler(ConfigFlow, domain=DOMAIN):
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Perform reauth upon an API authentication error."""
- self._original_data = entry_data
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 not user_input:
- return self._show_reauth_confirm_form()
+ errors: dict[str, str] = {}
+ reauth_entry = self._get_reauth_entry()
+ if user_input:
+ # Check credentials
+ self.renault_hub = RenaultHub(self.hass, reauth_entry.data[CONF_LOCALE])
+ if await self.renault_hub.attempt_login(
+ reauth_entry.data[CONF_USERNAME], user_input[CONF_PASSWORD]
+ ):
+ return self.async_update_reload_and_abort(
+ reauth_entry,
+ data_updates={CONF_PASSWORD: user_input[CONF_PASSWORD]},
+ )
+ errors = {"base": "invalid_credentials"}
- if TYPE_CHECKING:
- assert self._original_data
-
- # Check credentials
- self.renault_hub = RenaultHub(self.hass, self._original_data[CONF_LOCALE])
- if not await self.renault_hub.attempt_login(
- self._original_data[CONF_USERNAME], user_input[CONF_PASSWORD]
- ):
- return self._show_reauth_confirm_form({"base": "invalid_credentials"})
-
- # Update existing entry
- data = {**self._original_data, CONF_PASSWORD: user_input[CONF_PASSWORD]}
- existing_entry = await self.async_set_unique_id(
- self._original_data[CONF_KAMEREON_ACCOUNT_ID]
- )
- if TYPE_CHECKING:
- assert existing_entry
- self.hass.config_entries.async_update_entry(existing_entry, data=data)
- await self.hass.config_entries.async_reload(existing_entry.entry_id)
- return self.async_abort(reason="reauth_successful")
-
- def _show_reauth_confirm_form(
- self, errors: dict[str, Any] | None = None
- ) -> ConfigFlowResult:
- """Show the API keys form."""
- if TYPE_CHECKING:
- assert self._original_data
return self.async_show_form(
step_id="reauth_confirm",
- data_schema=vol.Schema({vol.Required(CONF_PASSWORD): str}),
- errors=errors or {},
- description_placeholders={
- CONF_USERNAME: self._original_data[CONF_USERNAME]
- },
+ data_schema=REAUTH_SCHEMA,
+ errors=errors,
+ description_placeholders={CONF_USERNAME: reauth_entry.data[CONF_USERNAME]},
)
diff --git a/homeassistant/components/renault/coordinator.py b/homeassistant/components/renault/coordinator.py
index d7aed6e3560..89e62867130 100644
--- a/homeassistant/components/renault/coordinator.py
+++ b/homeassistant/components/renault/coordinator.py
@@ -18,7 +18,7 @@ from renault_api.kamereon.models import KamereonVehicleDataAttributes
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
-T = TypeVar("T", bound=KamereonVehicleDataAttributes | None)
+T = TypeVar("T", bound=KamereonVehicleDataAttributes)
# We have potentially 7 coordinators per vehicle
_PARALLEL_SEMAPHORE = asyncio.Semaphore(1)
@@ -27,6 +27,8 @@ _PARALLEL_SEMAPHORE = asyncio.Semaphore(1)
class RenaultDataUpdateCoordinator(DataUpdateCoordinator[T]):
"""Handle vehicle communication with Renault servers."""
+ update_method: Callable[[], Awaitable[T]]
+
def __init__(
self,
hass: HomeAssistant,
@@ -50,8 +52,6 @@ class RenaultDataUpdateCoordinator(DataUpdateCoordinator[T]):
async def _async_update_data(self) -> T:
"""Fetch the latest data from the source."""
- if self.update_method is None:
- raise NotImplementedError("Update method not implemented")
try:
async with _PARALLEL_SEMAPHORE:
data = await self.update_method()
diff --git a/homeassistant/components/renault/device_tracker.py b/homeassistant/components/renault/device_tracker.py
index 2f7aeda5c39..08a2a698802 100644
--- a/homeassistant/components/renault/device_tracker.py
+++ b/homeassistant/components/renault/device_tracker.py
@@ -16,6 +16,9 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import RenaultConfigEntry
from .entity import RenaultDataEntity, RenaultDataEntityDescription
+# Coordinator is used to centralize the data updates
+PARALLEL_UPDATES = 0
+
@dataclass(frozen=True, kw_only=True)
class RenaultTrackerEntityDescription(
diff --git a/homeassistant/components/renault/entity.py b/homeassistant/components/renault/entity.py
index 10de028b2d0..7beb91e9603 100644
--- a/homeassistant/components/renault/entity.py
+++ b/homeassistant/components/renault/entity.py
@@ -59,6 +59,4 @@ class RenaultDataEntity(
def _get_data_attr(self, key: str) -> StateType:
"""Return the attribute value from the coordinator data."""
- if self.coordinator.data is None:
- return None # type: ignore[unreachable]
return cast(StateType, getattr(self.coordinator.data, key))
diff --git a/homeassistant/components/renault/manifest.json b/homeassistant/components/renault/manifest.json
index 716f2086bf1..1a599afe4e4 100644
--- a/homeassistant/components/renault/manifest.json
+++ b/homeassistant/components/renault/manifest.json
@@ -7,6 +7,6 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["renault_api"],
- "quality_scale": "platinum",
- "requirements": ["renault-api==0.2.7"]
+ "quality_scale": "silver",
+ "requirements": ["renault-api==0.2.9"]
}
diff --git a/homeassistant/components/renault/quality_scale.yaml b/homeassistant/components/renault/quality_scale.yaml
new file mode 100644
index 00000000000..f2d70622192
--- /dev/null
+++ b/homeassistant/components/renault/quality_scale.yaml
@@ -0,0 +1,64 @@
+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: 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:
+ status: exempt
+ comment: No options flow
+ 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: done
+ discovery-update-info:
+ status: exempt
+ comment: Discovery not possible
+ discovery:
+ status: exempt
+ comment: Discovery not possible
+ 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: done
+ stale-devices: done
+
+ # Platinum
+ async-dependency: done
+ inject-websession: done
+ strict-typing: done
diff --git a/homeassistant/components/renault/select.py b/homeassistant/components/renault/select.py
index b430da9396e..cab1d1f4d8a 100644
--- a/homeassistant/components/renault/select.py
+++ b/homeassistant/components/renault/select.py
@@ -15,6 +15,10 @@ from homeassistant.helpers.typing import StateType
from . import RenaultConfigEntry
from .entity import RenaultDataEntity, RenaultDataEntityDescription
+# Coordinator is used to centralize the data updates
+# but renault servers are unreliable and it's safer to queue action calls
+PARALLEL_UPDATES = 1
+
@dataclass(frozen=True, kw_only=True)
class RenaultSelectEntityDescription(
diff --git a/homeassistant/components/renault/sensor.py b/homeassistant/components/renault/sensor.py
index 78e64ae9acc..7854d70b1c4 100644
--- a/homeassistant/components/renault/sensor.py
+++ b/homeassistant/components/renault/sensor.py
@@ -40,6 +40,9 @@ from .coordinator import T
from .entity import RenaultDataEntity, RenaultDataEntityDescription
from .renault_vehicle import RenaultVehicleProxy
+# Coordinator is used to centralize the data updates
+PARALLEL_UPDATES = 0
+
@dataclass(frozen=True, kw_only=True)
class RenaultSensorEntityDescription(
diff --git a/homeassistant/components/renault/services.py b/homeassistant/components/renault/services.py
index 4409d9f284b..80fb2363b1e 100644
--- a/homeassistant/components/renault/services.py
+++ b/homeassistant/components/renault/services.py
@@ -11,6 +11,7 @@ import voluptuous as vol
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant, ServiceCall
+from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import config_validation as cv, device_registry as dr
from .const import DOMAIN
@@ -169,18 +170,27 @@ def setup_services(hass: HomeAssistant) -> None:
device_id = service_call_data[ATTR_VEHICLE]
device_entry = device_registry.async_get(device_id)
if device_entry is None:
- raise ValueError(f"Unable to find device with id: {device_id}")
+ raise ServiceValidationError(
+ translation_domain=DOMAIN,
+ translation_key="invalid_device_id",
+ translation_placeholders={"device_id": device_id},
+ )
loaded_entries: list[RenaultConfigEntry] = [
entry
for entry in hass.config_entries.async_entries(DOMAIN)
if entry.state == ConfigEntryState.LOADED
+ and entry.entry_id in device_entry.config_entries
]
for entry in loaded_entries:
for vin, vehicle in entry.runtime_data.vehicles.items():
if (DOMAIN, vin) in device_entry.identifiers:
return vehicle
- raise ValueError(f"Unable to find vehicle with VIN: {device_entry.identifiers}")
+ raise ServiceValidationError(
+ translation_domain=DOMAIN,
+ translation_key="no_config_entry_for_device",
+ translation_placeholders={"device_id": device_entry.name or device_id},
+ )
hass.services.async_register(
DOMAIN,
diff --git a/homeassistant/components/renault/strings.json b/homeassistant/components/renault/strings.json
index 9cc34edb82f..7d9cae1bcf1 100644
--- a/homeassistant/components/renault/strings.json
+++ b/homeassistant/components/renault/strings.json
@@ -6,19 +6,28 @@
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
},
"error": {
- "invalid_credentials": "[%key:common::config_flow::error::invalid_auth%]"
+ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
+ "invalid_credentials": "[%key:common::config_flow::error::invalid_auth%]",
+ "unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
"kamereon": {
"data": {
- "kamereon_account_id": "Kamereon account id"
+ "kamereon_account_id": "Account ID"
},
- "title": "Select Kamereon account id"
+ "data_description": {
+ "kamereon_account_id": "The Kamereon account ID associated with your vehicle"
+ },
+ "title": "Kamereon Account ID",
+ "description": "You have multiple Kamereon accounts associated to this email, please select one"
},
"reauth_confirm": {
"data": {
"password": "[%key:common::config_flow::data::password%]"
},
+ "data_description": {
+ "password": "Your MyRenault phone application password"
+ },
"description": "Please update your password for {username}",
"title": "[%key:common::config_flow::title::reauth%]"
},
@@ -28,6 +37,11 @@
"username": "[%key:common::config_flow::data::email%]",
"password": "[%key:common::config_flow::data::password%]"
},
+ "data_description": {
+ "locale": "Your country code",
+ "username": "Your MyRenault phone application email address",
+ "password": "Your MyRenault phone application password"
+ },
"title": "Set Renault credentials"
}
}
@@ -211,5 +225,13 @@
}
}
}
+ },
+ "exceptions": {
+ "invalid_device_id": {
+ "message": "No device with id {device_id} was found"
+ },
+ "no_config_entry_for_device": {
+ "message": "No loaded config entry was found for device with id {device_id}"
+ }
}
}
diff --git a/homeassistant/components/renson/fan.py b/homeassistant/components/renson/fan.py
index 44bea28ce3c..56b3655ef94 100644
--- a/homeassistant/components/renson/fan.py
+++ b/homeassistant/components/renson/fan.py
@@ -127,7 +127,6 @@ class RensonFan(RensonEntity, FanEntity):
| FanEntityFeature.TURN_OFF
| FanEntityFeature.TURN_ON
)
- _enable_turn_on_off_backwards_compatibility = False
def __init__(self, api: RensonVentilation, coordinator: RensonCoordinator) -> None:
"""Initialize the Renson fan."""
diff --git a/homeassistant/components/renson/manifest.json b/homeassistant/components/renson/manifest.json
index fa94207748e..fcc482959f2 100644
--- a/homeassistant/components/renson/manifest.json
+++ b/homeassistant/components/renson/manifest.json
@@ -5,5 +5,5 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/renson",
"iot_class": "local_polling",
- "requirements": ["renson-endura-delta==1.7.1"]
+ "requirements": ["renson-endura-delta==1.7.2"]
}
diff --git a/homeassistant/components/reolink/__init__.py b/homeassistant/components/reolink/__init__.py
index 7a36991201a..dd791bbaf1a 100644
--- a/homeassistant/components/reolink/__init__.py
+++ b/homeassistant/components/reolink/__init__.py
@@ -27,6 +27,7 @@ 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 .views import PlaybackProxyView
_LOGGER = logging.getLogger(__name__)
@@ -73,7 +74,9 @@ async def async_setup_entry(
) as err:
await host.stop()
raise ConfigEntryNotReady(
- f"Error while trying to setup {host.api.host}:{host.api.port}: {err!s}"
+ translation_domain=DOMAIN,
+ translation_key="config_entry_not_ready",
+ translation_placeholders={"host": host.api.host, "err": str(err)},
) from err
except BaseException:
await host.stop()
@@ -187,6 +190,8 @@ async def async_setup_entry(
migrate_entity_ids(hass, config_entry.entry_id, host)
+ hass.http.register_view(PlaybackProxyView(hass))
+
await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
config_entry.async_on_unload(
@@ -326,7 +331,19 @@ def migrate_entity_ids(
else:
new_device_id = f"{device_uid[0]}_{host.api.camera_uid(ch)}"
new_identifiers = {(DOMAIN, new_device_id)}
- device_reg.async_update_device(device.id, new_identifiers=new_identifiers)
+ existing_device = device_reg.async_get_device(identifiers=new_identifiers)
+ if existing_device is None:
+ device_reg.async_update_device(
+ device.id, new_identifiers=new_identifiers
+ )
+ else:
+ _LOGGER.warning(
+ "Reolink device with uid %s already exists, "
+ "removing device with uid %s",
+ new_device_id,
+ device_uid,
+ )
+ device_reg.async_remove_device(device.id)
entity_reg = er.async_get(hass)
entities = er.async_entries_for_config_entry(entity_reg, config_entry_id)
@@ -352,4 +369,18 @@ def migrate_entity_ids(
id_parts = entity.unique_id.split("_", 2)
if host.api.supported(ch, "UID") and id_parts[1] != host.api.camera_uid(ch):
new_id = f"{host.unique_id}_{host.api.camera_uid(ch)}_{id_parts[2]}"
- entity_reg.async_update_entity(entity.entity_id, new_unique_id=new_id)
+ existing_entity = entity_reg.async_get_entity_id(
+ entity.domain, entity.platform, new_id
+ )
+ if existing_entity is None:
+ entity_reg.async_update_entity(
+ entity.entity_id, new_unique_id=new_id
+ )
+ else:
+ _LOGGER.warning(
+ "Reolink entity with unique_id %s already exists, "
+ "removing device with unique_id %s",
+ new_id,
+ entity.unique_id,
+ )
+ entity_reg.async_remove(entity.entity_id)
diff --git a/homeassistant/components/reolink/binary_sensor.py b/homeassistant/components/reolink/binary_sensor.py
index f6c64d0b060..2191dedc9cf 100644
--- a/homeassistant/components/reolink/binary_sensor.py
+++ b/homeassistant/components/reolink/binary_sensor.py
@@ -28,6 +28,8 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .entity import ReolinkChannelCoordinatorEntity, ReolinkChannelEntityDescription
from .util import ReolinkConfigEntry, ReolinkData
+PARALLEL_UPDATES = 0
+
@dataclass(frozen=True, kw_only=True)
class ReolinkBinarySensorEntityDescription(
@@ -98,11 +100,19 @@ 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 = (
ReolinkBinarySensorEntityDescription(
key="sleep",
+ cmd_id=145,
cmd_key="GetChannelstatus",
translation_key="sleep",
entity_category=EntityCategory.DIAGNOSTIC,
@@ -173,14 +183,14 @@ class ReolinkPushBinarySensorEntity(ReolinkBinarySensorEntity):
self.async_on_remove(
async_dispatcher_connect(
self.hass,
- f"{self._host.webhook_id}_{self._channel}",
+ f"{self._host.unique_id}_{self._channel}",
self._async_handle_event,
)
)
self.async_on_remove(
async_dispatcher_connect(
self.hass,
- f"{self._host.webhook_id}_all",
+ f"{self._host.unique_id}_all",
self._async_handle_event,
)
)
diff --git a/homeassistant/components/reolink/button.py b/homeassistant/components/reolink/button.py
index 986ac9d872c..6b1fcc65a2f 100644
--- a/homeassistant/components/reolink/button.py
+++ b/homeassistant/components/reolink/button.py
@@ -7,7 +7,6 @@ from dataclasses import dataclass
from typing import Any
from reolink_aio.api import GuardEnum, Host, PtzEnum
-from reolink_aio.exceptions import ReolinkError
import voluptuous as vol
from homeassistant.components.button import (
@@ -18,7 +17,6 @@ from homeassistant.components.button import (
from homeassistant.components.camera import CameraEntityFeature
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import (
AddEntitiesCallback,
@@ -31,8 +29,9 @@ from .entity import (
ReolinkHostCoordinatorEntity,
ReolinkHostEntityDescription,
)
-from .util import ReolinkConfigEntry, ReolinkData
+from .util import ReolinkConfigEntry, ReolinkData, raise_translated_error
+PARALLEL_UPDATES = 0
ATTR_SPEED = "speed"
SUPPORT_PTZ_SPEED = CameraEntityFeature.STREAM
SERVICE_PTZ_MOVE = "ptz_move"
@@ -204,22 +203,18 @@ class ReolinkButtonEntity(ReolinkChannelCoordinatorEntity, ButtonEntity):
):
self._attr_supported_features = SUPPORT_PTZ_SPEED
+ @raise_translated_error
async def async_press(self) -> None:
"""Execute the button action."""
- try:
- await self.entity_description.method(self._host.api, self._channel)
- except ReolinkError as err:
- raise HomeAssistantError(err) from err
+ await self.entity_description.method(self._host.api, self._channel)
- async def async_ptz_move(self, **kwargs) -> None:
+ @raise_translated_error
+ async def async_ptz_move(self, **kwargs: Any) -> None:
"""PTZ move with speed."""
speed = kwargs[ATTR_SPEED]
- try:
- await self._host.api.set_ptz_command(
- self._channel, command=self.entity_description.ptz_cmd, speed=speed
- )
- except ReolinkError as err:
- raise HomeAssistantError(err) from err
+ await self._host.api.set_ptz_command(
+ self._channel, command=self.entity_description.ptz_cmd, speed=speed
+ )
class ReolinkHostButtonEntity(ReolinkHostCoordinatorEntity, ButtonEntity):
@@ -236,9 +231,7 @@ class ReolinkHostButtonEntity(ReolinkHostCoordinatorEntity, ButtonEntity):
self.entity_description = entity_description
super().__init__(reolink_data)
+ @raise_translated_error
async def async_press(self) -> None:
"""Execute the button action."""
- try:
- await self.entity_description.method(self._host.api)
- except ReolinkError as err:
- raise HomeAssistantError(err) from err
+ await self.entity_description.method(self._host.api)
diff --git a/homeassistant/components/reolink/camera.py b/homeassistant/components/reolink/camera.py
index 600286be9a2..a597be3ec7a 100644
--- a/homeassistant/components/reolink/camera.py
+++ b/homeassistant/components/reolink/camera.py
@@ -6,7 +6,6 @@ from dataclasses import dataclass
import logging
from reolink_aio.api import DUAL_LENS_MODELS
-from reolink_aio.exceptions import ReolinkError
from homeassistant.components.camera import (
Camera,
@@ -14,13 +13,13 @@ from homeassistant.components.camera import (
CameraEntityFeature,
)
from homeassistant.core import HomeAssistant
-from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .entity import ReolinkChannelCoordinatorEntity, ReolinkChannelEntityDescription
-from .util import ReolinkConfigEntry, ReolinkData
+from .util import ReolinkConfigEntry, ReolinkData, raise_translated_error
_LOGGER = logging.getLogger(__name__)
+PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
@@ -101,7 +100,7 @@ async def async_setup_entry(
if not entity_description.supported(reolink_data.host.api, channel):
continue
stream_url = await reolink_data.host.api.get_stream_source(
- channel, entity_description.stream
+ channel, entity_description.stream, False
)
if stream_url is None and "snapshots" not in entity_description.stream:
continue
@@ -141,13 +140,11 @@ class ReolinkCamera(ReolinkChannelCoordinatorEntity, Camera):
self._channel, self.entity_description.stream
)
+ @raise_translated_error
async def async_camera_image(
self, width: int | None = None, height: int | None = None
) -> bytes | None:
"""Return a still image response from the camera."""
- try:
- return await self._host.api.get_snapshot(
- self._channel, self.entity_description.stream
- )
- except ReolinkError as err:
- raise HomeAssistantError(err) from err
+ return await self._host.api.get_snapshot(
+ self._channel, self.entity_description.stream
+ )
diff --git a/homeassistant/components/reolink/config_flow.py b/homeassistant/components/reolink/config_flow.py
index 0b1ed7b4b15..c28e076aab4 100644
--- a/homeassistant/components/reolink/config_flow.py
+++ b/homeassistant/components/reolink/config_flow.py
@@ -128,13 +128,8 @@ class ReolinkFlowHandler(ConfigFlow, domain=DOMAIN):
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 not None:
- return await self.async_step_user()
- placeholders = {"name": self.context["title_placeholders"]["name"]}
- return self.async_show_form(
- step_id="reauth_confirm", description_placeholders=placeholders
- )
+ """Perform a reauthentication."""
+ return await self.async_step_user()
async def async_step_reconfigure(
self, user_input: dict[str, Any] | None = None
@@ -278,7 +273,7 @@ class ReolinkFlowHandler(ConfigFlow, domain=DOMAIN):
return self.async_update_reload_and_abort(
entry=self._get_reconfigure_entry(), data=user_input
)
- self._abort_if_unique_id_configured(updates=user_input)
+ self._abort_if_unique_id_configured()
return self.async_create_entry(
title=str(host.api.nvr_name),
diff --git a/homeassistant/components/reolink/entity.py b/homeassistant/components/reolink/entity.py
index 6101eee8a4c..dc2366e8f56 100644
--- a/homeassistant/components/reolink/entity.py
+++ b/homeassistant/components/reolink/entity.py
@@ -179,7 +179,7 @@ class ReolinkChannelCoordinatorEntity(ReolinkHostCoordinatorEntity):
"""Return True if entity is available."""
return super().available and self._host.api.camera_online(self._channel)
- def register_callback(self, unique_id: str, cmd_id) -> None:
+ def register_callback(self, unique_id: str, cmd_id: int) -> None:
"""Register callback for TCP push events."""
self._host.api.baichuan.register_callback(
unique_id, self._push_callback, cmd_id, self._channel
diff --git a/homeassistant/components/reolink/host.py b/homeassistant/components/reolink/host.py
index 336876d4c4f..97d888c0323 100644
--- a/homeassistant/components/reolink/host.py
+++ b/homeassistant/components/reolink/host.py
@@ -110,6 +110,7 @@ class ReolinkHost:
self._cancel_onvif_check: CALLBACK_TYPE | None = None
self._cancel_long_poll_check: CALLBACK_TYPE | None = None
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: bool = False
@@ -261,7 +262,7 @@ class ReolinkHost:
else:
ir.async_delete_issue(self._hass, DOMAIN, f"firmware_update_{key}")
- async def _async_check_tcp_push(self, *_) -> None:
+ async def _async_check_tcp_push(self, *_: Any) -> None:
"""Check the TCP push subscription."""
if self._api.baichuan.events_active:
ir.async_delete_issue(self._hass, DOMAIN, "webhook_url")
@@ -322,7 +323,7 @@ class ReolinkHost:
self._cancel_tcp_push_check = None
- async def _async_check_onvif(self, *_) -> None:
+ async def _async_check_onvif(self, *_: Any) -> None:
"""Check the ONVIF subscription."""
if self._webhook_reachable:
ir.async_delete_issue(self._hass, DOMAIN, "webhook_url")
@@ -343,7 +344,7 @@ class ReolinkHost:
self._cancel_onvif_check = None
- async def _async_check_onvif_long_poll(self, *_) -> None:
+ async def _async_check_onvif_long_poll(self, *_: Any) -> None:
"""Check if ONVIF long polling is working."""
if not self._long_poll_received:
_LOGGER.debug(
@@ -449,7 +450,7 @@ class ReolinkHost:
err,
)
- async def _async_start_long_polling(self, initial=False) -> None:
+ async def _async_start_long_polling(self, initial: bool = False) -> None:
"""Start ONVIF long polling task."""
if self._long_poll_task is None:
try:
@@ -494,7 +495,7 @@ class ReolinkHost:
err,
)
- async def stop(self, event=None) -> None:
+ async def stop(self, *_: Any) -> None:
"""Disconnect the API."""
if self._cancel_poll is not None:
self._cancel_poll()
@@ -535,6 +536,8 @@ class ReolinkHost:
async def renew(self) -> None:
"""Renew the subscription of motion events (lease time is 15 minutes)."""
+ await self._api.baichuan.check_subscribe_events()
+
if self._api.baichuan.events_active and self._api.subscribed(SubType.push):
# TCP push active, unsubscribe from ONVIF push because not needed
self.unregister_webhook()
@@ -650,7 +653,7 @@ class ReolinkHost:
webhook.async_unregister(self._hass, self.webhook_id)
self.webhook_id = None
- async def _async_long_polling(self, *_) -> None:
+ async def _async_long_polling(self, *_: Any) -> None:
"""Use ONVIF long polling to immediately receive events."""
# This task will be cancelled once _async_stop_long_polling is called
while True:
@@ -687,7 +690,7 @@ class ReolinkHost:
# Cooldown to prevent CPU over usage on camera freezes
await asyncio.sleep(LONG_POLL_COOLDOWN)
- async def _async_poll_all_motion(self, *_) -> None:
+ async def _async_poll_all_motion(self, *_: Any) -> None:
"""Poll motion and AI states until the first ONVIF push is received."""
if (
self._api.baichuan.events_active
@@ -699,14 +702,20 @@ class ReolinkHost:
return
try:
- await self._api.get_motion_state_all_ch()
+ if self._api.session_active:
+ await self._api.get_motion_state_all_ch()
except ReolinkError as err:
- _LOGGER.error(
- "Reolink error while polling motion state for host %s:%s: %s",
- self._api.host,
- self._api.port,
- err,
- )
+ if not self._fast_poll_error:
+ _LOGGER.error(
+ "Reolink error while polling motion state for host %s:%s: %s",
+ self._api.host,
+ self._api.port,
+ err,
+ )
+ self._fast_poll_error = True
+ else:
+ if self._api.session_active:
+ self._fast_poll_error = False
finally:
# schedule next poll
if not self._hass.is_stopping:
@@ -714,7 +723,7 @@ class ReolinkHost:
self._hass, POLL_INTERVAL_NO_PUSH, self._poll_job
)
- self._signal_write_ha_state(None)
+ self._signal_write_ha_state()
async def handle_webhook(
self, hass: HomeAssistant, webhook_id: str, request: Request
@@ -773,7 +782,7 @@ class ReolinkHost:
"Could not poll motion state after losing connection during receiving ONVIF event"
)
return
- async_dispatcher_send(hass, f"{webhook_id}_all", {})
+ self._signal_write_ha_state()
return
message = data.decode("utf-8")
@@ -786,14 +795,14 @@ class ReolinkHost:
self._signal_write_ha_state(channels)
- def _signal_write_ha_state(self, channels: list[int] | None) -> None:
+ def _signal_write_ha_state(self, channels: list[int] | None = None) -> None:
"""Update the binary sensors with async_write_ha_state."""
if channels is None:
- async_dispatcher_send(self._hass, f"{self.webhook_id}_all", {})
+ async_dispatcher_send(self._hass, f"{self.unique_id}_all", {})
return
for channel in channels:
- async_dispatcher_send(self._hass, f"{self.webhook_id}_{channel}", {})
+ async_dispatcher_send(self._hass, f"{self.unique_id}_{channel}", {})
@property
def event_connection(self) -> str:
diff --git a/homeassistant/components/reolink/icons.json b/homeassistant/components/reolink/icons.json
index 7f4a15ffe21..a9c231bf68f 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": {
@@ -222,6 +228,9 @@
"hdr": {
"default": "mdi:hdr"
},
+ "binning_mode": {
+ "default": "mdi:code-block-brackets"
+ },
"hub_alarm_ringtone": {
"default": "mdi:music-note",
"state": {
@@ -246,6 +255,12 @@
"off": "mdi:music-note-off"
}
},
+ "vehicle_tone": {
+ "default": "mdi:music-note",
+ "state": {
+ "off": "mdi:music-note-off"
+ }
+ },
"visitor_tone": {
"default": "mdi:music-note",
"state": {
@@ -257,6 +272,18 @@
"state": {
"off": "mdi:music-note-off"
}
+ },
+ "main_frame_rate": {
+ "default": "mdi:play-speed"
+ },
+ "sub_frame_rate": {
+ "default": "mdi:play-speed"
+ },
+ "main_bit_rate": {
+ "default": "mdi:play-speed"
+ },
+ "sub_bit_rate": {
+ "default": "mdi:play-speed"
}
},
"sensor": {
diff --git a/homeassistant/components/reolink/light.py b/homeassistant/components/reolink/light.py
index 0f239a30813..bbb9592dd76 100644
--- a/homeassistant/components/reolink/light.py
+++ b/homeassistant/components/reolink/light.py
@@ -7,7 +7,6 @@ from dataclasses import dataclass
from typing import Any
from reolink_aio.api import Host
-from reolink_aio.exceptions import InvalidParameterError, ReolinkError
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
@@ -17,7 +16,6 @@ from homeassistant.components.light import (
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .entity import (
@@ -26,7 +24,9 @@ from .entity import (
ReolinkHostCoordinatorEntity,
ReolinkHostEntityDescription,
)
-from .util import ReolinkConfigEntry, ReolinkData
+from .util import ReolinkConfigEntry, ReolinkData, raise_translated_error
+
+PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
@@ -152,37 +152,28 @@ class ReolinkLightEntity(ReolinkChannelCoordinatorEntity, LightEntity):
return round(255 * bright_pct / 100.0)
+ @raise_translated_error
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn light off."""
- try:
- await self.entity_description.turn_on_off_fn(
- self._host.api, self._channel, False
- )
- except ReolinkError as err:
- raise HomeAssistantError(err) from err
+ await self.entity_description.turn_on_off_fn(
+ self._host.api, self._channel, False
+ )
self.async_write_ha_state()
+ @raise_translated_error
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn light on."""
if (
brightness := kwargs.get(ATTR_BRIGHTNESS)
) is not None and self.entity_description.set_brightness_fn is not None:
brightness_pct = int(brightness / 255.0 * 100)
- try:
- await self.entity_description.set_brightness_fn(
- self._host.api, self._channel, brightness_pct
- )
- except InvalidParameterError as err:
- raise ServiceValidationError(err) from err
- except ReolinkError as err:
- raise HomeAssistantError(err) from err
-
- try:
- await self.entity_description.turn_on_off_fn(
- self._host.api, self._channel, True
+ await self.entity_description.set_brightness_fn(
+ self._host.api, self._channel, brightness_pct
)
- except ReolinkError as err:
- raise HomeAssistantError(err) from err
+
+ await self.entity_description.turn_on_off_fn(
+ self._host.api, self._channel, True
+ )
self.async_write_ha_state()
@@ -207,18 +198,14 @@ class ReolinkHostLightEntity(ReolinkHostCoordinatorEntity, LightEntity):
"""Return true if light is on."""
return self.entity_description.is_on_fn(self._host.api)
+ @raise_translated_error
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn light off."""
- try:
- await self.entity_description.turn_on_off_fn(self._host.api, False)
- except ReolinkError as err:
- raise HomeAssistantError(err) from err
+ await self.entity_description.turn_on_off_fn(self._host.api, False)
self.async_write_ha_state()
+ @raise_translated_error
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn light on."""
- try:
- await self.entity_description.turn_on_off_fn(self._host.api, True)
- except ReolinkError as err:
- raise HomeAssistantError(err) from err
+ await self.entity_description.turn_on_off_fn(self._host.api, True)
self.async_write_ha_state()
diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json
index 23a46c5e1c9..bb6b668368b 100644
--- a/homeassistant/components/reolink/manifest.json
+++ b/homeassistant/components/reolink/manifest.json
@@ -1,9 +1,9 @@
{
"domain": "reolink",
- "name": "Reolink IP NVR/camera",
+ "name": "Reolink",
"codeowners": ["@starkillerOG"],
"config_flow": true,
- "dependencies": ["webhook"],
+ "dependencies": ["http", "webhook"],
"dhcp": [
{
"hostname": "reolink*"
@@ -18,5 +18,6 @@
"documentation": "https://www.home-assistant.io/integrations/reolink",
"iot_class": "local_push",
"loggers": ["reolink_aio"],
- "requirements": ["reolink-aio==0.10.4"]
+ "quality_scale": "platinum",
+ "requirements": ["reolink-aio==0.11.6"]
}
diff --git a/homeassistant/components/reolink/media_source.py b/homeassistant/components/reolink/media_source.py
index 9280df0f5bd..e912bfb5100 100644
--- a/homeassistant/components/reolink/media_source.py
+++ b/homeassistant/components/reolink/media_source.py
@@ -23,7 +23,8 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr, entity_registry as er
from .const import DOMAIN
-from .host import ReolinkHost
+from .util import get_host
+from .views import async_generate_playback_proxy_url
_LOGGER = logging.getLogger(__name__)
@@ -46,13 +47,6 @@ def res_name(stream: str) -> str:
return "Low res."
-def get_host(hass: HomeAssistant, config_entry_id: str) -> ReolinkHost:
- """Return the Reolink host from the config entry id."""
- config_entry = hass.config_entries.async_get_entry(config_entry_id)
- assert config_entry is not None
- return config_entry.runtime_data.host
-
-
class ReolinkVODMediaSource(MediaSource):
"""Provide Reolink camera VODs as media sources."""
@@ -65,7 +59,9 @@ class ReolinkVODMediaSource(MediaSource):
async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia:
"""Resolve media to a url."""
- identifier = item.identifier.split("|", 5)
+ identifier = ["UNKNOWN"]
+ if item.identifier is not None:
+ identifier = item.identifier.split("|", 5)
if identifier[0] != "FILE":
raise Unresolvable(f"Unknown media item '{item.identifier}'.")
@@ -76,6 +72,8 @@ class ReolinkVODMediaSource(MediaSource):
def get_vod_type() -> VodRequestType:
if filename.endswith(".mp4"):
+ if host.api.is_nvr:
+ return VodRequestType.DOWNLOAD
return VodRequestType.PLAYBACK
if host.api.is_nvr:
return VodRequestType.FLV
@@ -83,22 +81,22 @@ class ReolinkVODMediaSource(MediaSource):
vod_type = get_vod_type()
+ if vod_type in [VodRequestType.DOWNLOAD, VodRequestType.PLAYBACK]:
+ proxy_url = async_generate_playback_proxy_url(
+ config_entry_id, channel, filename, stream_res, vod_type.value
+ )
+ return PlayMedia(proxy_url, "video/mp4")
+
mime_type, url = await host.api.get_vod_source(
channel, filename, stream_res, vod_type
)
if _LOGGER.isEnabledFor(logging.DEBUG):
- url_log = url
- if "&user=" in url_log:
- url_log = f"{url_log.split('&user=')[0]}&user=xxxxx&password=xxxxx"
- elif "&token=" in url_log:
- url_log = f"{url_log.split('&token=')[0]}&token=xxxxx"
_LOGGER.debug(
- "Opening VOD stream from %s: %s", host.api.camera_name(channel), url_log
+ "Opening VOD stream from %s: %s",
+ host.api.camera_name(channel),
+ host.api.hide_password(url),
)
- if mime_type == "video/mp4":
- return PlayMedia(url, mime_type)
-
stream = create_stream(self.hass, url, {}, DynamicStreamSettings())
stream.add_provider("hls", timeout=3600)
stream_url: str = stream.endpoint_url("hls")
@@ -110,7 +108,7 @@ class ReolinkVODMediaSource(MediaSource):
item: MediaSourceItem,
) -> BrowseMediaSource:
"""Return media."""
- if item.identifier is None:
+ if not item.identifier:
return await self._async_generate_root()
identifier = item.identifier.split("|", 7)
diff --git a/homeassistant/components/reolink/number.py b/homeassistant/components/reolink/number.py
index 8ce568d4bd0..e4b52c85d45 100644
--- a/homeassistant/components/reolink/number.py
+++ b/homeassistant/components/reolink/number.py
@@ -7,7 +7,6 @@ from dataclasses import dataclass
from typing import Any
from reolink_aio.api import Chime, Host
-from reolink_aio.exceptions import InvalidParameterError, ReolinkError
from homeassistant.components.number import (
NumberEntity,
@@ -16,7 +15,6 @@ from homeassistant.components.number import (
)
from homeassistant.const import EntityCategory, UnitOfTime
from homeassistant.core import HomeAssistant
-from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .entity import (
@@ -27,7 +25,9 @@ from .entity import (
ReolinkHostCoordinatorEntity,
ReolinkHostEntityDescription,
)
-from .util import ReolinkConfigEntry, ReolinkData
+from .util import ReolinkConfigEntry, ReolinkData, raise_translated_error
+
+PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
@@ -587,14 +587,10 @@ class ReolinkNumberEntity(ReolinkChannelCoordinatorEntity, NumberEntity):
"""State of the number entity."""
return self.entity_description.value(self._host.api, self._channel)
+ @raise_translated_error
async def async_set_native_value(self, value: float) -> None:
"""Update the current value."""
- try:
- await self.entity_description.method(self._host.api, self._channel, value)
- except InvalidParameterError as err:
- raise ServiceValidationError(err) from err
- except ReolinkError as err:
- raise HomeAssistantError(err) from err
+ await self.entity_description.method(self._host.api, self._channel, value)
self.async_write_ha_state()
@@ -619,14 +615,10 @@ class ReolinkHostNumberEntity(ReolinkHostCoordinatorEntity, NumberEntity):
"""State of the number entity."""
return self.entity_description.value(self._host.api)
+ @raise_translated_error
async def async_set_native_value(self, value: float) -> None:
"""Update the current value."""
- try:
- await self.entity_description.method(self._host.api, value)
- except InvalidParameterError as err:
- raise ServiceValidationError(err) from err
- except ReolinkError as err:
- raise HomeAssistantError(err) from err
+ await self.entity_description.method(self._host.api, value)
self.async_write_ha_state()
@@ -652,12 +644,8 @@ class ReolinkChimeNumberEntity(ReolinkChimeCoordinatorEntity, NumberEntity):
"""State of the number entity."""
return self.entity_description.value(self._chime)
+ @raise_translated_error
async def async_set_native_value(self, value: float) -> None:
"""Update the current value."""
- try:
- await self.entity_description.method(self._chime, value)
- except InvalidParameterError as err:
- raise ServiceValidationError(err) from err
- except ReolinkError as err:
- raise HomeAssistantError(err) from err
+ await self.entity_description.method(self._chime, value)
self.async_write_ha_state()
diff --git a/homeassistant/components/reolink/quality_scale.yaml b/homeassistant/components/reolink/quality_scale.yaml
new file mode 100644
index 00000000000..5cc054b7a4c
--- /dev/null
+++ b/homeassistant/components/reolink/quality_scale.yaml
@@ -0,0 +1,71 @@
+rules:
+ # Bronze
+ action-setup:
+ status: done
+ comment: |
+ play_chime service is setup in async_setup
+ ptz_move service is setup in async_setup_entry since it is a entity_service
+ 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: 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: done
+ integration-owner: done
+ log-when-unavailable: done
+ parallel-updates:
+ status: done
+ comment: |
+ Coordinators are used and asyncio mutex locks ensure safe operation in the upstream lib
+ Parallel_update=0 set on all platforms
+ 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: done
+ 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: done
+ stale-devices:
+ status: done
+ comment: |
+ For standalone cameras this does not apply: the integration should be removed.
+ For cameras connected to a NVR/Hub: the entities of a device are marked unavailable when power is unplugged. They can be removed using async_remove_config_entry_device.
+ Chimes can be uncoupled from the doorbell and removed from HA using async_remove_config_entry_device
+ Automatic removal lead to many user issues when a device was temporarily out of wifi range or disconnected from power, so not implemented anymore.
+ # Platinum
+ async-dependency: done
+ inject-websession: done
+ strict-typing: done
diff --git a/homeassistant/components/reolink/select.py b/homeassistant/components/reolink/select.py
index 1306c881059..7a74be2e28c 100644
--- a/homeassistant/components/reolink/select.py
+++ b/homeassistant/components/reolink/select.py
@@ -8,6 +8,7 @@ import logging
from typing import Any
from reolink_aio.api import (
+ BinningModeEnum,
Chime,
ChimeToneEnum,
DayNightEnum,
@@ -18,12 +19,10 @@ from reolink_aio.api import (
StatusLedEnum,
TrackMethodEnum,
)
-from reolink_aio.exceptions import InvalidParameterError, ReolinkError
from homeassistant.components.select import SelectEntity, SelectEntityDescription
-from homeassistant.const import EntityCategory
+from homeassistant.const import EntityCategory, UnitOfDataRate, UnitOfFrequency
from homeassistant.core import HomeAssistant
-from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .entity import (
@@ -32,9 +31,10 @@ from .entity import (
ReolinkChimeCoordinatorEntity,
ReolinkChimeEntityDescription,
)
-from .util import ReolinkConfigEntry, ReolinkData
+from .util import ReolinkConfigEntry, ReolinkData, raise_translated_error
_LOGGER = logging.getLogger(__name__)
+PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
@@ -174,6 +174,67 @@ SELECT_ENTITIES = (
value=lambda api, ch: HDREnum(api.HDR_state(ch)).name,
method=lambda api, ch, name: api.set_HDR(ch, HDREnum[name].value),
),
+ ReolinkSelectEntityDescription(
+ key="binning_mode",
+ cmd_key="GetIsp",
+ translation_key="binning_mode",
+ entity_category=EntityCategory.CONFIG,
+ entity_registry_enabled_default=False,
+ get_options=[method.name for method in BinningModeEnum],
+ supported=lambda api, ch: api.supported(ch, "binning_mode"),
+ value=lambda api, ch: BinningModeEnum(api.binning_mode(ch)).name,
+ method=lambda api, ch, name: api.set_binning_mode(
+ ch, BinningModeEnum[name].value
+ ),
+ ),
+ ReolinkSelectEntityDescription(
+ key="main_frame_rate",
+ cmd_key="GetEnc",
+ translation_key="main_frame_rate",
+ entity_category=EntityCategory.CONFIG,
+ entity_registry_enabled_default=False,
+ unit_of_measurement=UnitOfFrequency.HERTZ,
+ get_options=lambda api, ch: [str(v) for v in api.frame_rate_list(ch, "main")],
+ supported=lambda api, ch: api.supported(ch, "frame_rate"),
+ value=lambda api, ch: str(api.frame_rate(ch, "main")),
+ method=lambda api, ch, value: api.set_frame_rate(ch, int(value), "main"),
+ ),
+ ReolinkSelectEntityDescription(
+ key="sub_frame_rate",
+ cmd_key="GetEnc",
+ translation_key="sub_frame_rate",
+ entity_category=EntityCategory.CONFIG,
+ entity_registry_enabled_default=False,
+ unit_of_measurement=UnitOfFrequency.HERTZ,
+ get_options=lambda api, ch: [str(v) for v in api.frame_rate_list(ch, "sub")],
+ supported=lambda api, ch: api.supported(ch, "frame_rate"),
+ value=lambda api, ch: str(api.frame_rate(ch, "sub")),
+ method=lambda api, ch, value: api.set_frame_rate(ch, int(value), "sub"),
+ ),
+ ReolinkSelectEntityDescription(
+ key="main_bit_rate",
+ cmd_key="GetEnc",
+ translation_key="main_bit_rate",
+ entity_category=EntityCategory.CONFIG,
+ entity_registry_enabled_default=False,
+ unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND,
+ get_options=lambda api, ch: [str(v) for v in api.bit_rate_list(ch, "main")],
+ supported=lambda api, ch: api.supported(ch, "bit_rate"),
+ value=lambda api, ch: str(api.bit_rate(ch, "main")),
+ method=lambda api, ch, value: api.set_bit_rate(ch, int(value), "main"),
+ ),
+ ReolinkSelectEntityDescription(
+ key="sub_bit_rate",
+ cmd_key="GetEnc",
+ translation_key="sub_bit_rate",
+ entity_category=EntityCategory.CONFIG,
+ entity_registry_enabled_default=False,
+ unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND,
+ get_options=lambda api, ch: [str(v) for v in api.bit_rate_list(ch, "sub")],
+ supported=lambda api, ch: api.supported(ch, "bit_rate"),
+ value=lambda api, ch: str(api.bit_rate(ch, "sub")),
+ method=lambda api, ch, value: api.set_bit_rate(ch, int(value), "sub"),
+ ),
)
CHIME_SELECT_ENTITIES = (
@@ -197,6 +258,16 @@ CHIME_SELECT_ENTITIES = (
value=lambda chime: ChimeToneEnum(chime.tone("people")).name,
method=lambda chime, name: chime.set_tone("people", ChimeToneEnum[name].value),
),
+ ReolinkChimeSelectEntityDescription(
+ key="vehicle_tone",
+ cmd_key="GetDingDongCfg",
+ translation_key="vehicle_tone",
+ entity_category=EntityCategory.CONFIG,
+ get_options=[method.name for method in ChimeToneEnum],
+ supported=lambda chime: "vehicle" in chime.chime_event_types,
+ value=lambda chime: ChimeToneEnum(chime.tone("vehicle")).name,
+ method=lambda chime, name: chime.set_tone("vehicle", ChimeToneEnum[name].value),
+ ),
ReolinkChimeSelectEntityDescription(
key="visitor_tone",
cmd_key="GetDingDongCfg",
@@ -281,14 +352,10 @@ class ReolinkSelectEntity(ReolinkChannelCoordinatorEntity, SelectEntity):
self._log_error = True
return option
+ @raise_translated_error
async def async_select_option(self, option: str) -> None:
"""Change the selected option."""
- try:
- await self.entity_description.method(self._host.api, self._channel, option)
- except InvalidParameterError as err:
- raise ServiceValidationError(err) from err
- except ReolinkError as err:
- raise HomeAssistantError(err) from err
+ await self.entity_description.method(self._host.api, self._channel, option)
self.async_write_ha_state()
@@ -323,12 +390,8 @@ class ReolinkChimeSelectEntity(ReolinkChimeCoordinatorEntity, SelectEntity):
self._log_error = True
return option
+ @raise_translated_error
async def async_select_option(self, option: str) -> None:
"""Change the selected option."""
- try:
- await self.entity_description.method(self._chime, option)
- except InvalidParameterError as err:
- raise ServiceValidationError(err) from err
- except ReolinkError as err:
- raise HomeAssistantError(err) from err
+ await self.entity_description.method(self._chime, option)
self.async_write_ha_state()
diff --git a/homeassistant/components/reolink/sensor.py b/homeassistant/components/reolink/sensor.py
index 80e58c3d5c2..36900da99ca 100644
--- a/homeassistant/components/reolink/sensor.py
+++ b/homeassistant/components/reolink/sensor.py
@@ -29,6 +29,8 @@ from .entity import (
)
from .util import ReolinkConfigEntry, ReolinkData
+PARALLEL_UPDATES = 0
+
@dataclass(frozen=True, kw_only=True)
class ReolinkSensorEntityDescription(
@@ -71,6 +73,7 @@ SENSORS = (
),
ReolinkSensorEntityDescription(
key="battery_percent",
+ cmd_id=252,
cmd_key="GetBatteryInfo",
native_unit_of_measurement=PERCENTAGE,
device_class=SensorDeviceClass.BATTERY,
@@ -81,6 +84,7 @@ SENSORS = (
),
ReolinkSensorEntityDescription(
key="battery_temperature",
+ cmd_id=252,
cmd_key="GetBatteryInfo",
translation_key="battery_temperature",
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
@@ -93,6 +97,7 @@ SENSORS = (
),
ReolinkSensorEntityDescription(
key="battery_state",
+ cmd_id=252,
cmd_key="GetBatteryInfo",
translation_key="battery_state",
device_class=SensorDeviceClass.ENUM,
diff --git a/homeassistant/components/reolink/services.py b/homeassistant/components/reolink/services.py
index 326093e7a93..acd31fe0d7d 100644
--- a/homeassistant/components/reolink/services.py
+++ b/homeassistant/components/reolink/services.py
@@ -4,18 +4,17 @@ from __future__ import annotations
from reolink_aio.api import Chime
from reolink_aio.enums import ChimeToneEnum
-from reolink_aio.exceptions import InvalidParameterError, ReolinkError
import voluptuous as vol
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import ATTR_DEVICE_ID
from homeassistant.core import HomeAssistant, ServiceCall, callback
-from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
+from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import device_registry as dr
from .const import DOMAIN
from .host import ReolinkHost
-from .util import get_device_uid_and_ch
+from .util import get_device_uid_and_ch, raise_translated_error
ATTR_RINGTONE = "ringtone"
@@ -24,6 +23,7 @@ ATTR_RINGTONE = "ringtone"
def async_setup_services(hass: HomeAssistant) -> None:
"""Set up Reolink services."""
+ @raise_translated_error
async def async_play_chime(service_call: ServiceCall) -> None:
"""Play a ringtone."""
service_data = service_call.data
@@ -58,12 +58,7 @@ def async_setup_services(hass: HomeAssistant) -> None:
)
ringtone = service_data[ATTR_RINGTONE]
- try:
- await chime.play(ChimeToneEnum[ringtone].value)
- except InvalidParameterError as err:
- raise ServiceValidationError(err) from err
- except ReolinkError as err:
- raise HomeAssistantError(err) from err
+ await chime.play(ChimeToneEnum[ringtone].value)
hass.services.async_register(
DOMAIN,
diff --git a/homeassistant/components/reolink/siren.py b/homeassistant/components/reolink/siren.py
index 45f435c1f2c..74bb227d078 100644
--- a/homeassistant/components/reolink/siren.py
+++ b/homeassistant/components/reolink/siren.py
@@ -5,8 +5,6 @@ from __future__ import annotations
from dataclasses import dataclass
from typing import Any
-from reolink_aio.exceptions import InvalidParameterError, ReolinkError
-
from homeassistant.components.siren import (
ATTR_DURATION,
ATTR_VOLUME_LEVEL,
@@ -15,11 +13,12 @@ from homeassistant.components.siren import (
SirenEntityFeature,
)
from homeassistant.core import HomeAssistant
-from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .entity import ReolinkChannelCoordinatorEntity, ReolinkChannelEntityDescription
-from .util import ReolinkConfigEntry, ReolinkData
+from .util import ReolinkConfigEntry, ReolinkData, raise_translated_error
+
+PARALLEL_UPDATES = 0
@dataclass(frozen=True)
@@ -75,26 +74,15 @@ class ReolinkSirenEntity(ReolinkChannelCoordinatorEntity, SirenEntity):
self.entity_description = entity_description
super().__init__(reolink_data, channel)
+ @raise_translated_error
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn on the siren."""
if (volume := kwargs.get(ATTR_VOLUME_LEVEL)) is not None:
- try:
- await self._host.api.set_volume(self._channel, int(volume * 100))
- except InvalidParameterError as err:
- raise ServiceValidationError(err) from err
- except ReolinkError as err:
- raise HomeAssistantError(err) from err
+ await self._host.api.set_volume(self._channel, int(volume * 100))
duration = kwargs.get(ATTR_DURATION)
- try:
- await self._host.api.set_siren(self._channel, True, duration)
- except InvalidParameterError as err:
- raise ServiceValidationError(err) from err
- except ReolinkError as err:
- raise HomeAssistantError(err) from err
+ await self._host.api.set_siren(self._channel, True, duration)
+ @raise_translated_error
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off the siren."""
- try:
- await self._host.api.set_siren(self._channel, False, None)
- except ReolinkError as err:
- raise HomeAssistantError(err) from err
+ await self._host.api.set_siren(self._channel, False, None)
diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json
index fbc88ed1b50..412362fc447 100644
--- a/homeassistant/components/reolink/strings.json
+++ b/homeassistant/components/reolink/strings.json
@@ -18,10 +18,6 @@
"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."
}
- },
- "reauth_confirm": {
- "title": "[%key:common::config_flow::title::reauth%]",
- "description": "The Reolink integration needs to re-authenticate your connection details"
}
},
"error": {
@@ -37,7 +33,8 @@
"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 MAC address of the device does not match the previous MAC address"
}
},
"options": {
@@ -57,7 +54,49 @@
"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}"
+ },
+ "api_error": {
+ "message": "The device responded with a error: {err}"
+ },
+ "invalid_content_type": {
+ "message": "Received a different content type than expected: {err}"
+ },
+ "invalid_credentials": {
+ "message": "Invalid credentials: {err}"
+ },
+ "login_error": {
+ "message": "Error during login attempt: {err}"
+ },
+ "no_data": {
+ "message": "Device returned no data: {err}"
+ },
+ "unexpected_data": {
+ "message": "Device returned unexpected data: {err}"
+ },
+ "not_supported": {
+ "message": "Function not supported by this device: {err}"
+ },
+ "subscription_error": {
+ "message": "Error during ONVIF subscription: {err}"
+ },
+ "connection_error": {
+ "message": "Could not connect to the device: {err}"
+ },
+ "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}"
+ },
+ "config_entry_not_ready": {
+ "message": "Error while trying to setup {host}: {err}"
}
},
"issues": {
@@ -79,11 +118,7 @@
},
"firmware_update": {
"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 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."
+ "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})."
},
"hub_switch_deprecated": {
"title": "Reolink Home Hub switches deprecated",
@@ -93,7 +128,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",
@@ -103,11 +138,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",
@@ -179,6 +214,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": {
@@ -490,7 +532,7 @@
"name": "Floodlight mode",
"state": {
"off": "[%key:common::state::off%]",
- "auto": "Auto",
+ "auto": "[%key:component::reolink::entity::select::day_night_mode::state::auto%]",
"onatnight": "On at night",
"schedule": "Schedule",
"adaptive": "Adaptive",
@@ -529,8 +571,9 @@
"name": "Doorbell LED",
"state": {
"stayoff": "Stay off",
- "auto": "Auto",
+ "auto": "[%key:component::reolink::entity::select::day_night_mode::state::auto%]",
"alwaysonatnight": "Auto & always on at night",
+ "always": "Always on",
"alwayson": "Always on"
}
},
@@ -539,7 +582,15 @@
"state": {
"off": "[%key:common::state::off%]",
"on": "[%key:common::state::on%]",
- "auto": "Auto"
+ "auto": "[%key:component::reolink::entity::select::day_night_mode::state::auto%]"
+ }
+ },
+ "binning_mode": {
+ "name": "Binning mode",
+ "state": {
+ "off": "[%key:common::state::off%]",
+ "on": "[%key:common::state::on%]",
+ "auto": "[%key:component::reolink::entity::select::day_night_mode::state::auto%]"
}
},
"hub_alarm_ringtone": {
@@ -606,6 +657,22 @@
"waybackhome": "[%key:component::reolink::entity::select::motion_tone::state::waybackhome%]"
}
},
+ "vehicle_tone": {
+ "name": "Vehicle ringtone",
+ "state": {
+ "off": "[%key:common::state::off%]",
+ "citybird": "[%key:component::reolink::entity::select::motion_tone::state::citybird%]",
+ "originaltune": "[%key:component::reolink::entity::select::motion_tone::state::originaltune%]",
+ "pianokey": "[%key:component::reolink::entity::select::motion_tone::state::pianokey%]",
+ "loop": "[%key:component::reolink::entity::select::motion_tone::state::loop%]",
+ "attraction": "[%key:component::reolink::entity::select::motion_tone::state::attraction%]",
+ "hophop": "[%key:component::reolink::entity::select::motion_tone::state::hophop%]",
+ "goodday": "[%key:component::reolink::entity::select::motion_tone::state::goodday%]",
+ "operetta": "[%key:component::reolink::entity::select::motion_tone::state::operetta%]",
+ "moonlight": "[%key:component::reolink::entity::select::motion_tone::state::moonlight%]",
+ "waybackhome": "[%key:component::reolink::entity::select::motion_tone::state::waybackhome%]"
+ }
+ },
"visitor_tone": {
"name": "Visitor ringtone",
"state": {
@@ -637,6 +704,18 @@
"moonlight": "[%key:component::reolink::entity::select::motion_tone::state::moonlight%]",
"waybackhome": "[%key:component::reolink::entity::select::motion_tone::state::waybackhome%]"
}
+ },
+ "main_frame_rate": {
+ "name": "Clear frame rate"
+ },
+ "sub_frame_rate": {
+ "name": "Fluent frame rate"
+ },
+ "main_bit_rate": {
+ "name": "Clear bit rate"
+ },
+ "sub_bit_rate": {
+ "name": "Fluent bit rate"
}
},
"sensor": {
diff --git a/homeassistant/components/reolink/switch.py b/homeassistant/components/reolink/switch.py
index 482cdab18a7..85c35b5c987 100644
--- a/homeassistant/components/reolink/switch.py
+++ b/homeassistant/components/reolink/switch.py
@@ -7,12 +7,10 @@ from dataclasses import dataclass
from typing import Any
from reolink_aio.api import Chime, Host
-from reolink_aio.exceptions import ReolinkError
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 import entity_registry as er, issue_registry as ir
from homeassistant.helpers.entity_platform import AddEntitiesCallback
@@ -25,7 +23,9 @@ from .entity import (
ReolinkHostCoordinatorEntity,
ReolinkHostEntityDescription,
)
-from .util import ReolinkConfigEntry, ReolinkData
+from .util import ReolinkConfigEntry, ReolinkData, raise_translated_error
+
+PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
@@ -267,18 +267,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(
@@ -367,26 +355,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:
@@ -428,20 +396,16 @@ class ReolinkSwitchEntity(ReolinkChannelCoordinatorEntity, SwitchEntity):
"""Return true if switch is on."""
return self.entity_description.value(self._host.api, self._channel)
+ @raise_translated_error
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the entity on."""
- try:
- await self.entity_description.method(self._host.api, self._channel, True)
- except ReolinkError as err:
- raise HomeAssistantError(err) from err
+ await self.entity_description.method(self._host.api, self._channel, True)
self.async_write_ha_state()
+ @raise_translated_error
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the entity off."""
- try:
- await self.entity_description.method(self._host.api, self._channel, False)
- except ReolinkError as err:
- raise HomeAssistantError(err) from err
+ await self.entity_description.method(self._host.api, self._channel, False)
self.async_write_ha_state()
@@ -464,20 +428,16 @@ class ReolinkNVRSwitchEntity(ReolinkHostCoordinatorEntity, SwitchEntity):
"""Return true if switch is on."""
return self.entity_description.value(self._host.api)
+ @raise_translated_error
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the entity on."""
- try:
- await self.entity_description.method(self._host.api, True)
- except ReolinkError as err:
- raise HomeAssistantError(err) from err
+ await self.entity_description.method(self._host.api, True)
self.async_write_ha_state()
+ @raise_translated_error
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the entity off."""
- try:
- await self.entity_description.method(self._host.api, False)
- except ReolinkError as err:
- raise HomeAssistantError(err) from err
+ await self.entity_description.method(self._host.api, False)
self.async_write_ha_state()
@@ -501,18 +461,14 @@ class ReolinkChimeSwitchEntity(ReolinkChimeCoordinatorEntity, SwitchEntity):
"""Return true if switch is on."""
return self.entity_description.value(self._chime)
+ @raise_translated_error
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the entity on."""
- try:
- await self.entity_description.method(self._chime, True)
- except ReolinkError as err:
- raise HomeAssistantError(err) from err
+ await self.entity_description.method(self._chime, True)
self.async_write_ha_state()
+ @raise_translated_error
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the entity off."""
- try:
- await self.entity_description.method(self._chime, False)
- except ReolinkError as err:
- raise HomeAssistantError(err) from err
+ await self.entity_description.method(self._chime, False)
self.async_write_ha_state()
diff --git a/homeassistant/components/reolink/update.py b/homeassistant/components/reolink/update.py
index 5738411fa72..5a8c7d7dc08 100644
--- a/homeassistant/components/reolink/update.py
+++ b/homeassistant/components/reolink/update.py
@@ -3,11 +3,10 @@
from __future__ import annotations
from dataclasses import dataclass
-from datetime import datetime
from typing import Any
from reolink_aio.exceptions import ReolinkError
-from reolink_aio.software_version import NewSoftwareVersion
+from reolink_aio.software_version import NewSoftwareVersion, SoftwareVersion
from homeassistant.components.update import (
UpdateDeviceClass,
@@ -19,7 +18,13 @@ from homeassistant.core import CALLBACK_TYPE, HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.event import async_call_later
+from homeassistant.helpers.update_coordinator import (
+ CoordinatorEntity,
+ DataUpdateCoordinator,
+)
+from . import DEVICE_UPDATE_INTERVAL
+from .const import DOMAIN
from .entity import (
ReolinkChannelCoordinatorEntity,
ReolinkChannelEntityDescription,
@@ -28,7 +33,10 @@ from .entity import (
)
from .util import ReolinkConfigEntry, ReolinkData
+PARALLEL_UPDATES = 0
+RESUME_AFTER_INSTALL = 15
POLL_AFTER_INSTALL = 120
+POLL_PROGRESS = 2
@dataclass(frozen=True, kw_only=True)
@@ -86,25 +94,28 @@ async def async_setup_entry(
async_add_entities(entities)
-class ReolinkUpdateEntity(
- ReolinkChannelCoordinatorEntity,
- UpdateEntity,
+class ReolinkUpdateBaseEntity(
+ CoordinatorEntity[DataUpdateCoordinator[None]], UpdateEntity
):
- """Base update entity class for Reolink IP cameras."""
+ """Base update entity class for Reolink."""
- entity_description: ReolinkUpdateEntityDescription
_attr_release_url = "https://reolink.com/download-center/"
def __init__(
self,
reolink_data: ReolinkData,
- channel: int,
- entity_description: ReolinkUpdateEntityDescription,
+ channel: int | None,
+ coordinator: DataUpdateCoordinator[None],
) -> None:
"""Initialize Reolink update entity."""
- self.entity_description = entity_description
- super().__init__(reolink_data, channel, reolink_data.firmware_coordinator)
+ CoordinatorEntity.__init__(self, coordinator)
+ self._channel = channel
+ self._host = reolink_data.host
self._cancel_update: CALLBACK_TYPE | None = None
+ self._cancel_resume: CALLBACK_TYPE | None = None
+ self._cancel_progress: CALLBACK_TYPE | None = None
+ self._installing: bool = False
+ self._reolink_data = reolink_data
@property
def installed_version(self) -> str | None:
@@ -123,6 +134,16 @@ class ReolinkUpdateEntity(
return new_firmware.version_string
+ @property
+ def in_progress(self) -> bool:
+ """Update installation progress."""
+ return self._host.api.sw_upload_progress(self._channel) < 100
+
+ @property
+ def update_percentage(self) -> int:
+ """Update installation progress."""
+ return self._host.api.sw_upload_progress(self._channel)
+
@property
def supported_features(self) -> UpdateEntityFeature:
"""Flag supported features."""
@@ -130,8 +151,27 @@ class ReolinkUpdateEntity(
new_firmware = self._host.api.firmware_update_available(self._channel)
if isinstance(new_firmware, NewSoftwareVersion):
supported_features |= UpdateEntityFeature.RELEASE_NOTES
+ supported_features |= UpdateEntityFeature.PROGRESS
return supported_features
+ @property
+ def available(self) -> bool:
+ """Return True if entity is available."""
+ if self._installing or self._cancel_update is not None:
+ return True
+ return super().available
+
+ def version_is_newer(self, latest_version: str, installed_version: str) -> bool:
+ """Return True if latest_version is newer than installed_version."""
+ try:
+ installed = SoftwareVersion(installed_version)
+ latest = SoftwareVersion(latest_version)
+ except ReolinkError:
+ # when the online update API returns a unexpected string
+ return True
+
+ return latest > installed
+
async def async_release_notes(self) -> str | None:
"""Return the release notes."""
new_firmware = self._host.api.firmware_update_available(self._channel)
@@ -148,21 +188,56 @@ class ReolinkUpdateEntity(
self, version: str | None, backup: bool, **kwargs: Any
) -> None:
"""Install the latest firmware version."""
+ self._installing = True
+ await self._pause_update_coordinator()
+ self._cancel_progress = async_call_later(
+ self.hass, POLL_PROGRESS, self._async_update_progress
+ )
try:
await self._host.api.update_firmware(self._channel)
except ReolinkError as err:
raise HomeAssistantError(
- f"Error trying to update Reolink firmware: {err}"
+ translation_domain=DOMAIN,
+ translation_key="firmware_install_error",
+ translation_placeholders={"err": str(err)},
) from err
finally:
self.async_write_ha_state()
self._cancel_update = async_call_later(
self.hass, POLL_AFTER_INSTALL, self._async_update_future
)
+ self._cancel_resume = async_call_later(
+ self.hass, RESUME_AFTER_INSTALL, self._resume_update_coordinator
+ )
+ self._installing = False
- async def _async_update_future(self, now: datetime | None = None) -> None:
+ async def _pause_update_coordinator(self) -> None:
+ """Pause updating the states using the data update coordinator (during reboots)."""
+ self._reolink_data.device_coordinator.update_interval = None
+ self._reolink_data.device_coordinator.async_set_updated_data(None)
+
+ async def _resume_update_coordinator(self, *args: Any) -> None:
+ """Resume updating the states using the data update coordinator (after reboots)."""
+ self._reolink_data.device_coordinator.update_interval = DEVICE_UPDATE_INTERVAL
+ try:
+ await self._reolink_data.device_coordinator.async_refresh()
+ finally:
+ self._cancel_resume = None
+
+ async def _async_update_progress(self, *args: Any) -> None:
"""Request update."""
- await self.async_update()
+ self.async_write_ha_state()
+ if self._installing:
+ self._cancel_progress = async_call_later(
+ self.hass, POLL_PROGRESS, self._async_update_progress
+ )
+
+ async def _async_update_future(self, *args: Any) -> None:
+ """Request update."""
+ try:
+ await self.async_update()
+ finally:
+ self._cancel_update = None
async def async_added_to_hass(self) -> None:
"""Entity created."""
@@ -176,16 +251,44 @@ class ReolinkUpdateEntity(
self._host.firmware_ch_list.remove(self._channel)
if self._cancel_update is not None:
self._cancel_update()
+ if self._cancel_progress is not None:
+ self._cancel_progress()
+ if self._cancel_resume is not None:
+ self._cancel_resume()
+
+
+class ReolinkUpdateEntity(
+ ReolinkUpdateBaseEntity,
+ ReolinkChannelCoordinatorEntity,
+):
+ """Base update entity class for Reolink IP cameras."""
+
+ entity_description: ReolinkUpdateEntityDescription
+ _channel: int
+
+ def __init__(
+ self,
+ reolink_data: ReolinkData,
+ channel: int,
+ entity_description: ReolinkUpdateEntityDescription,
+ ) -> None:
+ """Initialize Reolink update entity."""
+ self.entity_description = entity_description
+ ReolinkUpdateBaseEntity.__init__(
+ self, reolink_data, channel, reolink_data.firmware_coordinator
+ )
+ ReolinkChannelCoordinatorEntity.__init__(
+ self, reolink_data, channel, reolink_data.firmware_coordinator
+ )
class ReolinkHostUpdateEntity(
+ ReolinkUpdateBaseEntity,
ReolinkHostCoordinatorEntity,
- UpdateEntity,
):
"""Update entity class for Reolink Host."""
entity_description: ReolinkHostUpdateEntityDescription
- _attr_release_url = "https://reolink.com/download-center/"
def __init__(
self,
@@ -194,76 +297,9 @@ class ReolinkHostUpdateEntity(
) -> None:
"""Initialize Reolink update entity."""
self.entity_description = entity_description
- super().__init__(reolink_data, reolink_data.firmware_coordinator)
- self._cancel_update: CALLBACK_TYPE | None = None
-
- @property
- def installed_version(self) -> str | None:
- """Version currently in use."""
- return self._host.api.sw_version
-
- @property
- def latest_version(self) -> str | None:
- """Latest version available for install."""
- new_firmware = self._host.api.firmware_update_available()
- if not new_firmware:
- return self.installed_version
-
- if isinstance(new_firmware, str):
- return new_firmware
-
- return new_firmware.version_string
-
- @property
- def supported_features(self) -> UpdateEntityFeature:
- """Flag supported features."""
- supported_features = UpdateEntityFeature.INSTALL
- new_firmware = self._host.api.firmware_update_available()
- if isinstance(new_firmware, NewSoftwareVersion):
- supported_features |= UpdateEntityFeature.RELEASE_NOTES
- return supported_features
-
- async def async_release_notes(self) -> str | None:
- """Return the release notes."""
- new_firmware = self._host.api.firmware_update_available()
- assert isinstance(new_firmware, NewSoftwareVersion)
-
- return (
- "If the install button fails, download this"
- f" [firmware zip file]({new_firmware.download_url})."
- " Then, follow the installation guide (PDF in the zip file).\n\n"
- f"## Release notes\n\n{new_firmware.release_notes}"
+ ReolinkUpdateBaseEntity.__init__(
+ self, reolink_data, None, reolink_data.firmware_coordinator
+ )
+ ReolinkHostCoordinatorEntity.__init__(
+ self, reolink_data, reolink_data.firmware_coordinator
)
-
- async def async_install(
- self, version: str | None, backup: bool, **kwargs: Any
- ) -> None:
- """Install the latest firmware version."""
- try:
- await self._host.api.update_firmware()
- except ReolinkError as err:
- raise HomeAssistantError(
- f"Error trying to update Reolink firmware: {err}"
- ) from err
- finally:
- self.async_write_ha_state()
- self._cancel_update = async_call_later(
- self.hass, POLL_AFTER_INSTALL, self._async_update_future
- )
-
- async def _async_update_future(self, now: datetime | None = None) -> None:
- """Request update."""
- await self.async_update()
-
- async def async_added_to_hass(self) -> None:
- """Entity created."""
- await super().async_added_to_hass()
- self._host.firmware_ch_list.append(None)
-
- async def async_will_remove_from_hass(self) -> None:
- """Entity removed."""
- await super().async_will_remove_from_hass()
- if None in self._host.firmware_ch_list:
- self._host.firmware_ch_list.remove(None)
- if self._cancel_update is not None:
- self._cancel_update()
diff --git a/homeassistant/components/reolink/util.py b/homeassistant/components/reolink/util.py
index 98c0e7b925b..bf7018dfba2 100644
--- a/homeassistant/components/reolink/util.py
+++ b/homeassistant/components/reolink/util.py
@@ -2,10 +2,29 @@
from __future__ import annotations
+from collections.abc import Awaitable, Callable, Coroutine
from dataclasses import dataclass
+from typing import Any, ParamSpec, TypeVar
+
+from reolink_aio.exceptions import (
+ ApiError,
+ CredentialsInvalidError,
+ InvalidContentTypeError,
+ InvalidParameterError,
+ LoginError,
+ NoDataError,
+ NotSupportedError,
+ ReolinkConnectionError,
+ ReolinkError,
+ ReolinkTimeoutError,
+ SubscriptionError,
+ UnexpectedDataError,
+)
from homeassistant import config_entries
+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.update_coordinator import DataUpdateCoordinator
@@ -33,6 +52,18 @@ def is_connected(hass: HomeAssistant, config_entry: config_entries.ConfigEntry)
)
+def get_host(hass: HomeAssistant, config_entry_id: str) -> ReolinkHost:
+ """Return the Reolink host from the config entry id."""
+ config_entry: ReolinkConfigEntry | None = hass.config_entries.async_get_entry(
+ config_entry_id
+ )
+ if config_entry is None:
+ raise Unresolvable(
+ f"Could not find Reolink config entry id '{config_entry_id}'."
+ )
+ return config_entry.runtime_data.host
+
+
def get_device_uid_and_ch(
device: dr.DeviceEntry, host: ReolinkHost
) -> tuple[list[str], int | None, bool]:
@@ -51,5 +82,96 @@ def get_device_uid_and_ch(
ch = int(device_uid[1][5:])
is_chime = True
else:
- ch = host.api.channel_for_uid(device_uid[1])
+ device_uid_part = "_".join(device_uid[1:])
+ ch = host.api.channel_for_uid(device_uid_part)
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]]:
+ """Wrap a reolink-aio function to translate any potential errors."""
+
+ async def decorator_raise_translated_error(*args: P.args, **kwargs: P.kwargs) -> T:
+ """Try a reolink-aio function and translate any potential errors."""
+ try:
+ return await func(*args, **kwargs)
+ except InvalidParameterError as err:
+ raise ServiceValidationError(
+ translation_domain=DOMAIN,
+ translation_key="invalid_parameter",
+ translation_placeholders={"err": str(err)},
+ ) from err
+ except ApiError as err:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="api_error",
+ translation_placeholders={"err": str(err)},
+ ) from err
+ except InvalidContentTypeError as err:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="invalid_content_type",
+ translation_placeholders={"err": str(err)},
+ ) from err
+ except CredentialsInvalidError as err:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="invalid_credentials",
+ translation_placeholders={"err": str(err)},
+ ) from err
+ except LoginError as err:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="login_error",
+ translation_placeholders={"err": str(err)},
+ ) from err
+ except NoDataError as err:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="no_data",
+ translation_placeholders={"err": str(err)},
+ ) from err
+ except UnexpectedDataError as err:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="unexpected_data",
+ translation_placeholders={"err": str(err)},
+ ) from err
+ except NotSupportedError as err:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="not_supported",
+ translation_placeholders={"err": str(err)},
+ ) from err
+ except SubscriptionError as err:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="subscription_error",
+ translation_placeholders={"err": str(err)},
+ ) from err
+ except ReolinkConnectionError as err:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="connection_error",
+ translation_placeholders={"err": str(err)},
+ ) from err
+ except ReolinkTimeoutError as err:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="timeout",
+ translation_placeholders={"err": str(err)},
+ ) from err
+ except ReolinkError as 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/reolink/views.py b/homeassistant/components/reolink/views.py
new file mode 100644
index 00000000000..1a4585bc997
--- /dev/null
+++ b/homeassistant/components/reolink/views.py
@@ -0,0 +1,147 @@
+"""Reolink Integration views."""
+
+from __future__ import annotations
+
+from base64 import urlsafe_b64decode, urlsafe_b64encode
+from http import HTTPStatus
+import logging
+
+from aiohttp import ClientError, ClientTimeout, web
+from reolink_aio.enums import VodRequestType
+from reolink_aio.exceptions import ReolinkError
+
+from homeassistant.components.http import HomeAssistantView
+from homeassistant.components.media_source import Unresolvable
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.util.ssl import SSLCipherList
+
+from .util import get_host
+
+_LOGGER = logging.getLogger(__name__)
+
+
+@callback
+def async_generate_playback_proxy_url(
+ config_entry_id: str, channel: int, filename: str, stream_res: str, vod_type: str
+) -> str:
+ """Generate proxy URL for event video."""
+
+ url_format = PlaybackProxyView.url
+ return url_format.format(
+ config_entry_id=config_entry_id,
+ channel=channel,
+ filename=urlsafe_b64encode(filename.encode("utf-8")).decode("utf-8"),
+ stream_res=stream_res,
+ vod_type=vod_type,
+ )
+
+
+class PlaybackProxyView(HomeAssistantView):
+ """View to proxy playback video from Reolink."""
+
+ requires_auth = True
+ url = "/api/reolink/video/{config_entry_id}/{channel}/{stream_res}/{vod_type}/{filename}"
+ name = "api:reolink_playback"
+
+ def __init__(self, hass: HomeAssistant) -> None:
+ """Initialize a proxy view."""
+ self.hass = hass
+ self.session = async_get_clientsession(
+ hass,
+ verify_ssl=False,
+ ssl_cipher=SSLCipherList.INSECURE,
+ )
+
+ async def get(
+ self,
+ request: web.Request,
+ config_entry_id: str,
+ channel: str,
+ stream_res: str,
+ vod_type: str,
+ filename: str,
+ retry: int = 2,
+ ) -> web.StreamResponse:
+ """Get playback proxy video response."""
+ retry = retry - 1
+
+ filename_decoded = urlsafe_b64decode(filename.encode("utf-8")).decode("utf-8")
+ ch = int(channel)
+ try:
+ host = get_host(self.hass, config_entry_id)
+ except Unresolvable:
+ err_str = f"Reolink playback proxy could not find config entry id: {config_entry_id}"
+ _LOGGER.warning(err_str)
+ return web.Response(body=err_str, status=HTTPStatus.BAD_REQUEST)
+
+ try:
+ mime_type, reolink_url = await host.api.get_vod_source(
+ ch, filename_decoded, stream_res, VodRequestType(vod_type)
+ )
+ except ReolinkError as err:
+ _LOGGER.warning("Reolink playback proxy error: %s", str(err))
+ return web.Response(body=str(err), status=HTTPStatus.BAD_REQUEST)
+
+ if _LOGGER.isEnabledFor(logging.DEBUG):
+ _LOGGER.debug(
+ "Opening VOD stream from %s: %s",
+ host.api.camera_name(ch),
+ host.api.hide_password(reolink_url),
+ )
+
+ try:
+ reolink_response = await self.session.get(
+ reolink_url,
+ timeout=ClientTimeout(
+ connect=15, sock_connect=15, sock_read=5, total=None
+ ),
+ )
+ except ClientError as err:
+ err_str = host.api.hide_password(
+ f"Reolink playback error while getting mp4: {err!s}"
+ )
+ if retry <= 0:
+ _LOGGER.warning(err_str)
+ return web.Response(body=err_str, status=HTTPStatus.BAD_REQUEST)
+ _LOGGER.debug("%s, renewing token", err_str)
+ await host.api.expire_session(unsubscribe=False)
+ return await self.get(
+ request, config_entry_id, channel, stream_res, vod_type, filename, retry
+ )
+
+ # Reolink typo "apolication/octet-stream" instead of "application/octet-stream"
+ if reolink_response.content_type not in [
+ "video/mp4",
+ "application/octet-stream",
+ "apolication/octet-stream",
+ ]:
+ err_str = f"Reolink playback expected video/mp4 but got {reolink_response.content_type}"
+ _LOGGER.error(err_str)
+ return web.Response(body=err_str, status=HTTPStatus.BAD_REQUEST)
+
+ response = web.StreamResponse(
+ status=200,
+ reason="OK",
+ headers={
+ "Content-Type": "video/mp4",
+ },
+ )
+
+ if reolink_response.content_length is not None:
+ response.content_length = reolink_response.content_length
+
+ await response.prepare(request)
+
+ try:
+ async for chunk in reolink_response.content.iter_chunked(65536):
+ await response.write(chunk)
+ except TimeoutError:
+ _LOGGER.debug(
+ "Timeout while reading Reolink playback from %s, writing EOF",
+ host.api.nvr_name,
+ )
+
+ reolink_response.release()
+ await response.write_eof()
+ return response
diff --git a/homeassistant/components/repetier/manifest.json b/homeassistant/components/repetier/manifest.json
index dfddb298284..7392ae0b23e 100644
--- a/homeassistant/components/repetier/manifest.json
+++ b/homeassistant/components/repetier/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/repetier",
"iot_class": "local_polling",
"loggers": ["pyrepetierng"],
+ "quality_scale": "legacy",
"requirements": ["pyrepetierng==0.1.0"]
}
diff --git a/homeassistant/components/rflink/manifest.json b/homeassistant/components/rflink/manifest.json
index 7917fa0bded..f5f372d2d33 100644
--- a/homeassistant/components/rflink/manifest.json
+++ b/homeassistant/components/rflink/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/rflink",
"iot_class": "assumed_state",
"loggers": ["rflink"],
+ "quality_scale": "legacy",
"requirements": ["rflink==0.0.66"]
}
diff --git a/homeassistant/components/rfxtrx/sensor.py b/homeassistant/components/rfxtrx/sensor.py
index cc195c9944e..4f8ae9767e2 100644
--- a/homeassistant/components/rfxtrx/sensor.py
+++ b/homeassistant/components/rfxtrx/sensor.py
@@ -182,13 +182,11 @@ SENSOR_TYPES = (
key="Count",
translation_key="count",
state_class=SensorStateClass.TOTAL_INCREASING,
- native_unit_of_measurement="count",
),
RfxtrxSensorEntityDescription(
key="Counter value",
translation_key="counter_value",
state_class=SensorStateClass.TOTAL_INCREASING,
- native_unit_of_measurement="count",
),
RfxtrxSensorEntityDescription(
key="Chill",
diff --git a/homeassistant/components/ridwell/config_flow.py b/homeassistant/components/ridwell/config_flow.py
index a54d4debe75..f03679c8315 100644
--- a/homeassistant/components/ridwell/config_flow.py
+++ b/homeassistant/components/ridwell/config_flow.py
@@ -93,6 +93,9 @@ class RidwellConfigFlow(ConfigFlow, domain=DOMAIN):
) -> ConfigFlowResult:
"""Handle re-auth completion."""
if not user_input:
+ if TYPE_CHECKING:
+ assert self._username
+
return self.async_show_form(
step_id="reauth_confirm",
data_schema=STEP_REAUTH_CONFIRM_DATA_SCHEMA,
diff --git a/homeassistant/components/ring/__init__.py b/homeassistant/components/ring/__init__.py
index b2340b34556..edc084fb57b 100644
--- a/homeassistant/components/ring/__init__.py
+++ b/homeassistant/components/ring/__init__.py
@@ -9,6 +9,7 @@ import uuid
from ring_doorbell import Auth, Ring, RingDevices
+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
@@ -70,8 +71,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: RingConfigEntry) -> bool
)
ring = Ring(auth)
- await _migrate_old_unique_ids(hass, entry.entry_id)
-
devices_coordinator = RingDataCoordinator(hass, ring)
listen_credentials = entry.data.get(CONF_LISTEN_CREDENTIALS)
listen_coordinator = RingListenCoordinator(
@@ -104,42 +103,46 @@ async def async_remove_config_entry_device(
return True
-async def _migrate_old_unique_ids(hass: HomeAssistant, entry_id: str) -> None:
- entity_registry = er.async_get(hass)
-
- @callback
- def _async_migrator(entity_entry: er.RegistryEntry) -> dict[str, str] | None:
- # Old format for camera and light was int
- unique_id = cast(str | int, entity_entry.unique_id)
- if isinstance(unique_id, int):
- new_unique_id = str(unique_id)
- if existing_entity_id := entity_registry.async_get_entity_id(
- entity_entry.domain, entity_entry.platform, new_unique_id
- ):
- _LOGGER.error(
- "Cannot migrate to unique_id '%s', already exists for '%s', "
- "You may have to delete unavailable ring entities",
- new_unique_id,
- existing_entity_id,
- )
- return None
- _LOGGER.debug("Fixing non string unique id %s", entity_entry.unique_id)
- return {"new_unique_id": new_unique_id}
- return None
-
- await er.async_migrate_entries(hass, entry_id, _async_migrator)
-
-
async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Migrate old config entry."""
entry_version = entry.version
entry_minor_version = entry.minor_version
+ entry_id = entry.entry_id
new_minor_version = 2
if entry_version == 1 and entry_minor_version == 1:
_LOGGER.debug(
"Migrating from version %s.%s", entry_version, entry_minor_version
)
+ # Migrate non-str unique ids
+ # This step used to run unconditionally from async_setup_entry
+ entity_registry = er.async_get(hass)
+
+ @callback
+ def _async_str_unique_id_migrator(
+ entity_entry: er.RegistryEntry,
+ ) -> dict[str, str] | None:
+ # Old format for camera and light was int
+ unique_id = cast(str | int, entity_entry.unique_id)
+ if isinstance(unique_id, int):
+ new_unique_id = str(unique_id)
+ if existing_entity_id := entity_registry.async_get_entity_id(
+ entity_entry.domain, entity_entry.platform, new_unique_id
+ ):
+ _LOGGER.error(
+ "Cannot migrate to unique_id '%s', already exists for '%s', "
+ "You may have to delete unavailable ring entities",
+ new_unique_id,
+ existing_entity_id,
+ )
+ return None
+ _LOGGER.debug("Fixing non string unique id %s", entity_entry.unique_id)
+ return {"new_unique_id": new_unique_id}
+ return None
+
+ await er.async_migrate_entries(hass, entry_id, _async_str_unique_id_migrator)
+
+ # Migrate the hardware id
hardware_id = str(uuid.uuid4())
hass.config_entries.async_update_entry(
entry,
@@ -149,4 +152,34 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
_LOGGER.debug(
"Migration to version %s.%s complete", entry_version, new_minor_version
)
+
+ entry_minor_version = entry.minor_version
+ new_minor_version = 3
+ if entry_version == 1 and entry_minor_version == 2:
+ _LOGGER.debug(
+ "Migrating from version %s.%s", entry_version, entry_minor_version
+ )
+
+ @callback
+ def _async_camera_unique_id_migrator(
+ entity_entry: er.RegistryEntry,
+ ) -> dict[str, str] | None:
+ # Migrate camera unique ids to append -last
+ if entity_entry.domain == CAMERA_DOMAIN and not isinstance(
+ cast(str | int, entity_entry.unique_id), int
+ ):
+ new_unique_id = f"{entity_entry.unique_id}-last_recording"
+ return {"new_unique_id": new_unique_id}
+ return None
+
+ await er.async_migrate_entries(hass, entry_id, _async_camera_unique_id_migrator)
+
+ hass.config_entries.async_update_entry(
+ entry,
+ minor_version=new_minor_version,
+ )
+ _LOGGER.debug(
+ "Migration to version %s.%s complete", entry_version, new_minor_version
+ )
+
return True
diff --git a/homeassistant/components/ring/camera.py b/homeassistant/components/ring/camera.py
index 9c66df9d89e..ccd91c163d6 100644
--- a/homeassistant/components/ring/camera.py
+++ b/homeassistant/components/ring/camera.py
@@ -2,24 +2,37 @@
from __future__ import annotations
+from collections.abc import Callable
+from dataclasses import dataclass
from datetime import timedelta
import logging
-from typing import TYPE_CHECKING, Any
+from typing import TYPE_CHECKING, Any, Generic
from aiohttp import web
from haffmpeg.camera import CameraMjpeg
from ring_doorbell import RingDoorBell
+from ring_doorbell.webrtcstream import RingWebRtcMessage
from homeassistant.components import ffmpeg
-from homeassistant.components.camera import Camera
+from homeassistant.components.camera import (
+ Camera,
+ CameraEntityDescription,
+ CameraEntityFeature,
+ RTCIceCandidateInit,
+ WebRTCAnswer,
+ WebRTCCandidate,
+ WebRTCError,
+ WebRTCSendMessage,
+)
from homeassistant.core import HomeAssistant, callback
+from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.aiohttp_client import async_aiohttp_proxy_stream
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util import dt as dt_util
from . import RingConfigEntry
from .coordinator import RingDataCoordinator
-from .entity import RingEntity, exception_wrap
+from .entity import RingDeviceT, RingEntity, exception_wrap
FORCE_REFRESH_INTERVAL = timedelta(minutes=3)
MOTION_DETECTION_CAPABILITY = "motion_detection"
@@ -27,6 +40,34 @@ MOTION_DETECTION_CAPABILITY = "motion_detection"
_LOGGER = logging.getLogger(__name__)
+@dataclass(frozen=True, kw_only=True)
+class RingCameraEntityDescription(CameraEntityDescription, Generic[RingDeviceT]):
+ """Base class for event entity description."""
+
+ exists_fn: Callable[[RingDoorBell], bool]
+ live_stream: bool
+ motion_detection: bool
+
+
+CAMERA_DESCRIPTIONS: tuple[RingCameraEntityDescription, ...] = (
+ RingCameraEntityDescription(
+ key="live_view",
+ translation_key="live_view",
+ exists_fn=lambda _: True,
+ live_stream=True,
+ motion_detection=False,
+ ),
+ RingCameraEntityDescription(
+ key="last_recording",
+ translation_key="last_recording",
+ entity_registry_enabled_default=False,
+ exists_fn=lambda camera: camera.has_subscription,
+ live_stream=False,
+ motion_detection=True,
+ ),
+)
+
+
async def async_setup_entry(
hass: HomeAssistant,
entry: RingConfigEntry,
@@ -38,9 +79,10 @@ async def async_setup_entry(
ffmpeg_manager = ffmpeg.get_ffmpeg_manager(hass)
cams = [
- RingCam(camera, devices_coordinator, ffmpeg_manager)
+ RingCam(camera, devices_coordinator, description, ffmpeg_manager=ffmpeg_manager)
+ for description in CAMERA_DESCRIPTIONS
for camera in ring_data.devices.video_devices
- if camera.has_subscription
+ if description.exists_fn(camera)
]
async_add_entities(cams)
@@ -49,26 +91,31 @@ async def async_setup_entry(
class RingCam(RingEntity[RingDoorBell], Camera):
"""An implementation of a Ring Door Bell camera."""
- _attr_name = None
-
def __init__(
self,
device: RingDoorBell,
coordinator: RingDataCoordinator,
+ description: RingCameraEntityDescription,
+ *,
ffmpeg_manager: ffmpeg.FFmpegManager,
) -> None:
"""Initialize a Ring Door Bell camera."""
super().__init__(device, coordinator)
+ self.entity_description = description
Camera.__init__(self)
self._ffmpeg_manager = ffmpeg_manager
self._last_event: dict[str, Any] | None = None
self._last_video_id: int | None = None
self._video_url: str | None = None
- self._image: bytes | None = None
+ self._images: dict[tuple[int | None, int | None], bytes] = {}
self._expires_at = dt_util.utcnow() - FORCE_REFRESH_INTERVAL
- self._attr_unique_id = str(device.id)
- if device.has_capability(MOTION_DETECTION_CAPABILITY):
+ self._attr_unique_id = f"{device.id}-{description.key}"
+ if description.motion_detection and device.has_capability(
+ MOTION_DETECTION_CAPABILITY
+ ):
self._attr_motion_detection_enabled = device.motion_detection
+ if description.live_stream:
+ self._attr_supported_features |= CameraEntityFeature.STREAM
@callback
def _handle_coordinator_update(self) -> None:
@@ -86,7 +133,7 @@ class RingCam(RingEntity[RingDoorBell], Camera):
self._last_event = None
self._last_video_id = None
self._video_url = None
- self._image = None
+ self._images = {}
self._expires_at = dt_util.utcnow()
self.async_write_ha_state()
@@ -102,7 +149,8 @@ class RingCam(RingEntity[RingDoorBell], Camera):
self, width: int | None = None, height: int | None = None
) -> bytes | None:
"""Return a still image response from the camera."""
- if self._image is None and self._video_url is not None:
+ key = (width, height)
+ if not (image := self._images.get(key)) and self._video_url is not None:
image = await ffmpeg.async_get_image(
self.hass,
self._video_url,
@@ -111,9 +159,9 @@ class RingCam(RingEntity[RingDoorBell], Camera):
)
if image:
- self._image = image
+ self._images[key] = image
- return self._image
+ return image
async def handle_async_mjpeg_stream(
self, request: web.Request
@@ -136,6 +184,47 @@ class RingCam(RingEntity[RingDoorBell], Camera):
finally:
await stream.close()
+ async def async_handle_async_webrtc_offer(
+ self, offer_sdp: str, session_id: str, send_message: WebRTCSendMessage
+ ) -> None:
+ """Return the source of the stream."""
+
+ def message_wrapper(ring_message: RingWebRtcMessage) -> None:
+ if ring_message.error_code:
+ msg = ring_message.error_message or ""
+ send_message(WebRTCError(ring_message.error_code, msg))
+ elif ring_message.answer:
+ send_message(WebRTCAnswer(ring_message.answer))
+ elif ring_message.candidate:
+ send_message(
+ WebRTCCandidate(
+ RTCIceCandidateInit(
+ ring_message.candidate,
+ sdp_m_line_index=ring_message.sdp_m_line_index or 0,
+ )
+ )
+ )
+
+ return await self._device.generate_async_webrtc_stream(
+ offer_sdp, session_id, message_wrapper, keep_alive_timeout=None
+ )
+
+ async def async_on_webrtc_candidate(
+ self, session_id: str, candidate: RTCIceCandidateInit
+ ) -> None:
+ """Handle a WebRTC candidate."""
+ if candidate.sdp_m_line_index is None:
+ msg = "The sdp_m_line_index is required for ring webrtc streaming"
+ raise HomeAssistantError(msg)
+ await self._device.on_webrtc_candidate(
+ session_id, candidate.candidate, candidate.sdp_m_line_index
+ )
+
+ @callback
+ def close_webrtc_session(self, session_id: str) -> None:
+ """Close a WebRTC session."""
+ self._device.sync_close_webrtc_stream(session_id)
+
async def async_update(self) -> None:
"""Update camera entity and refresh attributes."""
if (
@@ -157,7 +246,7 @@ class RingCam(RingEntity[RingDoorBell], Camera):
return
if self._last_video_id != self._last_event["id"]:
- self._image = None
+ self._images = {}
self._video_url = await self._async_get_video()
diff --git a/homeassistant/components/ring/const.py b/homeassistant/components/ring/const.py
index 9595241ebb1..68ac00d69f6 100644
--- a/homeassistant/components/ring/const.py
+++ b/homeassistant/components/ring/const.py
@@ -33,4 +33,4 @@ SCAN_INTERVAL = timedelta(minutes=1)
CONF_2FA = "2fa"
CONF_LISTEN_CREDENTIALS = "listen_token"
-CONF_CONFIG_ENTRY_MINOR_VERSION: Final = 2
+CONF_CONFIG_ENTRY_MINOR_VERSION: Final = 3
diff --git a/homeassistant/components/ring/event.py b/homeassistant/components/ring/event.py
index e6d9d25542f..71a4bc8aea5 100644
--- a/homeassistant/components/ring/event.py
+++ b/homeassistant/components/ring/event.py
@@ -96,7 +96,7 @@ class RingEvent(RingBaseEntity[RingListenCoordinator, RingDeviceT], EventEntity)
@callback
def _handle_coordinator_update(self) -> None:
- if alert := self._get_coordinator_alert():
+ if (alert := self._get_coordinator_alert()) and not alert.is_update:
self._async_handle_event(alert.kind)
super()._handle_coordinator_update()
diff --git a/homeassistant/components/ring/manifest.json b/homeassistant/components/ring/manifest.json
index 63c47cb2979..86758b26794 100644
--- a/homeassistant/components/ring/manifest.json
+++ b/homeassistant/components/ring/manifest.json
@@ -29,6 +29,5 @@
"documentation": "https://www.home-assistant.io/integrations/ring",
"iot_class": "cloud_polling",
"loggers": ["ring_doorbell"],
- "quality_scale": "silver",
- "requirements": ["ring-doorbell==0.9.9"]
+ "requirements": ["ring-doorbell==0.9.13"]
}
diff --git a/homeassistant/components/ring/strings.json b/homeassistant/components/ring/strings.json
index 0887e4112c6..8170ec8e161 100644
--- a/homeassistant/components/ring/strings.json
+++ b/homeassistant/components/ring/strings.json
@@ -124,6 +124,14 @@
"motion_detection": {
"name": "Motion detection"
}
+ },
+ "camera": {
+ "live_view": {
+ "name": "Live view"
+ },
+ "last_recording": {
+ "name": "Last recording"
+ }
}
},
"issues": {
diff --git a/homeassistant/components/ripple/manifest.json b/homeassistant/components/ripple/manifest.json
index 72df64ac850..17ff6b34f38 100644
--- a/homeassistant/components/ripple/manifest.json
+++ b/homeassistant/components/ripple/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/ripple",
"iot_class": "cloud_polling",
"loggers": ["pyripple"],
+ "quality_scale": "legacy",
"requirements": ["python-ripple-api==0.0.3"]
}
diff --git a/homeassistant/components/risco/manifest.json b/homeassistant/components/risco/manifest.json
index 372d8e0c629..149b8761589 100644
--- a/homeassistant/components/risco/manifest.json
+++ b/homeassistant/components/risco/manifest.json
@@ -6,6 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/risco",
"iot_class": "local_push",
"loggers": ["pyrisco"],
- "quality_scale": "platinum",
- "requirements": ["pyrisco==0.6.4"]
+ "requirements": ["pyrisco==0.6.5"]
}
diff --git a/homeassistant/components/rituals_perfume_genie/manifest.json b/homeassistant/components/rituals_perfume_genie/manifest.json
index 996dd1faecf..114491d9122 100644
--- a/homeassistant/components/rituals_perfume_genie/manifest.json
+++ b/homeassistant/components/rituals_perfume_genie/manifest.json
@@ -6,6 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/rituals_perfume_genie",
"iot_class": "cloud_polling",
"loggers": ["pyrituals"],
- "quality_scale": "silver",
"requirements": ["pyrituals==0.0.6"]
}
diff --git a/homeassistant/components/rituals_perfume_genie/select.py b/homeassistant/components/rituals_perfume_genie/select.py
index e93d6ae03ef..27aff70649b 100644
--- a/homeassistant/components/rituals_perfume_genie/select.py
+++ b/homeassistant/components/rituals_perfume_genie/select.py
@@ -9,7 +9,7 @@ from pyrituals import Diffuser
from homeassistant.components.select import SelectEntity, SelectEntityDescription
from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import AREA_SQUARE_METERS, EntityCategory
+from homeassistant.const import EntityCategory, UnitOfArea
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
@@ -30,7 +30,7 @@ ENTITY_DESCRIPTIONS = (
RitualsSelectEntityDescription(
key="room_size_square_meter",
translation_key="room_size_square_meter",
- unit_of_measurement=AREA_SQUARE_METERS,
+ unit_of_measurement=UnitOfArea.SQUARE_METERS,
entity_category=EntityCategory.CONFIG,
options=["15", "30", "60", "100"],
current_fn=lambda diffuser: str(diffuser.room_size_square_meter),
diff --git a/homeassistant/components/rmvtransport/manifest.json b/homeassistant/components/rmvtransport/manifest.json
index 81b650bcdc0..30be5417ff6 100644
--- a/homeassistant/components/rmvtransport/manifest.json
+++ b/homeassistant/components/rmvtransport/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/rmvtransport",
"iot_class": "cloud_polling",
"loggers": ["RMVtransport"],
+ "quality_scale": "legacy",
"requirements": ["PyRMVtransport==0.3.3"]
}
diff --git a/homeassistant/components/roborock/__init__.py b/homeassistant/components/roborock/__init__.py
index d1cbccc6b05..9ab9226c9a5 100644
--- a/homeassistant/components/roborock/__init__.py
+++ b/homeassistant/components/roborock/__init__.py
@@ -9,7 +9,13 @@ from datetime import timedelta
import logging
from typing import Any
-from roborock import HomeDataRoom, RoborockException, RoborockInvalidCredentials
+from roborock import (
+ HomeDataRoom,
+ RoborockException,
+ RoborockInvalidCredentials,
+ RoborockInvalidUserAgreement,
+ RoborockNoUserAgreement,
+)
from roborock.containers import DeviceData, HomeDataDevice, HomeDataProduct, UserData
from roborock.version_1_apis.roborock_mqtt_client_v1 import RoborockMqttClientV1
from roborock.version_a01_apis import RoborockMqttClientA01
@@ -47,7 +53,6 @@ class RoborockCoordinators:
async def async_setup_entry(hass: HomeAssistant, entry: RoborockConfigEntry) -> bool:
"""Set up roborock from a config entry."""
- _LOGGER.debug("Integration async setup entry: %s", entry.as_dict())
entry.async_on_unload(entry.add_update_listener(update_listener))
user_data = UserData.from_dict(entry.data[CONF_USER_DATA])
@@ -61,12 +66,23 @@ async def async_setup_entry(hass: HomeAssistant, entry: RoborockConfigEntry) ->
translation_domain=DOMAIN,
translation_key="invalid_credentials",
) from err
+ except RoborockInvalidUserAgreement as err:
+ raise ConfigEntryNotReady(
+ translation_domain=DOMAIN,
+ translation_key="invalid_user_agreement",
+ ) from err
+ except RoborockNoUserAgreement as err:
+ raise ConfigEntryNotReady(
+ translation_domain=DOMAIN,
+ translation_key="no_user_agreement",
+ ) from err
except RoborockException as err:
raise ConfigEntryNotReady(
"Failed to get Roborock home data",
translation_domain=DOMAIN,
translation_key="home_data_fail",
) from err
+
_LOGGER.debug("Got home data %s", home_data)
all_devices: list[HomeDataDevice] = home_data.devices + home_data.received_devices
device_map: dict[str, HomeDataDevice] = {
@@ -189,14 +205,6 @@ 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:
diff --git a/homeassistant/components/roborock/config_flow.py b/homeassistant/components/roborock/config_flow.py
index 200614b024e..1a6b67286bb 100644
--- a/homeassistant/components/roborock/config_flow.py
+++ b/homeassistant/components/roborock/config_flow.py
@@ -60,7 +60,7 @@ class RoborockFlowHandler(ConfigFlow, domain=DOMAIN):
if user_input is not None:
username = user_input[CONF_USERNAME]
await self.async_set_unique_id(username.lower())
- self._abort_if_unique_id_configured()
+ self._abort_if_unique_id_configured(error="already_configured_account")
self._username = username
_LOGGER.debug("Requesting code for Roborock account")
self._client = RoborockApiClient(username)
diff --git a/homeassistant/components/roborock/const.py b/homeassistant/components/roborock/const.py
index 834b25965c3..4a9bd14bfe1 100644
--- a/homeassistant/components/roborock/const.py
+++ b/homeassistant/components/roborock/const.py
@@ -49,3 +49,5 @@ IMAGE_CACHE_INTERVAL = 90
MAP_SLEEP = 3
GET_MAPS_SERVICE_NAME = "get_maps"
+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 20bc50f9855..443e50642f2 100644
--- a/homeassistant/components/roborock/coordinator.py
+++ b/homeassistant/components/roborock/coordinator.py
@@ -2,7 +2,6 @@
from __future__ import annotations
-import asyncio
from datetime import timedelta
import logging
@@ -74,7 +73,27 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]):
self.maps: dict[int, RoborockMapInfo] = {}
self._home_data_rooms = {str(room.id): room.name for room in home_data_rooms}
- 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:
@@ -97,18 +116,18 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]):
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."""
try:
- await asyncio.gather(*(self._update_device_prop(), self.get_rooms()))
+ # Update device props and standard api information
+ await self._update_device_prop()
+ # Set the new map id from the updated device props
self._set_current_map()
+ # Get the rooms for that map id.
+ await self.set_current_map_rooms()
except RoborockException as ex:
raise UpdateFailed(ex) from ex
return self.roborock_device_info.props
@@ -124,27 +143,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:
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..8717920b907 100644
--- a/homeassistant/components/roborock/image.py
+++ b/homeassistant/components/roborock/image.py
@@ -121,7 +121,10 @@ 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):
@@ -174,7 +177,8 @@ async def create_coordinator_maps(
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
+ *[coord.cloud_api.get_map_v1(), coord.set_current_map_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.
diff --git a/homeassistant/components/roborock/manifest.json b/homeassistant/components/roborock/manifest.json
index c305e4710fc..bb89ecedbe3 100644
--- a/homeassistant/components/roborock/manifest.json
+++ b/homeassistant/components/roborock/manifest.json
@@ -7,7 +7,7 @@
"iot_class": "local_polling",
"loggers": ["roborock"],
"requirements": [
- "python-roborock==2.7.2",
+ "python-roborock==2.8.4",
"vacuum-map-parser-roborock==0.1.2"
]
}
diff --git a/homeassistant/components/roborock/select.py b/homeassistant/components/roborock/select.py
index 3dfe0e72a7b..73cb95d2d7c 100644
--- a/homeassistant/components/roborock/select.py
+++ b/homeassistant/components/roborock/select.py
@@ -135,6 +135,9 @@ class RoborockCurrentMapSelectEntity(RoborockCoordinatedEntityV1, SelectEntity):
RoborockCommand.LOAD_MULTI_MAP,
[map_id],
)
+ # Update the current map id manually so that nothing gets broken
+ # if another service hits the api.
+ self.coordinator.current_map = map_id
# We need to wait after updating the map
# so that other commands will be executed correctly.
await asyncio.sleep(MAP_SLEEP)
@@ -148,6 +151,9 @@ class RoborockCurrentMapSelectEntity(RoborockCoordinatedEntityV1, SelectEntity):
@property
def current_option(self) -> str | None:
"""Get the current status of the select entity from device_status."""
- if (current_map := self.coordinator.current_map) is not None:
+ if (
+ (current_map := self.coordinator.current_map) is not None
+ and current_map in self.coordinator.maps
+ ): # 63 means it is searching for a map.
return self.coordinator.maps[current_map].name
return None
diff --git a/homeassistant/components/roborock/sensor.py b/homeassistant/components/roborock/sensor.py
index 33ce6be5a68..e01a03d7720 100644
--- a/homeassistant/components/roborock/sensor.py
+++ b/homeassistant/components/roborock/sensor.py
@@ -24,13 +24,9 @@ from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
+ SensorStateClass,
)
-from homeassistant.const import (
- AREA_SQUARE_METERS,
- PERCENTAGE,
- EntityCategory,
- UnitOfTime,
-)
+from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfArea, UnitOfTime
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import StateType
@@ -117,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,
@@ -131,14 +134,14 @@ SENSOR_DESCRIPTIONS = [
translation_key="cleaning_area",
value_fn=lambda data: data.status.square_meter_clean_area,
entity_category=EntityCategory.DIAGNOSTIC,
- native_unit_of_measurement=AREA_SQUARE_METERS,
+ native_unit_of_measurement=UnitOfArea.SQUARE_METERS,
),
RoborockSensorDescription(
key="total_cleaning_area",
translation_key="total_cleaning_area",
value_fn=lambda data: data.clean_summary.square_meter_clean_area,
entity_category=EntityCategory.DIAGNOSTIC,
- native_unit_of_measurement=AREA_SQUARE_METERS,
+ native_unit_of_measurement=UnitOfArea.SQUARE_METERS,
),
RoborockSensorDescription(
key="vacuum_error",
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 8ff82cae393..7005344614c 100644
--- a/homeassistant/components/roborock/strings.json
+++ b/homeassistant/components/roborock/strings.json
@@ -28,7 +28,7 @@
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"abort": {
- "already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
+ "already_configured_account": "[%key:common::config_flow::abort::already_configured_account%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
}
},
@@ -228,6 +228,9 @@
"total_cleaning_area": {
"name": "Total cleaning area"
},
+ "total_cleaning_count": {
+ "name": "Total cleaning count"
+ },
"vacuum_error": {
"name": "Vacuum error",
"state": {
@@ -422,12 +425,36 @@
},
"update_options_failed": {
"message": "Failed to update Roborock options"
+ },
+ "invalid_user_agreement": {
+ "message": "User agreement must be accepted again. Open your Roborock app and accept the agreement."
+ },
+ "no_user_agreement": {
+ "message": "You have not valid user agreement. Open your Roborock app and accept the agreement."
}
},
"services": {
"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 3b873f259e4..7582dadad16 100644
--- a/homeassistant/components/roborock/vacuum.py
+++ b/homeassistant/components/roborock/vacuum.py
@@ -6,50 +6,53 @@ 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 (
- STATE_CLEANING,
- STATE_DOCKED,
- STATE_ERROR,
- STATE_IDLE,
- STATE_PAUSED,
- STATE_RETURNING,
StateVacuumEntity,
+ VacuumActivity,
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: STATE_IDLE, # "Starting"
- RoborockStateCode.charger_disconnected: STATE_IDLE, # "Charger disconnected"
- RoborockStateCode.idle: STATE_IDLE, # "Idle"
- RoborockStateCode.remote_control_active: STATE_CLEANING, # "Remote control active"
- RoborockStateCode.cleaning: STATE_CLEANING, # "Cleaning"
- RoborockStateCode.returning_home: STATE_RETURNING, # "Returning home"
- RoborockStateCode.manual_mode: STATE_CLEANING, # "Manual mode"
- RoborockStateCode.charging: STATE_DOCKED, # "Charging"
- RoborockStateCode.charging_problem: STATE_ERROR, # "Charging problem"
- RoborockStateCode.paused: STATE_PAUSED, # "Paused"
- RoborockStateCode.spot_cleaning: STATE_CLEANING, # "Spot cleaning"
- RoborockStateCode.error: STATE_ERROR, # "Error"
- RoborockStateCode.shutting_down: STATE_IDLE, # "Shutting down"
- RoborockStateCode.updating: STATE_DOCKED, # "Updating"
- RoborockStateCode.docking: STATE_RETURNING, # "Docking"
- RoborockStateCode.going_to_target: STATE_CLEANING, # "Going to target"
- RoborockStateCode.zoned_cleaning: STATE_CLEANING, # "Zoned cleaning"
- RoborockStateCode.segment_cleaning: STATE_CLEANING, # "Segment cleaning"
- RoborockStateCode.emptying_the_bin: STATE_DOCKED, # "Emptying the bin" on s7+
- RoborockStateCode.washing_the_mop: STATE_DOCKED, # "Washing the mop" on s7maxV
- RoborockStateCode.going_to_wash_the_mop: STATE_RETURNING, # "Going to wash the mop" on s7maxV
- RoborockStateCode.charging_complete: STATE_DOCKED, # "Charging complete"
- RoborockStateCode.device_offline: STATE_ERROR, # "Device offline"
+ RoborockStateCode.starting: VacuumActivity.IDLE, # "Starting"
+ RoborockStateCode.charger_disconnected: VacuumActivity.IDLE, # "Charger disconnected"
+ RoborockStateCode.idle: VacuumActivity.IDLE, # "Idle"
+ RoborockStateCode.remote_control_active: VacuumActivity.CLEANING, # "Remote control active"
+ RoborockStateCode.cleaning: VacuumActivity.CLEANING, # "Cleaning"
+ RoborockStateCode.returning_home: VacuumActivity.RETURNING, # "Returning home"
+ RoborockStateCode.manual_mode: VacuumActivity.CLEANING, # "Manual mode"
+ RoborockStateCode.charging: VacuumActivity.DOCKED, # "Charging"
+ RoborockStateCode.charging_problem: VacuumActivity.ERROR, # "Charging problem"
+ RoborockStateCode.paused: VacuumActivity.PAUSED, # "Paused"
+ RoborockStateCode.spot_cleaning: VacuumActivity.CLEANING, # "Spot cleaning"
+ RoborockStateCode.error: VacuumActivity.ERROR, # "Error"
+ RoborockStateCode.shutting_down: VacuumActivity.IDLE, # "Shutting down"
+ RoborockStateCode.updating: VacuumActivity.DOCKED, # "Updating"
+ RoborockStateCode.docking: VacuumActivity.RETURNING, # "Docking"
+ RoborockStateCode.going_to_target: VacuumActivity.CLEANING, # "Going to target"
+ RoborockStateCode.zoned_cleaning: VacuumActivity.CLEANING, # "Zoned cleaning"
+ RoborockStateCode.segment_cleaning: VacuumActivity.CLEANING, # "Segment cleaning"
+ RoborockStateCode.emptying_the_bin: VacuumActivity.DOCKED, # "Emptying the bin" on s7+
+ RoborockStateCode.washing_the_mop: VacuumActivity.DOCKED, # "Washing the mop" on s7maxV
+ RoborockStateCode.going_to_wash_the_mop: VacuumActivity.RETURNING, # "Going to wash the mop" on s7maxV
+ RoborockStateCode.charging_complete: VacuumActivity.DOCKED, # "Charging complete"
+ RoborockStateCode.device_offline: VacuumActivity.ERROR, # "Device offline"
}
@@ -74,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."""
@@ -112,7 +134,7 @@ class RoborockVacuum(RoborockCoordinatedEntityV1, StateVacuumEntity):
self._attr_fan_speed_list = self._device_status.fan_power_options
@property
- def state(self) -> str | None:
+ def activity(self) -> VacuumActivity | None:
"""Return the status of the vacuum cleaner."""
assert self._device_status.state is not None
return STATE_CODE_TO_STATE.get(self._device_status.state)
@@ -163,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,
@@ -179,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/manifest.json b/homeassistant/components/rocketchat/manifest.json
index 50d7579df02..f4f72f02a10 100644
--- a/homeassistant/components/rocketchat/manifest.json
+++ b/homeassistant/components/rocketchat/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/rocketchat",
"iot_class": "cloud_push",
"loggers": ["rocketchat_API"],
+ "quality_scale": "legacy",
"requirements": ["rocketchat-API==0.6.1"]
}
diff --git a/homeassistant/components/roku/__init__.py b/homeassistant/components/roku/__init__.py
index b318a91e4c7..e6b92d91335 100644
--- a/homeassistant/components/roku/__init__.py
+++ b/homeassistant/components/roku/__init__.py
@@ -6,7 +6,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, Platform
from homeassistant.core import HomeAssistant
-from .const import CONF_PLAY_MEDIA_APP_ID, DEFAULT_PLAY_MEDIA_APP_ID, DOMAIN
+from .const import CONF_PLAY_MEDIA_APP_ID, DEFAULT_PLAY_MEDIA_APP_ID
from .coordinator import RokuDataUpdateCoordinator
PLATFORMS = [
@@ -17,8 +17,10 @@ PLATFORMS = [
Platform.SENSOR,
]
+type RokuConfigEntry = ConfigEntry[RokuDataUpdateCoordinator]
-async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+
+async def async_setup_entry(hass: HomeAssistant, entry: RokuConfigEntry) -> bool:
"""Set up Roku from a config entry."""
if (device_id := entry.unique_id) is None:
device_id = entry.entry_id
@@ -33,7 +35,7 @@ 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)
@@ -42,13 +44,11 @@ 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: RokuConfigEntry) -> 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 async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
+async def async_reload_entry(hass: HomeAssistant, entry: RokuConfigEntry) -> None:
"""Reload the config entry when it changed."""
await hass.config_entries.async_reload(entry.entry_id)
diff --git a/homeassistant/components/roku/binary_sensor.py b/homeassistant/components/roku/binary_sensor.py
index 0f5f29f63f6..2e7fd12788c 100644
--- a/homeassistant/components/roku/binary_sensor.py
+++ b/homeassistant/components/roku/binary_sensor.py
@@ -11,14 +11,16 @@ 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 . import RokuConfigEntry
from .entity import RokuEntity
+# Coordinator is used to centralize the data updates
+PARALLEL_UPDATES = 0
+
@dataclass(frozen=True, kw_only=True)
class RokuBinarySensorEntityDescription(BinarySensorEntityDescription):
@@ -56,15 +58,13 @@ BINARY_SENSORS: tuple[RokuBinarySensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
- entry: ConfigEntry,
+ entry: RokuConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up a Roku binary sensors based on a config entry."""
- coordinator = hass.data[DOMAIN][entry.entry_id]
-
async_add_entities(
RokuBinarySensorEntity(
- coordinator=coordinator,
+ coordinator=entry.runtime_data,
description=description,
)
for description in BINARY_SENSORS
diff --git a/homeassistant/components/roku/config_flow.py b/homeassistant/components/roku/config_flow.py
index 18e3b3ed68a..bc0092d6953 100644
--- a/homeassistant/components/roku/config_flow.py
+++ b/homeassistant/components/roku/config_flow.py
@@ -11,7 +11,7 @@ import voluptuous as vol
from homeassistant.components import ssdp, zeroconf
from homeassistant.config_entries import (
- ConfigEntry,
+ SOURCE_RECONFIGURE,
ConfigFlow,
ConfigFlowResult,
OptionsFlow,
@@ -20,6 +20,7 @@ from homeassistant.const import CONF_HOST, CONF_NAME
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from . import RokuConfigEntry
from .const import CONF_PLAY_MEDIA_APP_ID, DEFAULT_PLAY_MEDIA_APP_ID, DOMAIN
DATA_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str})
@@ -57,20 +58,38 @@ class RokuConfigFlow(ConfigFlow, domain=DOMAIN):
self.discovery_info = {}
@callback
- def _show_form(self, errors: dict[str, Any] | None = None) -> ConfigFlowResult:
+ def _show_form(
+ self,
+ user_input: dict[str, Any] | None,
+ errors: dict[str, Any] | None = None,
+ ) -> ConfigFlowResult:
"""Show the form to the user."""
+ suggested_values = user_input
+ if suggested_values is None and self.source == SOURCE_RECONFIGURE:
+ suggested_values = {
+ CONF_HOST: self._get_reconfigure_entry().data[CONF_HOST]
+ }
+
return self.async_show_form(
step_id="user",
- data_schema=DATA_SCHEMA,
+ data_schema=self.add_suggested_values_to_schema(
+ DATA_SCHEMA, suggested_values
+ ),
errors=errors or {},
)
+ async def async_step_reconfigure(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Handle reconfiguration of the integration."""
+ return await self.async_step_user(user_input)
+
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle a flow initialized by the user."""
if not user_input:
- return self._show_form()
+ return self._show_form(user_input)
errors = {}
@@ -79,13 +98,21 @@ class RokuConfigFlow(ConfigFlow, domain=DOMAIN):
except RokuError:
_LOGGER.debug("Roku Error", exc_info=True)
errors["base"] = ERROR_CANNOT_CONNECT
- return self._show_form(errors)
+ return self._show_form(user_input, errors)
except Exception:
_LOGGER.exception("Unknown error trying to connect")
return self.async_abort(reason=ERROR_UNKNOWN)
await self.async_set_unique_id(info["serial_number"])
- self._abort_if_unique_id_configured(updates={CONF_HOST: user_input[CONF_HOST]})
+
+ if self.source == SOURCE_RECONFIGURE:
+ self._abort_if_unique_id_mismatch(reason="wrong_device")
+ return self.async_update_reload_and_abort(
+ self._get_reconfigure_entry(),
+ data_updates={CONF_HOST: user_input[CONF_HOST]},
+ )
+
+ self._abort_if_unique_id_configured()
return self.async_create_entry(title=info["title"], data=user_input)
@@ -164,7 +191,7 @@ class RokuConfigFlow(ConfigFlow, domain=DOMAIN):
@staticmethod
@callback
def async_get_options_flow(
- config_entry: ConfigEntry,
+ config_entry: RokuConfigEntry,
) -> RokuOptionsFlowHandler:
"""Create the options flow."""
return RokuOptionsFlowHandler()
diff --git a/homeassistant/components/roku/diagnostics.py b/homeassistant/components/roku/diagnostics.py
index 6c6809ee33a..e98837ca442 100644
--- a/homeassistant/components/roku/diagnostics.py
+++ b/homeassistant/components/roku/diagnostics.py
@@ -4,25 +4,21 @@ 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 RokuDataUpdateCoordinator
+from . import RokuConfigEntry
async def async_get_config_entry_diagnostics(
- hass: HomeAssistant, config_entry: ConfigEntry
+ hass: HomeAssistant, entry: RokuConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
- coordinator: RokuDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id]
-
return {
"entry": {
"data": {
- **config_entry.data,
+ **entry.data,
},
- "unique_id": config_entry.unique_id,
+ "unique_id": entry.unique_id,
},
- "data": coordinator.data.as_dict(),
+ "data": entry.runtime_data.data.as_dict(),
}
diff --git a/homeassistant/components/roku/manifest.json b/homeassistant/components/roku/manifest.json
index fa9823de172..7fe2fb3b686 100644
--- a/homeassistant/components/roku/manifest.json
+++ b/homeassistant/components/roku/manifest.json
@@ -10,7 +10,6 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["rokuecp"],
- "quality_scale": "silver",
"requirements": ["rokuecp==0.19.3"],
"ssdp": [
{
diff --git a/homeassistant/components/roku/media_player.py b/homeassistant/components/roku/media_player.py
index 35f01553cdd..0c1f92521af 100644
--- a/homeassistant/components/roku/media_player.py
+++ b/homeassistant/components/roku/media_player.py
@@ -23,13 +23,13 @@ from homeassistant.components.media_player import (
async_process_play_media_url,
)
from homeassistant.components.stream import FORMAT_CONTENT_TYPE, HLS_PROVIDER
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_NAME
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_platform
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import VolDictType
+from . import RokuConfigEntry
from .browse_media import async_browse_media
from .const import (
ATTR_ARTIST_NAME,
@@ -38,7 +38,6 @@ from .const import (
ATTR_KEYWORD,
ATTR_MEDIA_TYPE,
ATTR_THUMBNAIL,
- DOMAIN,
SERVICE_SEARCH,
)
from .coordinator import RokuDataUpdateCoordinator
@@ -47,7 +46,6 @@ from .helpers import format_channel_name, roku_exception_handler
_LOGGER = logging.getLogger(__name__)
-
STREAM_FORMAT_TO_MEDIA_TYPE = {
"dash": MediaType.VIDEO,
"hls": MediaType.VIDEO,
@@ -81,17 +79,17 @@ ATTRS_TO_PLAY_ON_ROKU_AUDIO_PARAMS = {
SEARCH_SCHEMA: VolDictType = {vol.Required(ATTR_KEYWORD): str}
+PARALLEL_UPDATES = 1
+
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant, entry: RokuConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Set up the Roku config entry."""
- coordinator: RokuDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
-
async_add_entities(
[
RokuMediaPlayer(
- coordinator=coordinator,
+ coordinator=entry.runtime_data,
)
],
True,
diff --git a/homeassistant/components/roku/remote.py b/homeassistant/components/roku/remote.py
index fa351e021e8..f7916fb23a2 100644
--- a/homeassistant/components/roku/remote.py
+++ b/homeassistant/components/roku/remote.py
@@ -6,28 +6,26 @@ from collections.abc import Iterable
from typing import Any
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 .coordinator import RokuDataUpdateCoordinator
+from . import RokuConfigEntry
from .entity import RokuEntity
from .helpers import roku_exception_handler
+PARALLEL_UPDATES = 1
+
async def async_setup_entry(
hass: HomeAssistant,
- entry: ConfigEntry,
+ entry: RokuConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Load Roku remote based on a config entry."""
- coordinator: RokuDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
-
async_add_entities(
[
RokuRemote(
- coordinator=coordinator,
+ coordinator=entry.runtime_data,
)
],
True,
diff --git a/homeassistant/components/roku/select.py b/homeassistant/components/roku/select.py
index 5f3b9d4049b..360d4e25415 100644
--- a/homeassistant/components/roku/select.py
+++ b/homeassistant/components/roku/select.py
@@ -9,15 +9,15 @@ from rokuecp import Roku
from rokuecp.models import Device as RokuDevice
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 DOMAIN
-from .coordinator import RokuDataUpdateCoordinator
+from . import RokuConfigEntry
from .entity import RokuEntity
from .helpers import format_channel_name, roku_exception_handler
+PARALLEL_UPDATES = 1
+
def _get_application_name(device: RokuDevice) -> str | None:
if device.app is None or device.app.name is None:
@@ -108,16 +108,15 @@ CHANNEL_ENTITY = RokuSelectEntityDescription(
async def async_setup_entry(
hass: HomeAssistant,
- entry: ConfigEntry,
+ entry: RokuConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Roku select based on a config entry."""
- coordinator: RokuDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
- device: RokuDevice = coordinator.data
+ device: RokuDevice = entry.runtime_data.data
entities: list[RokuSelectEntity] = [
RokuSelectEntity(
- coordinator=coordinator,
+ coordinator=entry.runtime_data,
description=description,
)
for description in ENTITIES
@@ -126,7 +125,7 @@ async def async_setup_entry(
if len(device.channels) > 0:
entities.append(
RokuSelectEntity(
- coordinator=coordinator,
+ coordinator=entry.runtime_data,
description=CHANNEL_ENTITY,
)
)
diff --git a/homeassistant/components/roku/sensor.py b/homeassistant/components/roku/sensor.py
index ed134cc4c2a..870386945a6 100644
--- a/homeassistant/components/roku/sensor.py
+++ b/homeassistant/components/roku/sensor.py
@@ -8,15 +8,16 @@ from dataclasses import dataclass
from rokuecp.models import Device as RokuDevice
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 DOMAIN
-from .coordinator import RokuDataUpdateCoordinator
+from . import RokuConfigEntry
from .entity import RokuEntity
+# Coordinator is used to centralize the data updates
+PARALLEL_UPDATES = 0
+
@dataclass(frozen=True, kw_only=True)
class RokuSensorEntityDescription(SensorEntityDescription):
@@ -43,15 +44,13 @@ SENSORS: tuple[RokuSensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
- entry: ConfigEntry,
+ entry: RokuConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Roku sensor based on a config entry."""
- coordinator: RokuDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
-
async_add_entities(
RokuSensorEntity(
- coordinator=coordinator,
+ coordinator=entry.runtime_data,
description=description,
)
for description in SENSORS
diff --git a/homeassistant/components/roku/strings.json b/homeassistant/components/roku/strings.json
index 9d657be6d61..04348bc3bfb 100644
--- a/homeassistant/components/roku/strings.json
+++ b/homeassistant/components/roku/strings.json
@@ -21,7 +21,9 @@
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
- "unknown": "[%key:common::config_flow::error::unknown%]"
+ "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."
}
},
"options": {
diff --git a/homeassistant/components/romy/sensor.py b/homeassistant/components/romy/sensor.py
index bdd486c4f8f..341125b86ba 100644
--- a/homeassistant/components/romy/sensor.py
+++ b/homeassistant/components/romy/sensor.py
@@ -8,10 +8,10 @@ from homeassistant.components.sensor import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
- AREA_SQUARE_METERS,
PERCENTAGE,
SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
EntityCategory,
+ UnitOfArea,
UnitOfLength,
UnitOfTime,
)
@@ -61,7 +61,7 @@ SENSORS: list[SensorEntityDescription] = [
key="total_area_cleaned",
translation_key="total_area_cleaned",
state_class=SensorStateClass.TOTAL,
- native_unit_of_measurement=AREA_SQUARE_METERS,
+ native_unit_of_measurement=UnitOfArea.SQUARE_METERS,
entity_category=EntityCategory.DIAGNOSTIC,
),
SensorEntityDescription(
diff --git a/homeassistant/components/romy/vacuum.py b/homeassistant/components/romy/vacuum.py
index de74d371f0e..49129daabbd 100644
--- a/homeassistant/components/romy/vacuum.py
+++ b/homeassistant/components/romy/vacuum.py
@@ -6,7 +6,11 @@ https://home-assistant.io/components/vacuum.romy/.
from typing import Any
-from homeassistant.components.vacuum import StateVacuumEntity, VacuumEntityFeature
+from homeassistant.components.vacuum import (
+ StateVacuumEntity,
+ VacuumActivity,
+ VacuumEntityFeature,
+)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
@@ -75,7 +79,14 @@ class RomyVacuumEntity(RomyEntity, StateVacuumEntity):
"""Handle updated data from the coordinator."""
self._attr_fan_speed = FAN_SPEEDS[self.romy.fan_speed]
self._attr_battery_level = self.romy.battery_level
- self._attr_state = self.romy.status
+ if (status := self.romy.status) is None:
+ self._attr_activity = None
+ self.async_write_ha_state()
+ return
+ try:
+ self._attr_activity = VacuumActivity(status)
+ except ValueError:
+ self._attr_activity = None
self.async_write_ha_state()
diff --git a/homeassistant/components/roomba/braava.py b/homeassistant/components/roomba/braava.py
deleted file mode 100644
index 8744561b2c5..00000000000
--- a/homeassistant/components/roomba/braava.py
+++ /dev/null
@@ -1,128 +0,0 @@
-"""Class for Braava devices."""
-
-import logging
-
-from homeassistant.components.vacuum import VacuumEntityFeature
-
-from .entity import SUPPORT_IROBOT, IRobotVacuum
-
-_LOGGER = logging.getLogger(__name__)
-
-ATTR_DETECTED_PAD = "detected_pad"
-ATTR_LID_CLOSED = "lid_closed"
-ATTR_TANK_PRESENT = "tank_present"
-ATTR_TANK_LEVEL = "tank_level"
-ATTR_PAD_WETNESS = "spray_amount"
-
-OVERLAP_STANDARD = 67
-OVERLAP_DEEP = 85
-OVERLAP_EXTENDED = 25
-MOP_STANDARD = "Standard"
-MOP_DEEP = "Deep"
-MOP_EXTENDED = "Extended"
-BRAAVA_MOP_BEHAVIORS = [MOP_STANDARD, MOP_DEEP, MOP_EXTENDED]
-BRAAVA_SPRAY_AMOUNT = [1, 2, 3]
-
-# Braava Jets can set mopping behavior through fanspeed
-SUPPORT_BRAAVA = SUPPORT_IROBOT | VacuumEntityFeature.FAN_SPEED
-
-
-class BraavaJet(IRobotVacuum): # pylint: disable=hass-enforce-class-module
- """Braava Jet."""
-
- _attr_supported_features = SUPPORT_BRAAVA
-
- def __init__(self, roomba, blid):
- """Initialize the Roomba handler."""
- super().__init__(roomba, blid)
-
- # Initialize fan speed list
- self._attr_fan_speed_list = [
- f"{behavior}-{spray}"
- for behavior in BRAAVA_MOP_BEHAVIORS
- for spray in BRAAVA_SPRAY_AMOUNT
- ]
-
- @property
- def fan_speed(self):
- """Return the fan speed of the vacuum cleaner."""
- # Mopping behavior and spray amount as fan speed
- rank_overlap = self.vacuum_state.get("rankOverlap", {})
- behavior = None
- if rank_overlap == OVERLAP_STANDARD:
- behavior = MOP_STANDARD
- elif rank_overlap == OVERLAP_DEEP:
- behavior = MOP_DEEP
- elif rank_overlap == OVERLAP_EXTENDED:
- behavior = MOP_EXTENDED
- pad_wetness = self.vacuum_state.get("padWetness", {})
- # "disposable" and "reusable" values are always the same
- pad_wetness_value = pad_wetness.get("disposable")
- return f"{behavior}-{pad_wetness_value}"
-
- async def async_set_fan_speed(self, fan_speed, **kwargs):
- """Set fan speed."""
- try:
- split = fan_speed.split("-", 1)
- behavior = split[0]
- spray = int(split[1])
- if behavior.capitalize() in BRAAVA_MOP_BEHAVIORS:
- behavior = behavior.capitalize()
- except IndexError:
- _LOGGER.error(
- "Fan speed error: expected {behavior}-{spray_amount}, got '%s'",
- fan_speed,
- )
- return
- except ValueError:
- _LOGGER.error("Spray amount error: expected integer, got '%s'", split[1])
- return
- if behavior not in BRAAVA_MOP_BEHAVIORS:
- _LOGGER.error(
- "Mop behavior error: expected one of %s, got '%s'",
- str(BRAAVA_MOP_BEHAVIORS),
- behavior,
- )
- return
- if spray not in BRAAVA_SPRAY_AMOUNT:
- _LOGGER.error(
- "Spray amount error: expected one of %s, got '%d'",
- str(BRAAVA_SPRAY_AMOUNT),
- spray,
- )
- return
-
- overlap = 0
- if behavior == MOP_STANDARD:
- overlap = OVERLAP_STANDARD
- elif behavior == MOP_DEEP:
- overlap = OVERLAP_DEEP
- else:
- overlap = OVERLAP_EXTENDED
- await self.hass.async_add_executor_job(
- self.vacuum.set_preference, "rankOverlap", overlap
- )
- await self.hass.async_add_executor_job(
- self.vacuum.set_preference,
- "padWetness",
- {"disposable": spray, "reusable": spray},
- )
-
- @property
- def extra_state_attributes(self):
- """Return the state attributes of the device."""
- state_attrs = super().extra_state_attributes
-
- # Get Braava state
- state = self.vacuum_state
- detected_pad = state.get("detectedPad")
- mop_ready = state.get("mopReady", {})
- lid_closed = mop_ready.get("lidClosed")
- tank_present = mop_ready.get("tankPresent")
- tank_level = state.get("tankLvl")
- state_attrs[ATTR_DETECTED_PAD] = detected_pad
- state_attrs[ATTR_LID_CLOSED] = lid_closed
- state_attrs[ATTR_TANK_PRESENT] = tank_present
- state_attrs[ATTR_TANK_LEVEL] = tank_level
-
- return state_attrs
diff --git a/homeassistant/components/roomba/config_flow.py b/homeassistant/components/roomba/config_flow.py
index e48d2d91139..d040074246a 100644
--- a/homeassistant/components/roomba/config_flow.py
+++ b/homeassistant/components/roomba/config_flow.py
@@ -79,7 +79,7 @@ class RoombaConfigFlow(ConfigFlow, domain=DOMAIN):
VERSION = 1
name: str | None = None
- blid: str | None = None
+ blid: str
host: str | None = None
def __init__(self) -> None:
diff --git a/homeassistant/components/roomba/entity.py b/homeassistant/components/roomba/entity.py
index 10c3d36de12..d55a260e53a 100644
--- a/homeassistant/components/roomba/entity.py
+++ b/homeassistant/components/roomba/entity.py
@@ -2,62 +2,15 @@
from __future__ import annotations
-import asyncio
-import logging
-
-from homeassistant.components.vacuum import (
- ATTR_STATUS,
- STATE_CLEANING,
- STATE_DOCKED,
- STATE_ERROR,
- STATE_RETURNING,
- StateVacuumEntity,
- VacuumEntityFeature,
-)
-from homeassistant.const import ATTR_CONNECTIONS, STATE_IDLE, STATE_PAUSED
+from homeassistant.const import ATTR_CONNECTIONS
import homeassistant.helpers.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.unit_system import METRIC_SYSTEM
from . import roomba_reported_state
from .const import DOMAIN
-_LOGGER = logging.getLogger(__name__)
-
-ATTR_CLEANING_TIME = "cleaning_time"
-ATTR_CLEANED_AREA = "cleaned_area"
-ATTR_ERROR = "error"
-ATTR_ERROR_CODE = "error_code"
-ATTR_POSITION = "position"
-ATTR_SOFTWARE_VERSION = "software_version"
-
-# Commonly supported features
-SUPPORT_IROBOT = (
- VacuumEntityFeature.BATTERY
- | VacuumEntityFeature.PAUSE
- | VacuumEntityFeature.RETURN_HOME
- | VacuumEntityFeature.SEND_COMMAND
- | VacuumEntityFeature.START
- | VacuumEntityFeature.STATE
- | VacuumEntityFeature.STOP
- | VacuumEntityFeature.LOCATE
-)
-
-STATE_MAP = {
- "": STATE_IDLE,
- "charge": STATE_DOCKED,
- "evac": STATE_RETURNING, # Emptying at cleanbase
- "hmMidMsn": STATE_CLEANING, # Recharging at the middle of a cycle
- "hmPostMsn": STATE_RETURNING, # Cycle finished
- "hmUsrDock": STATE_RETURNING,
- "pause": STATE_PAUSED,
- "run": STATE_CLEANING,
- "stop": STATE_IDLE,
- "stuck": STATE_ERROR,
-}
-
class IRobotEntity(Entity):
"""Base class for iRobot Entities."""
@@ -65,7 +18,7 @@ class IRobotEntity(Entity):
_attr_should_poll = False
_attr_has_entity_name = True
- def __init__(self, roomba, blid):
+ def __init__(self, roomba, blid) -> None:
"""Initialize the iRobot handler."""
self.vacuum = roomba
self._blid = blid
@@ -127,20 +80,6 @@ class IRobotEntity(Entity):
return None
return dt_util.utc_from_timestamp(ts)
- @property
- def _robot_state(self):
- """Return the state of the vacuum cleaner."""
- clean_mission_status = self.vacuum_state.get("cleanMissionStatus", {})
- cycle = clean_mission_status.get("cycle")
- phase = clean_mission_status.get("phase")
- try:
- state = STATE_MAP[phase]
- except KeyError:
- return STATE_ERROR
- if cycle != "none" and state in (STATE_IDLE, STATE_DOCKED):
- state = STATE_PAUSED
- return state
-
async def async_added_to_hass(self):
"""Register callback function."""
self.vacuum.register_on_message_callback(self.on_message)
@@ -154,125 +93,3 @@ class IRobotEntity(Entity):
state = json_data.get("state", {}).get("reported", {})
if self.new_state_filter(state):
self.schedule_update_ha_state()
-
-
-class IRobotVacuum(IRobotEntity, StateVacuumEntity): # pylint: disable=hass-enforce-class-module
- """Base class for iRobot robots."""
-
- _attr_name = None
- _attr_supported_features = SUPPORT_IROBOT
- _attr_available = True # Always available, otherwise setup will fail
-
- def __init__(self, roomba, blid):
- """Initialize the iRobot handler."""
- super().__init__(roomba, blid)
- self._cap_position = self.vacuum_state.get("cap", {}).get("pose") == 1
-
- @property
- def state(self):
- """Return the state of the vacuum cleaner."""
- return self._robot_state
-
- @property
- def extra_state_attributes(self):
- """Return the state attributes of the device."""
- state = self.vacuum_state
-
- # Roomba software version
- software_version = state.get("softwareVer")
-
- # Set properties that are to appear in the GUI
- state_attrs = {ATTR_SOFTWARE_VERSION: software_version}
-
- # Set legacy status to avoid break changes
- state_attrs[ATTR_STATUS] = self.vacuum.current_state
-
- # Only add cleaning time and cleaned area attrs when the vacuum is
- # currently on
- if self.state == STATE_CLEANING:
- # Get clean mission status
- (
- state_attrs[ATTR_CLEANING_TIME],
- state_attrs[ATTR_CLEANED_AREA],
- ) = self.get_cleaning_status(state)
-
- # Error
- if self.vacuum.error_code != 0:
- state_attrs[ATTR_ERROR] = self.vacuum.error_message
- state_attrs[ATTR_ERROR_CODE] = self.vacuum.error_code
-
- # Not all Roombas expose position data
- # https://github.com/koalazak/dorita980/issues/48
- if self._cap_position:
- pos_state = state.get("pose", {})
- position = None
- pos_x = pos_state.get("point", {}).get("x")
- pos_y = pos_state.get("point", {}).get("y")
- theta = pos_state.get("theta")
- if all(item is not None for item in (pos_x, pos_y, theta)):
- position = f"({pos_x}, {pos_y}, {theta})"
- state_attrs[ATTR_POSITION] = position
-
- return state_attrs
-
- def get_cleaning_status(self, state) -> tuple[int, int]:
- """Return the cleaning time and cleaned area from the device."""
- if not (mission_state := state.get("cleanMissionStatus")):
- return (0, 0)
-
- if cleaning_time := mission_state.get("mssnM", 0):
- pass
- elif start_time := mission_state.get("mssnStrtTm"):
- now = dt_util.as_timestamp(dt_util.utcnow())
- if now > start_time:
- cleaning_time = (now - start_time) // 60
-
- if cleaned_area := mission_state.get("sqft", 0): # Imperial
- # Convert to m2 if the unit_system is set to metric
- if self.hass.config.units is METRIC_SYSTEM:
- cleaned_area = round(cleaned_area * 0.0929)
-
- return (cleaning_time, cleaned_area)
-
- def on_message(self, json_data):
- """Update state on message change."""
- state = json_data.get("state", {}).get("reported", {})
- if self.new_state_filter(state):
- _LOGGER.debug("Got new state from the vacuum: %s", json_data)
- self.schedule_update_ha_state()
-
- async def async_start(self):
- """Start or resume the cleaning task."""
- if self.state == STATE_PAUSED:
- await self.hass.async_add_executor_job(self.vacuum.send_command, "resume")
- else:
- await self.hass.async_add_executor_job(self.vacuum.send_command, "start")
-
- async def async_stop(self, **kwargs):
- """Stop the vacuum cleaner."""
- await self.hass.async_add_executor_job(self.vacuum.send_command, "stop")
-
- async def async_pause(self):
- """Pause the cleaning cycle."""
- await self.hass.async_add_executor_job(self.vacuum.send_command, "pause")
-
- async def async_return_to_base(self, **kwargs):
- """Set the vacuum cleaner to return to the dock."""
- if self.state == STATE_CLEANING:
- await self.async_pause()
- for _ in range(10):
- if self.state == STATE_PAUSED:
- break
- await asyncio.sleep(1)
- await self.hass.async_add_executor_job(self.vacuum.send_command, "dock")
-
- async def async_locate(self, **kwargs):
- """Located vacuum."""
- await self.hass.async_add_executor_job(self.vacuum.send_command, "find")
-
- async def async_send_command(self, command, params=None, **kwargs):
- """Send raw command."""
- _LOGGER.debug("async_send_command %s (%s), %s", command, params, kwargs)
- await self.hass.async_add_executor_job(
- self.vacuum.send_command, command, params
- )
diff --git a/homeassistant/components/roomba/roomba.py b/homeassistant/components/roomba/roomba.py
deleted file mode 100644
index 917fbb2bfff..00000000000
--- a/homeassistant/components/roomba/roomba.py
+++ /dev/null
@@ -1,89 +0,0 @@
-"""Class for Roomba devices."""
-
-import logging
-
-from homeassistant.components.vacuum import VacuumEntityFeature
-
-from .entity import SUPPORT_IROBOT, IRobotVacuum
-
-_LOGGER = logging.getLogger(__name__)
-
-ATTR_BIN_FULL = "bin_full"
-ATTR_BIN_PRESENT = "bin_present"
-
-FAN_SPEED_AUTOMATIC = "Automatic"
-FAN_SPEED_ECO = "Eco"
-FAN_SPEED_PERFORMANCE = "Performance"
-FAN_SPEEDS = [FAN_SPEED_AUTOMATIC, FAN_SPEED_ECO, FAN_SPEED_PERFORMANCE]
-
-# Only Roombas with CarpetBost can set their fanspeed
-SUPPORT_ROOMBA_CARPET_BOOST = SUPPORT_IROBOT | VacuumEntityFeature.FAN_SPEED
-
-
-class RoombaVacuum(IRobotVacuum): # pylint: disable=hass-enforce-class-module
- """Basic Roomba robot (without carpet boost)."""
-
- @property
- def extra_state_attributes(self):
- """Return the state attributes of the device."""
- state_attrs = super().extra_state_attributes
-
- # Get bin state
- bin_raw_state = self.vacuum_state.get("bin", {})
- bin_state = {}
- if bin_raw_state.get("present") is not None:
- bin_state[ATTR_BIN_PRESENT] = bin_raw_state.get("present")
- if bin_raw_state.get("full") is not None:
- bin_state[ATTR_BIN_FULL] = bin_raw_state.get("full")
- state_attrs.update(bin_state)
-
- return state_attrs
-
-
-class RoombaVacuumCarpetBoost(RoombaVacuum): # pylint: disable=hass-enforce-class-module
- """Roomba robot with carpet boost."""
-
- _attr_fan_speed_list = FAN_SPEEDS
- _attr_supported_features = SUPPORT_ROOMBA_CARPET_BOOST
-
- @property
- def fan_speed(self):
- """Return the fan speed of the vacuum cleaner."""
- fan_speed = None
- carpet_boost = self.vacuum_state.get("carpetBoost")
- high_perf = self.vacuum_state.get("vacHigh")
- if carpet_boost is not None and high_perf is not None:
- if carpet_boost:
- fan_speed = FAN_SPEED_AUTOMATIC
- elif high_perf:
- fan_speed = FAN_SPEED_PERFORMANCE
- else: # carpet_boost and high_perf are False
- fan_speed = FAN_SPEED_ECO
- return fan_speed
-
- async def async_set_fan_speed(self, fan_speed, **kwargs):
- """Set fan speed."""
- if fan_speed.capitalize() in FAN_SPEEDS:
- fan_speed = fan_speed.capitalize()
- _LOGGER.debug("Set fan speed to: %s", fan_speed)
- high_perf = None
- carpet_boost = None
- if fan_speed == FAN_SPEED_AUTOMATIC:
- high_perf = False
- carpet_boost = True
- elif fan_speed == FAN_SPEED_ECO:
- high_perf = False
- carpet_boost = False
- elif fan_speed == FAN_SPEED_PERFORMANCE:
- high_perf = True
- carpet_boost = False
- else:
- _LOGGER.error("No such fan speed available: %s", fan_speed)
- return
- # The set_preference method does only accept string values
- await self.hass.async_add_executor_job(
- self.vacuum.set_preference, "carpetBoost", str(carpet_boost)
- )
- await self.hass.async_add_executor_job(
- self.vacuum.set_preference, "vacHigh", str(high_perf)
- )
diff --git a/homeassistant/components/roomba/sensor.py b/homeassistant/components/roomba/sensor.py
index 87e97fdb760..d358dcb428c 100644
--- a/homeassistant/components/roomba/sensor.py
+++ b/homeassistant/components/roomba/sensor.py
@@ -12,12 +12,7 @@ from homeassistant.components.sensor import (
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import (
- AREA_SQUARE_METERS,
- PERCENTAGE,
- EntityCategory,
- UnitOfTime,
-)
+from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfArea, UnitOfTime
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import StateType
@@ -108,7 +103,7 @@ SENSORS: list[RoombaSensorEntityDescription] = [
RoombaSensorEntityDescription(
key="total_cleaned_area",
translation_key="total_cleaned_area",
- native_unit_of_measurement=AREA_SQUARE_METERS,
+ native_unit_of_measurement=UnitOfArea.SQUARE_METERS,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda self: (
None if (sqft := self.run_stats.get("sqft")) is None else sqft * 9.29
diff --git a/homeassistant/components/roomba/vacuum.py b/homeassistant/components/roomba/vacuum.py
index a45b8eea632..92063f74afa 100644
--- a/homeassistant/components/roomba/vacuum.py
+++ b/homeassistant/components/roomba/vacuum.py
@@ -2,16 +2,88 @@
from __future__ import annotations
+import asyncio
+import logging
+from typing import Any
+
+from homeassistant.components.vacuum import (
+ ATTR_STATUS,
+ StateVacuumEntity,
+ VacuumActivity,
+ VacuumEntityFeature,
+)
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 homeassistant.util.unit_system import METRIC_SYSTEM
from . import roomba_reported_state
-from .braava import BraavaJet
from .const import DOMAIN
-from .entity import IRobotVacuum
+from .entity import IRobotEntity
from .models import RoombaData
-from .roomba import RoombaVacuum, RoombaVacuumCarpetBoost
+
+SUPPORT_IROBOT = (
+ VacuumEntityFeature.BATTERY
+ | VacuumEntityFeature.PAUSE
+ | VacuumEntityFeature.RETURN_HOME
+ | VacuumEntityFeature.SEND_COMMAND
+ | VacuumEntityFeature.START
+ | VacuumEntityFeature.STATE
+ | VacuumEntityFeature.STOP
+ | VacuumEntityFeature.LOCATE
+)
+
+STATE_MAP = {
+ "": VacuumActivity.IDLE,
+ "charge": VacuumActivity.DOCKED,
+ "evac": VacuumActivity.RETURNING, # Emptying at cleanbase
+ "hmMidMsn": VacuumActivity.CLEANING, # Recharging at the middle of a cycle
+ "hmPostMsn": VacuumActivity.RETURNING, # Cycle finished
+ "hmUsrDock": VacuumActivity.RETURNING,
+ "pause": VacuumActivity.PAUSED,
+ "run": VacuumActivity.CLEANING,
+ "stop": VacuumActivity.IDLE,
+ "stuck": VacuumActivity.ERROR,
+}
+
+_LOGGER = logging.getLogger(__name__)
+ATTR_SOFTWARE_VERSION = "software_version"
+ATTR_CLEANING_TIME = "cleaning_time"
+ATTR_CLEANED_AREA = "cleaned_area"
+ATTR_ERROR = "error"
+ATTR_ERROR_CODE = "error_code"
+ATTR_POSITION = "position"
+ATTR_SOFTWARE_VERSION = "software_version"
+
+ATTR_BIN_FULL = "bin_full"
+ATTR_BIN_PRESENT = "bin_present"
+
+FAN_SPEED_AUTOMATIC = "Automatic"
+FAN_SPEED_ECO = "Eco"
+FAN_SPEED_PERFORMANCE = "Performance"
+FAN_SPEEDS = [FAN_SPEED_AUTOMATIC, FAN_SPEED_ECO, FAN_SPEED_PERFORMANCE]
+
+# Only Roombas with CarpetBost can set their fanspeed
+SUPPORT_ROOMBA_CARPET_BOOST = SUPPORT_IROBOT | VacuumEntityFeature.FAN_SPEED
+
+ATTR_DETECTED_PAD = "detected_pad"
+ATTR_LID_CLOSED = "lid_closed"
+ATTR_TANK_PRESENT = "tank_present"
+ATTR_TANK_LEVEL = "tank_level"
+ATTR_PAD_WETNESS = "spray_amount"
+
+OVERLAP_STANDARD = 67
+OVERLAP_DEEP = 85
+OVERLAP_EXTENDED = 25
+MOP_STANDARD = "Standard"
+MOP_DEEP = "Deep"
+MOP_EXTENDED = "Extended"
+BRAAVA_MOP_BEHAVIORS = [MOP_STANDARD, MOP_DEEP, MOP_EXTENDED]
+BRAAVA_SPRAY_AMOUNT = [1, 2, 3]
+
+# Braava Jets can set mopping behavior through fanspeed
+SUPPORT_BRAAVA = SUPPORT_IROBOT | VacuumEntityFeature.FAN_SPEED
async def async_setup_entry(
@@ -39,3 +111,304 @@ async def async_setup_entry(
roomba_vac = constructor(roomba, blid)
async_add_entities([roomba_vac])
+
+
+class IRobotVacuum(IRobotEntity, StateVacuumEntity):
+ """Base class for iRobot robots."""
+
+ _attr_name = None
+ _attr_supported_features = SUPPORT_IROBOT
+ _attr_available = True # Always available, otherwise setup will fail
+
+ def __init__(self, roomba, blid) -> None:
+ """Initialize the iRobot handler."""
+ super().__init__(roomba, blid)
+ self._cap_position = self.vacuum_state.get("cap", {}).get("pose") == 1
+
+ @property
+ def activity(self):
+ """Return the state of the vacuum cleaner."""
+ clean_mission_status = self.vacuum_state.get("cleanMissionStatus", {})
+ cycle = clean_mission_status.get("cycle")
+ phase = clean_mission_status.get("phase")
+ try:
+ state = STATE_MAP[phase]
+ except KeyError:
+ return VacuumActivity.ERROR
+ if cycle != "none" and state in (VacuumActivity.IDLE, VacuumActivity.DOCKED):
+ state = VacuumActivity.PAUSED
+ return state
+
+ @property
+ def extra_state_attributes(self) -> dict[str, Any]:
+ """Return the state attributes of the device."""
+ state = self.vacuum_state
+
+ # Roomba software version
+ software_version = state.get("softwareVer")
+
+ # Set properties that are to appear in the GUI
+ state_attrs = {ATTR_SOFTWARE_VERSION: software_version}
+
+ # Set legacy status to avoid break changes
+ state_attrs[ATTR_STATUS] = self.vacuum.current_state
+
+ # Only add cleaning time and cleaned area attrs when the vacuum is
+ # currently on
+ if self.state == VacuumActivity.CLEANING:
+ # Get clean mission status
+ (
+ state_attrs[ATTR_CLEANING_TIME],
+ state_attrs[ATTR_CLEANED_AREA],
+ ) = self.get_cleaning_status(state)
+
+ # Error
+ if self.vacuum.error_code != 0:
+ state_attrs[ATTR_ERROR] = self.vacuum.error_message
+ state_attrs[ATTR_ERROR_CODE] = self.vacuum.error_code
+
+ # Not all Roombas expose position data
+ # https://github.com/koalazak/dorita980/issues/48
+ if self._cap_position:
+ pos_state = state.get("pose", {})
+ position = None
+ pos_x = pos_state.get("point", {}).get("x")
+ pos_y = pos_state.get("point", {}).get("y")
+ theta = pos_state.get("theta")
+ if all(item is not None for item in (pos_x, pos_y, theta)):
+ position = f"({pos_x}, {pos_y}, {theta})"
+ state_attrs[ATTR_POSITION] = position
+
+ return state_attrs
+
+ def get_cleaning_status(self, state) -> tuple[int, int]:
+ """Return the cleaning time and cleaned area from the device."""
+ if not (mission_state := state.get("cleanMissionStatus")):
+ return (0, 0)
+
+ if cleaning_time := mission_state.get("mssnM", 0):
+ pass
+ elif start_time := mission_state.get("mssnStrtTm"):
+ now = dt_util.as_timestamp(dt_util.utcnow())
+ if now > start_time:
+ cleaning_time = (now - start_time) // 60
+
+ if cleaned_area := mission_state.get("sqft", 0): # Imperial
+ # Convert to m2 if the unit_system is set to metric
+ if self.hass.config.units is METRIC_SYSTEM:
+ cleaned_area = round(cleaned_area * 0.0929)
+
+ return (cleaning_time, cleaned_area)
+
+ def on_message(self, json_data):
+ """Update state on message change."""
+ state = json_data.get("state", {}).get("reported", {})
+ if self.new_state_filter(state):
+ _LOGGER.debug("Got new state from the vacuum: %s", json_data)
+ self.schedule_update_ha_state()
+
+ async def async_start(self) -> None:
+ """Start or resume the cleaning task."""
+ if self.state == VacuumActivity.PAUSED:
+ await self.hass.async_add_executor_job(self.vacuum.send_command, "resume")
+ else:
+ await self.hass.async_add_executor_job(self.vacuum.send_command, "start")
+
+ async def async_stop(self, **kwargs):
+ """Stop the vacuum cleaner."""
+ await self.hass.async_add_executor_job(self.vacuum.send_command, "stop")
+
+ async def async_pause(self) -> None:
+ """Pause the cleaning cycle."""
+ await self.hass.async_add_executor_job(self.vacuum.send_command, "pause")
+
+ async def async_return_to_base(self, **kwargs):
+ """Set the vacuum cleaner to return to the dock."""
+ if self.state == VacuumActivity.CLEANING:
+ await self.async_pause()
+ for _ in range(10):
+ if self.state == VacuumActivity.PAUSED:
+ break
+ await asyncio.sleep(1)
+ await self.hass.async_add_executor_job(self.vacuum.send_command, "dock")
+
+ async def async_locate(self, **kwargs):
+ """Located vacuum."""
+ await self.hass.async_add_executor_job(self.vacuum.send_command, "find")
+
+ async def async_send_command(self, command, params=None, **kwargs):
+ """Send raw command."""
+ _LOGGER.debug("async_send_command %s (%s), %s", command, params, kwargs)
+ await self.hass.async_add_executor_job(
+ self.vacuum.send_command, command, params
+ )
+
+
+class RoombaVacuum(IRobotVacuum):
+ """Basic Roomba robot (without carpet boost)."""
+
+ @property
+ def extra_state_attributes(self) -> dict[str, Any]:
+ """Return the state attributes of the device."""
+ state_attrs = super().extra_state_attributes
+
+ # Get bin state
+ bin_raw_state = self.vacuum_state.get("bin", {})
+ bin_state = {}
+ if bin_raw_state.get("present") is not None:
+ bin_state[ATTR_BIN_PRESENT] = bin_raw_state.get("present")
+ if bin_raw_state.get("full") is not None:
+ bin_state[ATTR_BIN_FULL] = bin_raw_state.get("full")
+ state_attrs.update(bin_state)
+
+ return state_attrs
+
+
+class RoombaVacuumCarpetBoost(RoombaVacuum):
+ """Roomba robot with carpet boost."""
+
+ _attr_fan_speed_list = FAN_SPEEDS
+ _attr_supported_features = SUPPORT_ROOMBA_CARPET_BOOST
+
+ @property
+ def fan_speed(self):
+ """Return the fan speed of the vacuum cleaner."""
+ fan_speed = None
+ carpet_boost = self.vacuum_state.get("carpetBoost")
+ high_perf = self.vacuum_state.get("vacHigh")
+ if carpet_boost is not None and high_perf is not None:
+ if carpet_boost:
+ fan_speed = FAN_SPEED_AUTOMATIC
+ elif high_perf:
+ fan_speed = FAN_SPEED_PERFORMANCE
+ else: # carpet_boost and high_perf are False
+ fan_speed = FAN_SPEED_ECO
+ return fan_speed
+
+ async def async_set_fan_speed(self, fan_speed, **kwargs):
+ """Set fan speed."""
+ if fan_speed.capitalize() in FAN_SPEEDS:
+ fan_speed = fan_speed.capitalize()
+ _LOGGER.debug("Set fan speed to: %s", fan_speed)
+ high_perf = None
+ carpet_boost = None
+ if fan_speed == FAN_SPEED_AUTOMATIC:
+ high_perf = False
+ carpet_boost = True
+ elif fan_speed == FAN_SPEED_ECO:
+ high_perf = False
+ carpet_boost = False
+ elif fan_speed == FAN_SPEED_PERFORMANCE:
+ high_perf = True
+ carpet_boost = False
+ else:
+ _LOGGER.error("No such fan speed available: %s", fan_speed)
+ return
+ # The set_preference method does only accept string values
+ await self.hass.async_add_executor_job(
+ self.vacuum.set_preference, "carpetBoost", str(carpet_boost)
+ )
+ await self.hass.async_add_executor_job(
+ self.vacuum.set_preference, "vacHigh", str(high_perf)
+ )
+
+
+class BraavaJet(IRobotVacuum):
+ """Braava Jet."""
+
+ _attr_supported_features = SUPPORT_BRAAVA
+
+ def __init__(self, roomba, blid) -> None:
+ """Initialize the Roomba handler."""
+ super().__init__(roomba, blid)
+
+ # Initialize fan speed list
+ self._attr_fan_speed_list = [
+ f"{behavior}-{spray}"
+ for behavior in BRAAVA_MOP_BEHAVIORS
+ for spray in BRAAVA_SPRAY_AMOUNT
+ ]
+
+ @property
+ def fan_speed(self):
+ """Return the fan speed of the vacuum cleaner."""
+ # Mopping behavior and spray amount as fan speed
+ rank_overlap = self.vacuum_state.get("rankOverlap", {})
+ behavior = None
+ if rank_overlap == OVERLAP_STANDARD:
+ behavior = MOP_STANDARD
+ elif rank_overlap == OVERLAP_DEEP:
+ behavior = MOP_DEEP
+ elif rank_overlap == OVERLAP_EXTENDED:
+ behavior = MOP_EXTENDED
+ pad_wetness = self.vacuum_state.get("padWetness", {})
+ # "disposable" and "reusable" values are always the same
+ pad_wetness_value = pad_wetness.get("disposable")
+ return f"{behavior}-{pad_wetness_value}"
+
+ async def async_set_fan_speed(self, fan_speed, **kwargs):
+ """Set fan speed."""
+ try:
+ split = fan_speed.split("-", 1)
+ behavior = split[0]
+ spray = int(split[1])
+ if behavior.capitalize() in BRAAVA_MOP_BEHAVIORS:
+ behavior = behavior.capitalize()
+ except IndexError:
+ _LOGGER.error(
+ "Fan speed error: expected {behavior}-{spray_amount}, got '%s'",
+ fan_speed,
+ )
+ return
+ except ValueError:
+ _LOGGER.error("Spray amount error: expected integer, got '%s'", split[1])
+ return
+ if behavior not in BRAAVA_MOP_BEHAVIORS:
+ _LOGGER.error(
+ "Mop behavior error: expected one of %s, got '%s'",
+ str(BRAAVA_MOP_BEHAVIORS),
+ behavior,
+ )
+ return
+ if spray not in BRAAVA_SPRAY_AMOUNT:
+ _LOGGER.error(
+ "Spray amount error: expected one of %s, got '%d'",
+ str(BRAAVA_SPRAY_AMOUNT),
+ spray,
+ )
+ return
+
+ overlap = 0
+ if behavior == MOP_STANDARD:
+ overlap = OVERLAP_STANDARD
+ elif behavior == MOP_DEEP:
+ overlap = OVERLAP_DEEP
+ else:
+ overlap = OVERLAP_EXTENDED
+ await self.hass.async_add_executor_job(
+ self.vacuum.set_preference, "rankOverlap", overlap
+ )
+ await self.hass.async_add_executor_job(
+ self.vacuum.set_preference,
+ "padWetness",
+ {"disposable": spray, "reusable": spray},
+ )
+
+ @property
+ def extra_state_attributes(self) -> dict[str, Any]:
+ """Return the state attributes of the device."""
+ state_attrs = super().extra_state_attributes
+
+ # Get Braava state
+ state = self.vacuum_state
+ detected_pad = state.get("detectedPad")
+ mop_ready = state.get("mopReady", {})
+ lid_closed = mop_ready.get("lidClosed")
+ tank_present = mop_ready.get("tankPresent")
+ tank_level = state.get("tankLvl")
+ state_attrs[ATTR_DETECTED_PAD] = detected_pad
+ state_attrs[ATTR_LID_CLOSED] = lid_closed
+ state_attrs[ATTR_TANK_PRESENT] = tank_present
+ state_attrs[ATTR_TANK_LEVEL] = tank_level
+
+ return state_attrs
diff --git a/homeassistant/components/roon/strings.json b/homeassistant/components/roon/strings.json
index 85cb53b9010..463f0431891 100644
--- a/homeassistant/components/roon/strings.json
+++ b/homeassistant/components/roon/strings.json
@@ -10,8 +10,8 @@
}
},
"link": {
- "title": "Authorize HomeAssistant in Roon",
- "description": "You must authorize Home Assistant in Roon. After you select **Submit**, go to the Roon Core application, open **Settings** and enable HomeAssistant on the **Extensions** tab."
+ "title": "Authorize Home Assistant in Roon",
+ "description": "You must authorize Home Assistant in Roon. After you select **Submit**, go to the Roon Core application, open **Settings** and enable Home Assistant on the **Extensions** tab."
}
},
"error": {
diff --git a/homeassistant/components/route53/manifest.json b/homeassistant/components/route53/manifest.json
index 6db240bdcab..978c916e3ee 100644
--- a/homeassistant/components/route53/manifest.json
+++ b/homeassistant/components/route53/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/route53",
"iot_class": "cloud_push",
"loggers": ["boto3", "botocore", "s3transfer"],
+ "quality_scale": "legacy",
"requirements": ["boto3==1.34.131"]
}
diff --git a/homeassistant/components/rpi_camera/manifest.json b/homeassistant/components/rpi_camera/manifest.json
index 9f7346ea353..aab16b1c462 100644
--- a/homeassistant/components/rpi_camera/manifest.json
+++ b/homeassistant/components/rpi_camera/manifest.json
@@ -3,5 +3,6 @@
"name": "Raspberry Pi Camera",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/rpi_camera",
- "iot_class": "local_polling"
+ "iot_class": "local_polling",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/rtorrent/manifest.json b/homeassistant/components/rtorrent/manifest.json
index 96b079c4363..bcd39a03aa3 100644
--- a/homeassistant/components/rtorrent/manifest.json
+++ b/homeassistant/components/rtorrent/manifest.json
@@ -3,5 +3,6 @@
"name": "rTorrent",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/rtorrent",
- "iot_class": "local_polling"
+ "iot_class": "local_polling",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/rtsp_to_webrtc/__init__.py b/homeassistant/components/rtsp_to_webrtc/__init__.py
index 59b8077e398..0fc257c463f 100644
--- a/homeassistant/components/rtsp_to_webrtc/__init__.py
+++ b/homeassistant/components/rtsp_to_webrtc/__init__.py
@@ -30,6 +30,7 @@ from homeassistant.components import camera
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError
+from homeassistant.helpers import issue_registry as ir
from homeassistant.helpers.aiohttp_client import async_get_clientsession
_LOGGER = logging.getLogger(__name__)
@@ -40,10 +41,24 @@ DATA_UNSUB = "unsub"
TIMEOUT = 10
CONF_STUN_SERVER = "stun_server"
+_DEPRECATED = "deprecated"
+
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up RTSPtoWebRTC from a config entry."""
hass.data.setdefault(DOMAIN, {})
+ ir.async_create_issue(
+ hass,
+ DOMAIN,
+ _DEPRECATED,
+ breaks_in_ha_version="2025.6.0",
+ is_fixable=False,
+ severity=ir.IssueSeverity.WARNING,
+ translation_key=_DEPRECATED,
+ translation_placeholders={
+ "go2rtc": "[go2rtc](https://www.home-assistant.io/integrations/go2rtc/)",
+ },
+ )
client: WebRTCClientInterface
try:
@@ -98,6 +113,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
if DOMAIN in hass.data:
del hass.data[DOMAIN]
+ ir.async_delete_issue(hass, DOMAIN, _DEPRECATED)
return True
diff --git a/homeassistant/components/rtsp_to_webrtc/strings.json b/homeassistant/components/rtsp_to_webrtc/strings.json
index e52ab554473..c8dcbb7f462 100644
--- a/homeassistant/components/rtsp_to_webrtc/strings.json
+++ b/homeassistant/components/rtsp_to_webrtc/strings.json
@@ -24,6 +24,12 @@
"server_unreachable": "[%key:component::rtsp_to_webrtc::config::error::server_unreachable%]"
}
},
+ "issues": {
+ "deprecated": {
+ "title": "The RTSPtoWebRTC integration is deprecated",
+ "description": "The RTSPtoWebRTC integration is deprecated and will be removed. Please use the {go2rtc} integration instead, which is enabled by default and provides a better experience. You only need to remove the RTSPtoWebRTC config entry."
+ }
+ },
"options": {
"step": {
"init": {
diff --git a/homeassistant/components/ruckus_unleashed/manifest.json b/homeassistant/components/ruckus_unleashed/manifest.json
index 2066b65221e..8d56f3a5563 100644
--- a/homeassistant/components/ruckus_unleashed/manifest.json
+++ b/homeassistant/components/ruckus_unleashed/manifest.json
@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["aioruckus"],
- "requirements": ["aioruckus==0.41"]
+ "requirements": ["aioruckus==0.42"]
}
diff --git a/homeassistant/components/russound_rio/__init__.py b/homeassistant/components/russound_rio/__init__.py
index 784629ea0bc..65fbd89e203 100644
--- a/homeassistant/components/russound_rio/__init__.py
+++ b/homeassistant/components/russound_rio/__init__.py
@@ -1,6 +1,5 @@
"""The russound_rio component."""
-import asyncio
import logging
from aiorussound import RussoundClient, RussoundTcpConnectionHandler
@@ -11,7 +10,7 @@ from homeassistant.const import CONF_HOST, CONF_PORT, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
-from .const import CONNECT_TIMEOUT, DOMAIN, RUSSOUND_RIO_EXCEPTIONS
+from .const import DOMAIN, RUSSOUND_RIO_EXCEPTIONS
PLATFORMS = [Platform.MEDIA_PLAYER]
@@ -40,8 +39,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: RussoundConfigEntry) ->
await client.register_state_update_callbacks(_connection_update_callback)
try:
- async with asyncio.timeout(CONNECT_TIMEOUT):
- await client.connect()
+ await client.connect()
+ await client.load_zone_source_metadata()
except RUSSOUND_RIO_EXCEPTIONS as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
@@ -58,7 +57,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: RussoundConfigEntry) ->
return True
-async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_unload_entry(hass: HomeAssistant, entry: RussoundConfigEntry) -> bool:
"""Unload a config entry."""
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
await entry.runtime_data.disconnect()
diff --git a/homeassistant/components/russound_rio/config_flow.py b/homeassistant/components/russound_rio/config_flow.py
index 15d002b3f49..5618a424726 100644
--- a/homeassistant/components/russound_rio/config_flow.py
+++ b/homeassistant/components/russound_rio/config_flow.py
@@ -2,18 +2,22 @@
from __future__ import annotations
-import asyncio
import logging
from typing import Any
from aiorussound import RussoundClient, RussoundTcpConnectionHandler
import voluptuous as vol
-from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
-from homeassistant.const import CONF_HOST, CONF_PORT
+from homeassistant.components import zeroconf
+from homeassistant.config_entries import (
+ SOURCE_RECONFIGURE,
+ ConfigFlow,
+ ConfigFlowResult,
+)
+from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT
from homeassistant.helpers import config_validation as cv
-from .const import CONNECT_TIMEOUT, DOMAIN, RUSSOUND_RIO_EXCEPTIONS
+from .const import DOMAIN, RUSSOUND_RIO_EXCEPTIONS
DATA_SCHEMA = vol.Schema(
{
@@ -30,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: zeroconf.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:
@@ -41,15 +92,22 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
client = RussoundClient(RussoundTcpConnectionHandler(host, port))
try:
- async with asyncio.timeout(CONNECT_TIMEOUT):
- await client.connect()
- controller = client.controllers[1]
- await client.disconnect()
+ await client.connect()
+ controller = client.controllers[1]
+ await client.disconnect()
except RUSSOUND_RIO_EXCEPTIONS:
_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(
+ self._get_reconfigure_entry(),
+ data_updates=user_input,
+ )
self._abort_if_unique_id_configured()
data = {CONF_HOST: host, CONF_PORT: port}
return self.async_create_entry(
@@ -60,26 +118,13 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
step_id="user", data_schema=DATA_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]})
- 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:
- async with asyncio.timeout(CONNECT_TIMEOUT):
- 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={}
+ async def async_step_reconfigure(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Handle reconfiguration of the integration."""
+ if not user_input:
+ return self.async_show_form(
+ step_id="reconfigure",
+ data_schema=DATA_SCHEMA,
)
- 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)
+ return await self.async_step_user(user_input)
diff --git a/homeassistant/components/russound_rio/const.py b/homeassistant/components/russound_rio/const.py
index 1b38dc8ce5c..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,10 +12,3 @@ RUSSOUND_RIO_EXCEPTIONS = (
TimeoutError,
asyncio.CancelledError,
)
-
-
-CONNECT_TIMEOUT = 5
-
-MP_FEATURES_BY_FLAG = {
- FeatureFlag.COMMANDS_ZONE_MUTE_OFF_ON: MediaPlayerEntityFeature.VOLUME_MUTE
-}
diff --git a/homeassistant/components/russound_rio/diagnostics.py b/homeassistant/components/russound_rio/diagnostics.py
new file mode 100644
index 00000000000..0e96413c41a
--- /dev/null
+++ b/homeassistant/components/russound_rio/diagnostics.py
@@ -0,0 +1,14 @@
+"""Diagnostics platform for Russound RIO."""
+
+from typing import Any
+
+from homeassistant.core import HomeAssistant
+
+from . import RussoundConfigEntry
+
+
+async def async_get_config_entry_diagnostics(
+ hass: HomeAssistant, entry: RussoundConfigEntry
+) -> dict[str, Any]:
+ """Return diagnostics for the provided config entry."""
+ return entry.runtime_data.state
diff --git a/homeassistant/components/russound_rio/entity.py b/homeassistant/components/russound_rio/entity.py
index 0233305bb1f..9790ff43e68 100644
--- a/homeassistant/components/russound_rio/entity.py
+++ b/homeassistant/components/russound_rio/entity.py
@@ -96,6 +96,4 @@ class RussoundBaseEntity(Entity):
async def async_will_remove_from_hass(self) -> None:
"""Remove callbacks."""
- await self._client.unregister_state_update_callbacks(
- self._state_update_callback
- )
+ self._client.unregister_state_update_callbacks(self._state_update_callback)
diff --git a/homeassistant/components/russound_rio/manifest.json b/homeassistant/components/russound_rio/manifest.json
index 96fc0fb53db..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.0.5"]
+ "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 316e4d2be7c..62981262e32 100644
--- a/homeassistant/components/russound_rio/media_player.py
+++ b/homeassistant/components/russound_rio/media_player.py
@@ -3,10 +3,13 @@
from __future__ import annotations
import logging
+from typing import TYPE_CHECKING
from aiorussound import Controller
-from aiorussound.models import Source
+from aiorussound.const import FeatureFlag
+from aiorussound.models import PlayStatus, Source
from aiorussound.rio import ZoneControlSurface
+from aiorussound.util import is_feature_supported
from homeassistant.components.media_player import (
MediaPlayerDeviceClass,
@@ -15,66 +18,15 @@ 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__)
-
-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",
- },
- )
+PARALLEL_UPDATES = 0
async def async_setup_entry(
@@ -101,6 +53,7 @@ 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
@@ -116,9 +69,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:
@@ -132,50 +82,75 @@ class RussoundZoneDevice(RussoundBaseEntity, MediaPlayerEntity):
def state(self) -> MediaPlayerState | None:
"""Return the state of the device."""
status = self._zone.status
- if status == "ON":
- return MediaPlayerState.ON
- if status == "OFF":
+ play_status = self._source.play_status
+ if not status:
return MediaPlayerState.OFF
- return None
+ if play_status == PlayStatus.PLAYING:
+ return MediaPlayerState.PLAYING
+ if play_status == PlayStatus.PAUSED:
+ return MediaPlayerState.PAUSED
+ if play_status == PlayStatus.TRANSITIONING:
+ return MediaPlayerState.BUFFERING
+ if play_status == PlayStatus.STOPPED:
+ return MediaPlayerState.IDLE
+ return MediaPlayerState.ON
@property
- def source(self):
+ def source(self) -> str:
"""Get the currently selected source."""
return self._source.name
@property
- def source_list(self):
+ def source_list(self) -> list[str]:
"""Return a list of available input sources."""
- return [x.name for x in self._sources.values()]
+ if TYPE_CHECKING:
+ assert self._client.rio_version
+ available_sources = (
+ [
+ source
+ for source_id, source in self._sources.items()
+ if source_id in self._zone.enabled_sources
+ ]
+ if is_feature_supported(
+ self._client.rio_version, FeatureFlag.SUPPORT_ZONE_SOURCE_EXCLUSION
+ )
+ else self._sources.values()
+ )
+ return [x.name for x in available_sources]
@property
- def media_title(self):
+ def media_title(self) -> str | None:
"""Title of current playing media."""
return self._source.song_name
@property
- def media_artist(self):
+ def media_artist(self) -> str | None:
"""Artist of current playing media, music track only."""
return self._source.artist_name
@property
- def media_album_name(self):
+ def media_album_name(self) -> str | None:
"""Album name of current playing media, music track only."""
return self._source.album_name
@property
- def media_image_url(self):
+ def media_image_url(self) -> str | None:
"""Image url of current playing media."""
return self._source.cover_art_url
@property
- def volume_level(self):
+ def volume_level(self) -> float:
"""Volume level of the media player (0..1).
Value is returned based on a range (0..50).
Therefore float divide by 50 to get to the required range.
"""
- return float(self._zone.volume or "0") / 50.0
+ 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:
@@ -211,3 +186,16 @@ 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()
diff --git a/homeassistant/components/russound_rio/quality_scale.yaml b/homeassistant/components/russound_rio/quality_scale.yaml
new file mode 100644
index 00000000000..02b1eaa6aae
--- /dev/null
+++ b/homeassistant/components/russound_rio/quality_scale.yaml
@@ -0,0 +1,92 @@
+rules:
+ # Bronze
+ action-setup:
+ status: exempt
+ comment: |
+ This integration does not provide additional actions.
+ appropriate-polling:
+ status: exempt
+ comment: |
+ This integration uses a push API. No polling required.
+ 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
+ config-entry-unloading: done
+ log-when-unavailable: done
+ entity-unavailable: done
+ action-exceptions: done
+ reauthentication-flow:
+ status: exempt
+ comment: |
+ This integration does not require authentication.
+ parallel-updates: done
+ test-coverage: done
+ integration-owner: done
+ docs-installation-parameters: done
+ docs-configuration-parameters:
+ status: exempt
+ comment: |
+ This integration does not have an options flow.
+ # Gold
+ entity-translations:
+ status: exempt
+ comment: |
+ There are no entities to translate.
+ entity-device-class: done
+ devices: done
+ entity-category: done
+ entity-disabled-by-default:
+ status: exempt
+ comment: |
+ This integration doesn't have enough / noisy entities that warrant being disabled by default.
+ discovery: done
+ stale-devices: todo
+ diagnostics: done
+ exception-translations: done
+ icon-translations:
+ status: exempt
+ comment: |
+ There are no entities that require icons.
+ reconfiguration-flow: done
+ dynamic-devices: todo
+ 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
+ docs-data-update: done
+ docs-known-limitations:
+ status: exempt
+ comment: |
+ There are no known limitations beyond the push API delay noted in Troubleshooting.
+ docs-troubleshooting: done
+ docs-examples: todo
+
+ # Platinum
+ async-dependency: done
+ inject-websession:
+ status: exempt
+ comment: |
+ This integration uses telnet exclusively and does not make http calls.
+ strict-typing: done
diff --git a/homeassistant/components/russound_rio/strings.json b/homeassistant/components/russound_rio/strings.json
index b8c29c08301..eba66856302 100644
--- a/homeassistant/components/russound_rio/strings.json
+++ b/homeassistant/components/russound_rio/strings.json
@@ -9,6 +9,24 @@
"host": "[%key:common::config_flow::data::host%]",
"name": "[%key:common::config_flow::data::name%]",
"port": "[%key:common::config_flow::data::port%]"
+ },
+ "data_description": {
+ "host": "The IP address of the Russound controller.",
+ "port": "The port of the Russound controller."
+ }
+ },
+ "discovery_confirm": {
+ "description": "Do you want to set up {name}?"
+ },
+ "reconfigure": {
+ "description": "Reconfigure your Russound controller.",
+ "data": {
+ "host": "[%key:common::config_flow::data::host%]",
+ "port": "[%key:common::config_flow::data::port%]"
+ },
+ "data_description": {
+ "host": "[%key:component::russound_rio::config::step::user::data_description::host%]",
+ "port": "[%key:component::russound_rio::config::step::user::data_description::port%]"
}
}
},
@@ -17,21 +35,9 @@
},
"abort": {
"cannot_connect": "[%key:component::russound_rio::common::error_cannot_connect%]",
- "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 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%]"
+ "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."
}
},
"exceptions": {
diff --git a/homeassistant/components/russound_rnet/manifest.json b/homeassistant/components/russound_rnet/manifest.json
index 90bf5d5a7f3..27fbfbca57f 100644
--- a/homeassistant/components/russound_rnet/manifest.json
+++ b/homeassistant/components/russound_rnet/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/russound_rnet",
"iot_class": "local_polling",
"loggers": ["russound"],
+ "quality_scale": "legacy",
"requirements": ["russound==0.2.0"]
}
diff --git a/homeassistant/components/sabnzbd/__init__.py b/homeassistant/components/sabnzbd/__init__.py
index a827e9a36a4..fee459340f3 100644
--- a/homeassistant/components/sabnzbd/__init__.py
+++ b/homeassistant/components/sabnzbd/__init__.py
@@ -8,40 +8,26 @@ from typing import Any
import voluptuous as vol
-from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry, ConfigEntryState
-from homeassistant.const import (
- CONF_API_KEY,
- CONF_HOST,
- CONF_NAME,
- CONF_PORT,
- CONF_SENSORS,
- CONF_SSL,
- Platform,
-)
+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, device_registry as dr
-from homeassistant.helpers.entity_registry import RegistryEntry, async_migrate_entries
+from homeassistant.helpers import config_validation as cv
+import homeassistant.helpers.issue_registry as ir
from homeassistant.helpers.typing import ConfigType
from .const import (
ATTR_API_KEY,
ATTR_SPEED,
- DEFAULT_HOST,
- DEFAULT_NAME,
- DEFAULT_PORT,
DEFAULT_SPEED_LIMIT,
- DEFAULT_SSL,
DOMAIN,
SERVICE_PAUSE,
SERVICE_RESUME,
SERVICE_SET_SPEED,
)
-from .coordinator import SabnzbdUpdateCoordinator
-from .sab import get_client
-from .sensor import OLD_SENSOR_KEYS
+from .coordinator import SabnzbdConfigEntry, SabnzbdUpdateCoordinator
+from .helpers import get_client
-PLATFORMS = [Platform.SENSOR]
+PLATFORMS = [Platform.BINARY_SENSOR, Platform.BUTTON, Platform.NUMBER, Platform.SENSOR]
_LOGGER = logging.getLogger(__name__)
SERVICES = (
@@ -62,122 +48,26 @@ SERVICE_SPEED_SCHEMA = SERVICE_BASE_SCHEMA.extend(
}
)
-CONFIG_SCHEMA = vol.Schema(
- {
- DOMAIN: vol.Schema(
- vol.All(
- cv.deprecated(CONF_HOST),
- cv.deprecated(CONF_PORT),
- cv.deprecated(CONF_SENSORS),
- cv.deprecated(CONF_SSL),
- {
- vol.Required(CONF_API_KEY): str,
- vol.Optional(CONF_NAME, default=DEFAULT_NAME): str,
- vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string,
- vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
- vol.Optional(CONF_SENSORS): vol.All(
- cv.ensure_list, [vol.In(OLD_SENSOR_KEYS)]
- ),
- vol.Optional(CONF_SSL, default=DEFAULT_SSL): cv.boolean,
- },
- )
- )
- },
- extra=vol.ALLOW_EXTRA,
-)
-
-
-async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
- """Set up the SABnzbd component."""
- hass.data.setdefault(DOMAIN, {})
-
- if hass.config_entries.async_entries(DOMAIN):
- return True
-
- if DOMAIN in config:
- hass.async_create_task(
- hass.config_entries.flow.async_init(
- DOMAIN,
- context={"source": SOURCE_IMPORT},
- data=config[DOMAIN],
- )
- )
-
- return True
+CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
@callback
-def async_get_entry_id_for_service_call(hass: HomeAssistant, call: ServiceCall) -> str:
+def async_get_entry_for_service_call(
+ hass: HomeAssistant, call: ServiceCall
+) -> SabnzbdConfigEntry:
"""Get the entry ID related to a service call (by device ID)."""
call_data_api_key = call.data[ATTR_API_KEY]
for entry in hass.config_entries.async_entries(DOMAIN):
if entry.data[ATTR_API_KEY] == call_data_api_key:
- return entry.entry_id
+ return entry
raise ValueError(f"No api for API key: {call_data_api_key}")
-def update_device_identifiers(hass: HomeAssistant, entry: ConfigEntry):
- """Update device identifiers to new identifiers."""
- device_registry = dr.async_get(hass)
- device_entry = device_registry.async_get_device(identifiers={(DOMAIN, DOMAIN)})
- if device_entry and entry.entry_id in device_entry.config_entries:
- new_identifiers = {(DOMAIN, entry.entry_id)}
- _LOGGER.debug(
- "Updating device id <%s> with new identifiers <%s>",
- device_entry.id,
- new_identifiers,
- )
- device_registry.async_update_device(
- device_entry.id, new_identifiers=new_identifiers
- )
-
-
-async def migrate_unique_id(hass: HomeAssistant, entry: ConfigEntry):
- """Migrate entities to new unique ids (with entry_id)."""
-
- @callback
- def async_migrate_callback(entity_entry: RegistryEntry) -> dict | None:
- """Define a callback to migrate appropriate SabnzbdSensor entities to new unique IDs.
-
- Old: description.key
- New: {entry_id}_description.key
- """
- entry_id = entity_entry.config_entry_id
- if entry_id is None:
- return None
- if entity_entry.unique_id.startswith(entry_id):
- return None
-
- new_unique_id = f"{entry_id}_{entity_entry.unique_id}"
-
- _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)
-
-
-async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the SabNzbd Component."""
- sab_api = await get_client(hass, entry.data)
- if not sab_api:
- raise ConfigEntryNotReady
-
- await migrate_unique_id(hass, entry)
- update_device_identifiers(hass, entry)
-
- coordinator = SabnzbdUpdateCoordinator(hass, sab_api)
- await coordinator.async_config_entry_first_refresh()
- hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
-
@callback
def extract_api(
func: Callable[
@@ -188,8 +78,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def wrapper(call: ServiceCall) -> None:
"""Wrap the service function."""
- entry_id = async_get_entry_id_for_service_call(hass, call)
- coordinator: SabnzbdUpdateCoordinator = hass.data[DOMAIN][entry_id]
+ config_entry = async_get_entry_for_service_call(hass, call)
+ coordinator = config_entry.runtime_data
try:
await func(call, coordinator)
@@ -204,18 +94,45 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_pause_queue(
call: ServiceCall, coordinator: SabnzbdUpdateCoordinator
) -> None:
+ ir.async_create_issue(
+ hass,
+ DOMAIN,
+ "pause_action_deprecated",
+ is_fixable=False,
+ severity=ir.IssueSeverity.WARNING,
+ breaks_in_ha_version="2025.6",
+ translation_key="pause_action_deprecated",
+ )
await coordinator.sab_api.pause_queue()
@extract_api
async def async_resume_queue(
call: ServiceCall, coordinator: SabnzbdUpdateCoordinator
) -> None:
+ ir.async_create_issue(
+ hass,
+ DOMAIN,
+ "resume_action_deprecated",
+ is_fixable=False,
+ severity=ir.IssueSeverity.WARNING,
+ breaks_in_ha_version="2025.6",
+ translation_key="resume_action_deprecated",
+ )
await coordinator.sab_api.resume_queue()
@extract_api
async def async_set_queue_speed(
call: ServiceCall, coordinator: SabnzbdUpdateCoordinator
) -> None:
+ ir.async_create_issue(
+ hass,
+ DOMAIN,
+ "set_speed_action_deprecated",
+ is_fixable=False,
+ severity=ir.IssueSeverity.WARNING,
+ breaks_in_ha_version="2025.6",
+ translation_key="set_speed_action_deprecated",
+ )
speed = call.data.get(ATTR_SPEED)
await coordinator.sab_api.set_speed_limit(speed)
@@ -224,31 +141,27 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
(SERVICE_RESUME, async_resume_queue, SERVICE_BASE_SCHEMA),
(SERVICE_SET_SPEED, async_set_queue_speed, SERVICE_SPEED_SCHEMA),
):
- if hass.services.has_service(DOMAIN, service):
- continue
-
hass.services.async_register(DOMAIN, service, method, schema=schema)
+ return True
+
+
+async def async_setup_entry(hass: HomeAssistant, entry: SabnzbdConfigEntry) -> bool:
+ """Set up the SabNzbd Component."""
+
+ sab_api = await get_client(hass, entry.data)
+ if not sab_api:
+ raise ConfigEntryNotReady
+
+ coordinator = SabnzbdUpdateCoordinator(hass, entry, sab_api)
+ 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: SabnzbdConfigEntry) -> bool:
"""Unload a Sabnzbd config entry."""
- unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
- if unload_ok:
- hass.data[DOMAIN].pop(entry.entry_id)
-
- loaded_entries = [
- entry
- for entry in hass.config_entries.async_entries(DOMAIN)
- if entry.state == ConfigEntryState.LOADED
- ]
- if len(loaded_entries) == 1:
- # If this is the last loaded instance of Sabnzbd, deregister any services
- # defined during integration setup:
- for service_name in SERVICES:
- hass.services.async_remove(DOMAIN, service_name)
-
- return unload_ok
+ return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
diff --git a/homeassistant/components/sabnzbd/binary_sensor.py b/homeassistant/components/sabnzbd/binary_sensor.py
new file mode 100644
index 00000000000..1d65bf01211
--- /dev/null
+++ b/homeassistant/components/sabnzbd/binary_sensor.py
@@ -0,0 +1,61 @@
+"""Binary sensor platform for SABnzbd."""
+
+from __future__ import annotations
+
+from collections.abc import Callable
+from dataclasses import dataclass
+from typing import Any
+
+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 .coordinator import SabnzbdConfigEntry
+from .entity import SabnzbdEntity
+
+
+@dataclass(frozen=True, kw_only=True)
+class SabnzbdBinarySensorEntityDescription(BinarySensorEntityDescription):
+ """Describes Sabnzbd binary sensor entity."""
+
+ is_on_fn: Callable[[dict[str, Any]], bool]
+
+
+BINARY_SENSORS: tuple[SabnzbdBinarySensorEntityDescription, ...] = (
+ SabnzbdBinarySensorEntityDescription(
+ key="warnings",
+ translation_key="warnings",
+ device_class=BinarySensorDeviceClass.PROBLEM,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ is_on_fn=lambda data: data["have_warnings"] != "0",
+ ),
+)
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ config_entry: SabnzbdConfigEntry,
+ async_add_entities: AddEntitiesCallback,
+) -> None:
+ """Set up a Sabnzbd sensor entry."""
+ coordinator = config_entry.runtime_data
+
+ async_add_entities(
+ [SabnzbdBinarySensor(coordinator, sensor) for sensor in BINARY_SENSORS]
+ )
+
+
+class SabnzbdBinarySensor(SabnzbdEntity, BinarySensorEntity):
+ """Representation of an SABnzbd binary sensor."""
+
+ entity_description: SabnzbdBinarySensorEntityDescription
+
+ @property
+ def is_on(self) -> bool:
+ """Return latest sensor data."""
+ return self.entity_description.is_on_fn(self.coordinator.data)
diff --git a/homeassistant/components/sabnzbd/button.py b/homeassistant/components/sabnzbd/button.py
new file mode 100644
index 00000000000..1ff26b41655
--- /dev/null
+++ b/homeassistant/components/sabnzbd/button.py
@@ -0,0 +1,68 @@
+"""Button platform for the SABnzbd component."""
+
+from collections.abc import Callable
+from dataclasses import dataclass
+from typing import Any
+
+from pysabnzbd import SabnzbdApiException
+
+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 SabnzbdConfigEntry, SabnzbdUpdateCoordinator
+from .entity import SabnzbdEntity
+
+
+@dataclass(kw_only=True, frozen=True)
+class SabnzbdButtonEntityDescription(ButtonEntityDescription):
+ """Describes SABnzbd button entity."""
+
+ press_fn: Callable[[SabnzbdUpdateCoordinator], Any]
+
+
+BUTTON_DESCRIPTIONS: tuple[SabnzbdButtonEntityDescription, ...] = (
+ SabnzbdButtonEntityDescription(
+ key="pause",
+ translation_key="pause",
+ press_fn=lambda coordinator: coordinator.sab_api.pause_queue(),
+ ),
+ SabnzbdButtonEntityDescription(
+ key="resume",
+ translation_key="resume",
+ press_fn=lambda coordinator: coordinator.sab_api.resume_queue(),
+ ),
+)
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: SabnzbdConfigEntry,
+ async_add_entities: AddEntitiesCallback,
+) -> None:
+ """Set up buttons from a config entry."""
+ coordinator = entry.runtime_data
+
+ async_add_entities(
+ SabnzbdButton(coordinator, description) for description in BUTTON_DESCRIPTIONS
+ )
+
+
+class SabnzbdButton(SabnzbdEntity, ButtonEntity):
+ """Representation of a SABnzbd button."""
+
+ entity_description: SabnzbdButtonEntityDescription
+
+ async def async_press(self) -> None:
+ """Handle the button press."""
+ try:
+ await self.entity_description.press_fn(self.coordinator)
+ except SabnzbdApiException as e:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="service_call_exception",
+ ) from e
+ else:
+ await self.coordinator.async_request_refresh()
diff --git a/homeassistant/components/sabnzbd/config_flow.py b/homeassistant/components/sabnzbd/config_flow.py
index 2637659e91a..ce9b0a13b18 100644
--- a/homeassistant/components/sabnzbd/config_flow.py
+++ b/homeassistant/components/sabnzbd/config_flow.py
@@ -6,27 +6,38 @@ import logging
from typing import Any
import voluptuous as vol
+import yarl
-from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
-from homeassistant.const import (
- CONF_API_KEY,
- CONF_HOST,
- CONF_NAME,
- CONF_PORT,
- CONF_SSL,
- CONF_URL,
+from homeassistant.config_entries import (
+ SOURCE_RECONFIGURE,
+ ConfigFlow,
+ ConfigFlowResult,
)
+from homeassistant.const import CONF_API_KEY, CONF_URL
+from homeassistant.helpers.selector import (
+ TextSelector,
+ TextSelectorConfig,
+ TextSelectorType,
+)
+from homeassistant.util import slugify
-from .const import DEFAULT_NAME, DOMAIN
-from .sab import get_client
+from .const import DOMAIN
+from .helpers import get_client
_LOGGER = logging.getLogger(__name__)
USER_SCHEMA = vol.Schema(
{
- vol.Required(CONF_API_KEY): str,
- vol.Optional(CONF_NAME, default=DEFAULT_NAME): str,
- vol.Required(CONF_URL): str,
+ vol.Required(CONF_URL): TextSelector(
+ TextSelectorConfig(
+ type=TextSelectorType.URL,
+ )
+ ),
+ vol.Required(CONF_API_KEY): TextSelector(
+ TextSelectorConfig(
+ type=TextSelectorType.PASSWORD,
+ )
+ ),
}
)
@@ -36,39 +47,47 @@ class SABnzbdConfigFlow(ConfigFlow, domain=DOMAIN):
VERSION = 1
- async def _async_validate_input(self, user_input):
- """Validate the user input allows us to connect."""
- errors = {}
- sab_api = await get_client(self.hass, user_input)
- if not sab_api:
- errors["base"] = "cannot_connect"
-
- return errors
+ async def async_step_reconfigure(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Handle reconfiguration flow."""
+ return await self.async_step_user(user_input)
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle a flow initialized by the user."""
-
errors = {}
- if user_input is not None:
- errors = await self._async_validate_input(user_input)
- if not errors:
+ if user_input is not None:
+ sab_api = await get_client(self.hass, user_input)
+ if not sab_api:
+ errors["base"] = "cannot_connect"
+ else:
+ self._async_abort_entries_match(
+ {
+ CONF_URL: user_input[CONF_URL],
+ CONF_API_KEY: user_input[CONF_API_KEY],
+ }
+ )
+
+ if self.source == SOURCE_RECONFIGURE:
+ return self.async_update_reload_and_abort(
+ self._get_reconfigure_entry(), data_updates=user_input
+ )
+
+ parsed_url = yarl.URL(user_input[CONF_URL])
return self.async_create_entry(
- title=user_input[CONF_API_KEY][:12], data=user_input
+ title=slugify(parsed_url.host), data=user_input
)
return self.async_show_form(
step_id="user",
- data_schema=USER_SCHEMA,
+ data_schema=self.add_suggested_values_to_schema(
+ USER_SCHEMA,
+ self._get_reconfigure_entry().data
+ if self.source == SOURCE_RECONFIGURE
+ else user_input,
+ ),
errors=errors,
)
-
- async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult:
- """Import sabnzbd config from configuration.yaml."""
- protocol = "https://" if import_data[CONF_SSL] else "http://"
- import_data[CONF_URL] = (
- f"{protocol}{import_data[CONF_HOST]}:{import_data[CONF_PORT]}"
- )
- return await self.async_step_user(import_data)
diff --git a/homeassistant/components/sabnzbd/const.py b/homeassistant/components/sabnzbd/const.py
index 55346509133..f05b3f19e98 100644
--- a/homeassistant/components/sabnzbd/const.py
+++ b/homeassistant/components/sabnzbd/const.py
@@ -1,16 +1,11 @@
"""Constants for the Sabnzbd component."""
DOMAIN = "sabnzbd"
-DATA_SABNZBD = "sabnzbd"
ATTR_SPEED = "speed"
ATTR_API_KEY = "api_key"
-DEFAULT_HOST = "localhost"
-DEFAULT_NAME = "SABnzbd"
-DEFAULT_PORT = 8080
DEFAULT_SPEED_LIMIT = "100"
-DEFAULT_SSL = False
SERVICE_PAUSE = "pause"
SERVICE_RESUME = "resume"
diff --git a/homeassistant/components/sabnzbd/coordinator.py b/homeassistant/components/sabnzbd/coordinator.py
index 5db59bb584b..dac8d8a8e95 100644
--- a/homeassistant/components/sabnzbd/coordinator.py
+++ b/homeassistant/components/sabnzbd/coordinator.py
@@ -6,18 +6,24 @@ from typing import Any
from pysabnzbd import SabnzbdApi, SabnzbdApiException
+from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
_LOGGER = logging.getLogger(__name__)
+type SabnzbdConfigEntry = ConfigEntry[SabnzbdUpdateCoordinator]
+
class SabnzbdUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"""The SABnzbd update coordinator."""
+ config_entry: SabnzbdConfigEntry
+
def __init__(
self,
hass: HomeAssistant,
+ config_entry: SabnzbdConfigEntry,
sab_api: SabnzbdApi,
) -> None:
"""Initialize the SABnzbd update coordinator."""
@@ -26,6 +32,7 @@ class SabnzbdUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
super().__init__(
hass,
_LOGGER,
+ config_entry=config_entry,
name="SABnzbd",
update_interval=timedelta(seconds=30),
)
diff --git a/homeassistant/components/sabnzbd/entity.py b/homeassistant/components/sabnzbd/entity.py
new file mode 100644
index 00000000000..60a2eb8d251
--- /dev/null
+++ b/homeassistant/components/sabnzbd/entity.py
@@ -0,0 +1,33 @@
+"""Base entity for Sabnzbd."""
+
+from homeassistant.const import CONF_URL
+from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
+from homeassistant.helpers.entity import EntityDescription
+from homeassistant.helpers.update_coordinator import CoordinatorEntity
+
+from .const import DOMAIN
+from .coordinator import SabnzbdUpdateCoordinator
+
+
+class SabnzbdEntity(CoordinatorEntity[SabnzbdUpdateCoordinator]):
+ """Defines a base Sabnzbd entity."""
+
+ _attr_has_entity_name = True
+
+ def __init__(
+ self,
+ coordinator: SabnzbdUpdateCoordinator,
+ description: EntityDescription,
+ ) -> None:
+ """Initialize the base entity."""
+ super().__init__(coordinator)
+
+ entry_id = coordinator.config_entry.entry_id
+ self._attr_unique_id = f"{entry_id}_{description.key}"
+ self.entity_description = description
+ self._attr_device_info = DeviceInfo(
+ entry_type=DeviceEntryType.SERVICE,
+ identifiers={(DOMAIN, entry_id)},
+ sw_version=coordinator.data["version"],
+ configuration_url=coordinator.config_entry.data[CONF_URL],
+ )
diff --git a/homeassistant/components/sabnzbd/sab.py b/homeassistant/components/sabnzbd/helpers.py
similarity index 100%
rename from homeassistant/components/sabnzbd/sab.py
rename to homeassistant/components/sabnzbd/helpers.py
diff --git a/homeassistant/components/sabnzbd/icons.json b/homeassistant/components/sabnzbd/icons.json
index ca4f4d584ae..b0a72040b4b 100644
--- a/homeassistant/components/sabnzbd/icons.json
+++ b/homeassistant/components/sabnzbd/icons.json
@@ -1,4 +1,19 @@
{
+ "entity": {
+ "button": {
+ "pause": {
+ "default": "mdi:pause"
+ },
+ "resume": {
+ "default": "mdi:play"
+ }
+ },
+ "number": {
+ "speedlimit": {
+ "default": "mdi:speedometer"
+ }
+ }
+ },
"services": {
"pause": {
"service": "mdi:pause"
diff --git a/homeassistant/components/sabnzbd/manifest.json b/homeassistant/components/sabnzbd/manifest.json
index afc35a2340e..f1b8a17134b 100644
--- a/homeassistant/components/sabnzbd/manifest.json
+++ b/homeassistant/components/sabnzbd/manifest.json
@@ -6,5 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/sabnzbd",
"iot_class": "local_polling",
"loggers": ["pysabnzbd"],
+ "quality_scale": "bronze",
"requirements": ["pysabnzbd==1.1.1"]
}
diff --git a/homeassistant/components/sabnzbd/number.py b/homeassistant/components/sabnzbd/number.py
new file mode 100644
index 00000000000..53c8d462f11
--- /dev/null
+++ b/homeassistant/components/sabnzbd/number.py
@@ -0,0 +1,81 @@
+"""Number entities for the SABnzbd integration."""
+
+from __future__ import annotations
+
+from collections.abc import Awaitable, Callable
+from dataclasses import dataclass
+
+from pysabnzbd import SabnzbdApiException
+
+from homeassistant.components.number import (
+ NumberEntity,
+ NumberEntityDescription,
+ NumberMode,
+)
+from homeassistant.const import PERCENTAGE
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+
+from .const import DOMAIN
+from .coordinator import SabnzbdConfigEntry, SabnzbdUpdateCoordinator
+from .entity import SabnzbdEntity
+
+
+@dataclass(frozen=True, kw_only=True)
+class SabnzbdNumberEntityDescription(NumberEntityDescription):
+ """Class describing a SABnzbd number entities."""
+
+ set_fn: Callable[[SabnzbdUpdateCoordinator, float], Awaitable]
+
+
+NUMBER_DESCRIPTIONS: tuple[SabnzbdNumberEntityDescription, ...] = (
+ SabnzbdNumberEntityDescription(
+ key="speedlimit",
+ translation_key="speedlimit",
+ mode=NumberMode.BOX,
+ native_max_value=100,
+ native_min_value=0,
+ native_step=1,
+ native_unit_of_measurement=PERCENTAGE,
+ set_fn=lambda coordinator, speed: (
+ coordinator.sab_api.set_speed_limit(int(speed))
+ ),
+ ),
+)
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ config_entry: SabnzbdConfigEntry,
+ async_add_entities: AddEntitiesCallback,
+) -> None:
+ """Set up the SABnzbd number entity."""
+ coordinator = config_entry.runtime_data
+
+ async_add_entities(
+ SabnzbdNumber(coordinator, description) for description in NUMBER_DESCRIPTIONS
+ )
+
+
+class SabnzbdNumber(SabnzbdEntity, NumberEntity):
+ """Representation of a SABnzbd number."""
+
+ entity_description: SabnzbdNumberEntityDescription
+
+ @property
+ def native_value(self) -> float:
+ """Return latest value for number."""
+ return self.coordinator.data[self.entity_description.key]
+
+ async def async_set_native_value(self, value: float) -> None:
+ """Set the new number value."""
+ try:
+ await self.entity_description.set_fn(self.coordinator, value)
+ except SabnzbdApiException as e:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="service_call_exception",
+ ) from e
+ else:
+ await self.coordinator.async_request_refresh()
diff --git a/homeassistant/components/sabnzbd/quality_scale.yaml b/homeassistant/components/sabnzbd/quality_scale.yaml
new file mode 100644
index 00000000000..a1d6fc076b2
--- /dev/null
+++ b/homeassistant/components/sabnzbd/quality_scale.yaml
@@ -0,0 +1,90 @@
+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:
+ status: exempt
+ comment: |
+ The integration has deprecated the actions, thus the documentation has been removed.
+ 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:
+ status: todo
+ comment: |
+ Raise ServiceValidationError in async_get_entry_for_service_call.
+ config-entry-unloading: done
+ docs-configuration-parameters:
+ status: exempt
+ comment: |
+ The integration does not provide any additional options.
+ docs-installation-parameters: done
+ entity-unavailable: done
+ integration-owner: done
+ log-when-unavailable: done
+ parallel-updates: todo
+ reauthentication-flow: todo
+ test-coverage:
+ status: todo
+ comment: |
+ Coverage for loading and unloading config entries is missing.
+
+ # Gold
+ devices: done
+ diagnostics: todo
+ discovery-update-info:
+ status: exempt
+ comment: |
+ This integration cannot be discovered.
+ discovery:
+ status: exempt
+ comment: |
+ This integration cannot be discovered.
+ docs-data-update: todo
+ docs-examples: todo
+ docs-known-limitations: todo
+ docs-supported-devices: todo
+ docs-supported-functions:
+ status: todo
+ comment: |
+ Describe the state of the sensor and make it a enum sensor.
+ docs-troubleshooting: todo
+ docs-use-cases: todo
+ dynamic-devices:
+ status: exempt
+ comment: |
+ The integration connects to a single service per configuration entry.
+ entity-category: done
+ entity-device-class: done
+ entity-disabled-by-default: done
+ entity-translations: done
+ exception-translations: todo
+ icon-translations: done
+ reconfiguration-flow: done
+ repair-issues: done
+ stale-devices:
+ status: exempt
+ comment: |
+ This integration connect to a single service per configuration entry.
+
+ # Platinum
+ async-dependency: done
+ inject-websession: done
+ strict-typing: todo
diff --git a/homeassistant/components/sabnzbd/sensor.py b/homeassistant/components/sabnzbd/sensor.py
index d956d06f1ac..662ae739d15 100644
--- a/homeassistant/components/sabnzbd/sensor.py
+++ b/homeassistant/components/sabnzbd/sensor.py
@@ -10,16 +10,13 @@ from homeassistant.components.sensor import (
SensorEntityDescription,
SensorStateClass,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import UnitOfDataRate, UnitOfInformation
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
-from homeassistant.helpers.update_coordinator import CoordinatorEntity
-from . import DOMAIN, SabnzbdUpdateCoordinator
-from .const import DEFAULT_NAME
+from .coordinator import SabnzbdConfigEntry
+from .entity import SabnzbdEntity
@dataclass(frozen=True, kw_only=True)
@@ -114,59 +111,22 @@ SENSOR_TYPES: tuple[SabnzbdSensorEntityDescription, ...] = (
),
)
-OLD_SENSOR_KEYS = [
- "current_status",
- "speed",
- "queue_size",
- "queue_remaining",
- "disk_size",
- "disk_free",
- "queue_count",
- "day_size",
- "week_size",
- "month_size",
- "total_size",
-]
-
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ config_entry: SabnzbdConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up a Sabnzbd sensor entry."""
+ coordinator = config_entry.runtime_data
- entry_id = config_entry.entry_id
- coordinator: SabnzbdUpdateCoordinator = hass.data[DOMAIN][entry_id]
-
- async_add_entities(
- [SabnzbdSensor(coordinator, sensor, entry_id) for sensor in SENSOR_TYPES]
- )
+ async_add_entities([SabnzbdSensor(coordinator, sensor) for sensor in SENSOR_TYPES])
-class SabnzbdSensor(CoordinatorEntity[SabnzbdUpdateCoordinator], SensorEntity):
+class SabnzbdSensor(SabnzbdEntity, SensorEntity):
"""Representation of an SABnzbd sensor."""
entity_description: SabnzbdSensorEntityDescription
- _attr_should_poll = False
- _attr_has_entity_name = True
-
- def __init__(
- self,
- coordinator: SabnzbdUpdateCoordinator,
- description: SabnzbdSensorEntityDescription,
- entry_id,
- ) -> None:
- """Initialize the sensor."""
- super().__init__(coordinator)
-
- self._attr_unique_id = f"{entry_id}_{description.key}"
- self.entity_description = description
- self._attr_device_info = DeviceInfo(
- entry_type=DeviceEntryType.SERVICE,
- identifiers={(DOMAIN, entry_id)},
- name=DEFAULT_NAME,
- )
@property
def native_value(self) -> StateType:
diff --git a/homeassistant/components/sabnzbd/strings.json b/homeassistant/components/sabnzbd/strings.json
index 5b7312e3b0d..0ac8b93c57f 100644
--- a/homeassistant/components/sabnzbd/strings.json
+++ b/homeassistant/components/sabnzbd/strings.json
@@ -4,20 +4,42 @@
"user": {
"data": {
"api_key": "[%key:common::config_flow::data::api_key%]",
- "name": "[%key:common::config_flow::data::name%]",
"url": "[%key:common::config_flow::data::url%]"
},
"data_description": {
- "url": "The full URL, including port, of the SABnzbd server. Example: `http://localhost:8080` or `http://a02368d7-sabnzbd:8080`"
+ "url": "The full URL, including port, of the SABnzbd server. Example: `http://localhost:8080` or `http://a02368d7-sabnzbd:8080`, if you are using the add-on.",
+ "api_key": "The API key of the SABnzbd server. This can be found in the SABnzbd web interface under Config cog (top right) > General > Security."
}
}
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_api_key": "[%key:common::config_flow::error::invalid_api_key%]"
+ },
+ "abort": {
+ "already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
+ "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
}
},
"entity": {
+ "binary_sensor": {
+ "warnings": {
+ "name": "Warnings"
+ }
+ },
+ "button": {
+ "pause": {
+ "name": "[%key:common::action::pause%]"
+ },
+ "resume": {
+ "name": "[%key:component::sabnzbd::services::resume::name%]"
+ }
+ },
+ "number": {
+ "speedlimit": {
+ "name": "Speedlimit"
+ }
+ },
"sensor": {
"status": {
"name": "Status"
@@ -89,5 +111,24 @@
}
}
}
+ },
+ "issues": {
+ "pause_action_deprecated": {
+ "title": "SABnzbd pause action deprecated",
+ "description": "The 'Pause' action is deprecated and will be removed in a future version. Please use the 'Pause' button instead. To remove this issue, please adjust automations accordingly and restart Home Assistant."
+ },
+ "resume_action_deprecated": {
+ "title": "SABnzbd resume action deprecated",
+ "description": "The 'Resume' action is deprecated and will be removed in a future version. Please use the 'Resume' button instead. To remove this issue, please adjust automations accordingly and restart Home Assistant."
+ },
+ "set_speed_action_deprecated": {
+ "title": "SABnzbd set_speed action deprecated",
+ "description": "The 'Set speed' action is deprecated and will be removed in a future version. Please use the 'Speedlimit' number entity instead. To remove this issue, please adjust automations accordingly and restart Home Assistant."
+ }
+ },
+ "exceptions": {
+ "service_call_exception": {
+ "message": "Unable to send command to SABnzbd due to a connection error, try again later"
+ }
}
}
diff --git a/homeassistant/components/saj/manifest.json b/homeassistant/components/saj/manifest.json
index e882c9f0d02..2a4243f7489 100644
--- a/homeassistant/components/saj/manifest.json
+++ b/homeassistant/components/saj/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/saj",
"iot_class": "local_polling",
"loggers": ["pysaj"],
+ "quality_scale": "legacy",
"requirements": ["pysaj==0.0.16"]
}
diff --git a/homeassistant/components/samsungtv/manifest.json b/homeassistant/components/samsungtv/manifest.json
index bc4ba900028..a1fda25589e 100644
--- a/homeassistant/components/samsungtv/manifest.json
+++ b/homeassistant/components/samsungtv/manifest.json
@@ -37,9 +37,9 @@
"requirements": [
"getmac==0.9.4",
"samsungctl[websocket]==0.7.1",
- "samsungtvws[async,encrypted]==2.6.0",
+ "samsungtvws[async,encrypted]==2.7.2",
"wakeonlan==2.1.0",
- "async-upnp-client==0.41.0"
+ "async-upnp-client==0.42.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/manifest.json b/homeassistant/components/satel_integra/manifest.json
index 828261aa466..a90ea1db5a5 100644
--- a/homeassistant/components/satel_integra/manifest.json
+++ b/homeassistant/components/satel_integra/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/satel_integra",
"iot_class": "local_push",
"loggers": ["satel_integra"],
+ "quality_scale": "legacy",
"requirements": ["satel-integra==0.3.7"]
}
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/schlage/__init__.py b/homeassistant/components/schlage/__init__.py
index e9fb24f1309..6eae69d9542 100644
--- a/homeassistant/components/schlage/__init__.py
+++ b/homeassistant/components/schlage/__init__.py
@@ -10,7 +10,6 @@ from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
-from .const import DOMAIN
from .coordinator import SchlageDataUpdateCoordinator
PLATFORMS: list[Platform] = [
@@ -21,8 +20,10 @@ PLATFORMS: list[Platform] = [
Platform.SWITCH,
]
+type SchlageConfigEntry = ConfigEntry[SchlageDataUpdateCoordinator]
-async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+
+async def async_setup_entry(hass: HomeAssistant, entry: SchlageConfigEntry) -> bool:
"""Set up Schlage from a config entry."""
username = entry.data[CONF_USERNAME]
password = entry.data[CONF_PASSWORD]
@@ -32,15 +33,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
raise ConfigEntryAuthFailed from ex
coordinator = SchlageDataUpdateCoordinator(hass, username, pyschlage.Schlage(auth))
- hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
+ entry.runtime_data = coordinator
await coordinator.async_config_entry_first_refresh()
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: SchlageConfigEntry) -> 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/schlage/binary_sensor.py b/homeassistant/components/schlage/binary_sensor.py
index bc1ee666f9e..f928d42b3ee 100644
--- a/homeassistant/components/schlage/binary_sensor.py
+++ b/homeassistant/components/schlage/binary_sensor.py
@@ -10,12 +10,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 . import SchlageConfigEntry
from .coordinator import LockData, SchlageDataUpdateCoordinator
from .entity import SchlageEntity
@@ -40,11 +39,11 @@ _DESCRIPTIONS: tuple[SchlageBinarySensorEntityDescription] = (
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ config_entry: SchlageConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up binary_sensors based on a config entry."""
- coordinator: SchlageDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id]
+ coordinator = config_entry.runtime_data
def _add_new_locks(locks: dict[str, LockData]) -> None:
async_add_entities(
diff --git a/homeassistant/components/schlage/config_flow.py b/homeassistant/components/schlage/config_flow.py
index f359f7dda71..6e8f94473dd 100644
--- a/homeassistant/components/schlage/config_flow.py
+++ b/homeassistant/components/schlage/config_flow.py
@@ -40,6 +40,7 @@ class SchlageConfigFlow(ConfigFlow, domain=DOMAIN):
return self._show_user_form(errors)
await self.async_set_unique_id(user_id)
+ self._abort_if_unique_id_configured()
return self.async_create_entry(
title=username,
data={
diff --git a/homeassistant/components/schlage/coordinator.py b/homeassistant/components/schlage/coordinator.py
index 53bb43751a9..b319b21be0c 100644
--- a/homeassistant/components/schlage/coordinator.py
+++ b/homeassistant/components/schlage/coordinator.py
@@ -44,6 +44,7 @@ class SchlageDataUpdateCoordinator(DataUpdateCoordinator[SchlageData]):
super().__init__(
hass, LOGGER, name=f"{DOMAIN} ({username})", update_interval=UPDATE_INTERVAL
)
+ self.data = SchlageData(locks={})
self.api = api
self.new_locks_callbacks: list[Callable[[dict[str, LockData]], None]] = []
self.async_add_listener(self._add_remove_locks)
@@ -55,7 +56,9 @@ class SchlageDataUpdateCoordinator(DataUpdateCoordinator[SchlageData]):
except NotAuthorizedError as ex:
raise ConfigEntryAuthFailed from ex
except SchlageError as ex:
- raise UpdateFailed("Failed to refresh Schlage data") from ex
+ raise UpdateFailed(
+ translation_domain=DOMAIN, translation_key="schlage_refresh_failed"
+ ) from ex
lock_data = await asyncio.gather(
*(
self.hass.async_add_executor_job(self._get_lock_data, lock)
@@ -83,9 +86,6 @@ class SchlageDataUpdateCoordinator(DataUpdateCoordinator[SchlageData]):
@callback
def _add_remove_locks(self) -> None:
"""Add newly discovered locks and remove nonexistent locks."""
- if self.data is None:
- return
-
device_registry = dr.async_get(self.hass)
devices = dr.async_entries_for_config_entry(
device_registry, self.config_entry.entry_id
diff --git a/homeassistant/components/schlage/diagnostics.py b/homeassistant/components/schlage/diagnostics.py
index af1bf311676..ec4d9c489e3 100644
--- a/homeassistant/components/schlage/diagnostics.py
+++ b/homeassistant/components/schlage/diagnostics.py
@@ -4,19 +4,17 @@ 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 SchlageDataUpdateCoordinator
+from . import SchlageConfigEntry
async def async_get_config_entry_diagnostics(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ config_entry: SchlageConfigEntry,
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
- coordinator: SchlageDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id]
+ coordinator = config_entry.runtime_data
# NOTE: Schlage diagnostics are already redacted.
return {
"locks": [ld.lock.get_diagnostics() for ld in coordinator.data.locks.values()]
diff --git a/homeassistant/components/schlage/lock.py b/homeassistant/components/schlage/lock.py
index 97dbfc78d41..d203913191d 100644
--- a/homeassistant/components/schlage/lock.py
+++ b/homeassistant/components/schlage/lock.py
@@ -5,22 +5,21 @@ from __future__ import annotations
from typing import Any
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 .const import DOMAIN
+from . import SchlageConfigEntry
from .coordinator import LockData, SchlageDataUpdateCoordinator
from .entity import SchlageEntity
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ config_entry: SchlageConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Schlage WiFi locks based on a config entry."""
- coordinator: SchlageDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id]
+ coordinator = config_entry.runtime_data
def _add_new_locks(locks: dict[str, LockData]) -> None:
async_add_entities(
diff --git a/homeassistant/components/schlage/manifest.json b/homeassistant/components/schlage/manifest.json
index 5619cf7b312..61cc2a3c63d 100644
--- a/homeassistant/components/schlage/manifest.json
+++ b/homeassistant/components/schlage/manifest.json
@@ -5,5 +5,5 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/schlage",
"iot_class": "cloud_polling",
- "requirements": ["pyschlage==2024.8.0"]
+ "requirements": ["pyschlage==2024.11.0"]
}
diff --git a/homeassistant/components/schlage/select.py b/homeassistant/components/schlage/select.py
index 6d93eccaa85..6cf0853835f 100644
--- a/homeassistant/components/schlage/select.py
+++ b/homeassistant/components/schlage/select.py
@@ -3,12 +3,11 @@
from __future__ import annotations
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 .const import DOMAIN
+from . import SchlageConfigEntry
from .coordinator import LockData, SchlageDataUpdateCoordinator
from .entity import SchlageEntity
@@ -33,11 +32,11 @@ _DESCRIPTIONS = (
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ config_entry: SchlageConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up selects based on a config entry."""
- coordinator: SchlageDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id]
+ coordinator = config_entry.runtime_data
def _add_new_locks(locks: dict[str, LockData]) -> None:
async_add_entities(
diff --git a/homeassistant/components/schlage/sensor.py b/homeassistant/components/schlage/sensor.py
index 115412882a2..a15d1740b91 100644
--- a/homeassistant/components/schlage/sensor.py
+++ b/homeassistant/components/schlage/sensor.py
@@ -13,7 +13,6 @@ from homeassistant.const import PERCENTAGE, EntityCategory
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from .const import DOMAIN
from .coordinator import LockData, SchlageDataUpdateCoordinator
from .entity import SchlageEntity
@@ -34,7 +33,7 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up sensors based on a config entry."""
- coordinator: SchlageDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id]
+ coordinator = config_entry.runtime_data
def _add_new_locks(locks: dict[str, LockData]) -> None:
async_add_entities(
diff --git a/homeassistant/components/schlage/strings.json b/homeassistant/components/schlage/strings.json
index 5c8cd0826a9..56e72c2d2c0 100644
--- a/homeassistant/components/schlage/strings.json
+++ b/homeassistant/components/schlage/strings.json
@@ -53,5 +53,10 @@
"name": "1-Touch Locking"
}
}
+ },
+ "exceptions": {
+ "schlage_refresh_failed": {
+ "message": "Failed to refresh Schlage data"
+ }
}
}
diff --git a/homeassistant/components/schlage/switch.py b/homeassistant/components/schlage/switch.py
index aaed57fc741..39fe6dbbc99 100644
--- a/homeassistant/components/schlage/switch.py
+++ b/homeassistant/components/schlage/switch.py
@@ -19,7 +19,6 @@ from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from .const import DOMAIN
from .coordinator import LockData, SchlageDataUpdateCoordinator
from .entity import SchlageEntity
@@ -61,7 +60,7 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up switches based on a config entry."""
- coordinator: SchlageDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id]
+ coordinator = config_entry.runtime_data
def _add_new_locks(locks: dict[str, LockData]) -> None:
async_add_entities(
diff --git a/homeassistant/components/schluter/climate.py b/homeassistant/components/schluter/climate.py
index 6f0a49e6eb9..7db15d3923c 100644
--- a/homeassistant/components/schluter/climate.py
+++ b/homeassistant/components/schluter/climate.py
@@ -82,7 +82,6 @@ class SchluterThermostat(CoordinatorEntity, ClimateEntity):
_attr_hvac_modes = [HVACMode.HEAT]
_attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE
_attr_temperature_unit = UnitOfTemperature.CELSIUS
- _enable_turn_on_off_backwards_compatibility = False
def __init__(self, coordinator, serial_number, api, session_id):
"""Initialize the thermostat."""
diff --git a/homeassistant/components/schluter/manifest.json b/homeassistant/components/schluter/manifest.json
index e96058cc146..0302ce09440 100644
--- a/homeassistant/components/schluter/manifest.json
+++ b/homeassistant/components/schluter/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/schluter",
"iot_class": "cloud_polling",
"loggers": ["schluter"],
+ "quality_scale": "legacy",
"requirements": ["py-schluter==0.1.7"]
}
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/scrape/strings.json b/homeassistant/components/scrape/strings.json
index 42cf3001b75..27115836157 100644
--- a/homeassistant/components/scrape/strings.json
+++ b/homeassistant/components/scrape/strings.json
@@ -141,8 +141,10 @@
"options": {
"apparent_power": "[%key:component::sensor::entity_component::apparent_power::name%]",
"aqi": "[%key:component::sensor::entity_component::aqi::name%]",
+ "area": "[%key:component::sensor::entity_component::area::name%]",
"atmospheric_pressure": "[%key:component::sensor::entity_component::atmospheric_pressure::name%]",
"battery": "[%key:component::sensor::entity_component::battery::name%]",
+ "blood_glucose_concentration": "[%key:component::sensor::entity_component::blood_glucose_concentration::name%]",
"carbon_dioxide": "[%key:component::sensor::entity_component::carbon_dioxide::name%]",
"carbon_monoxide": "[%key:component::sensor::entity_component::carbon_monoxide::name%]",
"conductivity": "[%key:component::sensor::entity_component::conductivity::name%]",
diff --git a/homeassistant/components/screenlogic/__init__.py b/homeassistant/components/screenlogic/__init__.py
index 6f58e9b3666..972837f7d75 100644
--- a/homeassistant/components/screenlogic/__init__.py
+++ b/homeassistant/components/screenlogic/__init__.py
@@ -4,6 +4,7 @@ import logging
from typing import Any
from screenlogicpy import ScreenLogicError, ScreenLogicGateway
+from screenlogicpy.const.common import ScreenLogicConnectionError
from screenlogicpy.const.data import SHARED_VALUES
from homeassistant.config_entries import ConfigEntry
@@ -64,7 +65,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ScreenLogicConfigEntry)
try:
await gateway.async_connect(**connect_info)
await gateway.async_update()
- except ScreenLogicError as ex:
+ except (ScreenLogicConnectionError, ScreenLogicError) as ex:
raise ConfigEntryNotReady(ex.msg) from ex
coordinator = ScreenlogicDataUpdateCoordinator(
diff --git a/homeassistant/components/screenlogic/binary_sensor.py b/homeassistant/components/screenlogic/binary_sensor.py
index fda1c348edf..4a178c60d81 100644
--- a/homeassistant/components/screenlogic/binary_sensor.py
+++ b/homeassistant/components/screenlogic/binary_sensor.py
@@ -49,26 +49,31 @@ SUPPORTED_CORE_SENSORS = [
data_root=(DEVICE.CONTROLLER, GROUP.SENSOR),
key=VALUE.ACTIVE_ALERT,
device_class=BinarySensorDeviceClass.PROBLEM,
+ translation_key="active_alert",
),
ScreenLogicPushBinarySensorDescription(
subscription_code=CODE.STATUS_CHANGED,
data_root=(DEVICE.CONTROLLER, GROUP.SENSOR),
key=VALUE.CLEANER_DELAY,
+ translation_key="cleaner_delay",
),
ScreenLogicPushBinarySensorDescription(
subscription_code=CODE.STATUS_CHANGED,
data_root=(DEVICE.CONTROLLER, GROUP.SENSOR),
key=VALUE.FREEZE_MODE,
+ translation_key="freeze_mode",
),
ScreenLogicPushBinarySensorDescription(
subscription_code=CODE.STATUS_CHANGED,
data_root=(DEVICE.CONTROLLER, GROUP.SENSOR),
key=VALUE.POOL_DELAY,
+ translation_key="pool_delay",
),
ScreenLogicPushBinarySensorDescription(
subscription_code=CODE.STATUS_CHANGED,
data_root=(DEVICE.CONTROLLER, GROUP.SENSOR),
key=VALUE.SPA_DELAY,
+ translation_key="spa_delay",
),
]
@@ -85,75 +90,96 @@ SUPPORTED_INTELLICHEM_SENSORS = [
data_root=(DEVICE.INTELLICHEM, GROUP.ALARM),
key=VALUE.FLOW_ALARM,
device_class=BinarySensorDeviceClass.PROBLEM,
+ translation_key="flow_alarm",
),
ScreenLogicPushBinarySensorDescription(
subscription_code=CODE.CHEMISTRY_CHANGED,
data_root=(DEVICE.INTELLICHEM, GROUP.ALARM),
key=VALUE.ORP_HIGH_ALARM,
device_class=BinarySensorDeviceClass.PROBLEM,
+ translation_key="chem_high_alarm",
+ translation_placeholders={"chem": "ORP"},
),
ScreenLogicPushBinarySensorDescription(
subscription_code=CODE.CHEMISTRY_CHANGED,
data_root=(DEVICE.INTELLICHEM, GROUP.ALARM),
key=VALUE.ORP_LOW_ALARM,
device_class=BinarySensorDeviceClass.PROBLEM,
+ translation_key="chem_low_alarm",
+ translation_placeholders={"chem": "ORP"},
),
ScreenLogicPushBinarySensorDescription(
subscription_code=CODE.CHEMISTRY_CHANGED,
data_root=(DEVICE.INTELLICHEM, GROUP.ALARM),
key=VALUE.ORP_SUPPLY_ALARM,
device_class=BinarySensorDeviceClass.PROBLEM,
+ translation_key="chem_supply_alarm",
+ translation_placeholders={"chem": "ORP"},
),
ScreenLogicPushBinarySensorDescription(
subscription_code=CODE.CHEMISTRY_CHANGED,
data_root=(DEVICE.INTELLICHEM, GROUP.ALARM),
key=VALUE.PH_HIGH_ALARM,
device_class=BinarySensorDeviceClass.PROBLEM,
+ translation_key="chem_high_alarm",
+ translation_placeholders={"chem": "pH"},
),
ScreenLogicPushBinarySensorDescription(
subscription_code=CODE.CHEMISTRY_CHANGED,
data_root=(DEVICE.INTELLICHEM, GROUP.ALARM),
key=VALUE.PH_LOW_ALARM,
device_class=BinarySensorDeviceClass.PROBLEM,
+ translation_key="chem_low_alarm",
+ translation_placeholders={"chem": "pH"},
),
ScreenLogicPushBinarySensorDescription(
subscription_code=CODE.CHEMISTRY_CHANGED,
data_root=(DEVICE.INTELLICHEM, GROUP.ALARM),
key=VALUE.PH_SUPPLY_ALARM,
device_class=BinarySensorDeviceClass.PROBLEM,
+ translation_key="chem_supply_alarm",
+ translation_placeholders={"chem": "pH"},
),
ScreenLogicPushBinarySensorDescription(
subscription_code=CODE.CHEMISTRY_CHANGED,
data_root=(DEVICE.INTELLICHEM, GROUP.ALARM),
key=VALUE.PROBE_FAULT_ALARM,
device_class=BinarySensorDeviceClass.PROBLEM,
+ translation_key="probe_fault_alarm",
),
ScreenLogicPushBinarySensorDescription(
subscription_code=CODE.CHEMISTRY_CHANGED,
data_root=(DEVICE.INTELLICHEM, GROUP.ALERT),
key=VALUE.ORP_LIMIT,
+ translation_key="chem_limit",
+ translation_placeholders={"chem": "ORP"},
),
ScreenLogicPushBinarySensorDescription(
subscription_code=CODE.CHEMISTRY_CHANGED,
data_root=(DEVICE.INTELLICHEM, GROUP.ALERT),
key=VALUE.PH_LIMIT,
+ translation_key="chem_limit",
+ translation_placeholders={"chem": "pH"},
),
ScreenLogicPushBinarySensorDescription(
subscription_code=CODE.CHEMISTRY_CHANGED,
data_root=(DEVICE.INTELLICHEM, GROUP.ALERT),
key=VALUE.PH_LOCKOUT,
+ translation_key="ph_lockout",
),
ScreenLogicPushBinarySensorDescription(
subscription_code=CODE.CHEMISTRY_CHANGED,
data_root=(DEVICE.INTELLICHEM, GROUP.WATER_BALANCE),
key=VALUE.CORROSIVE,
device_class=BinarySensorDeviceClass.PROBLEM,
+ translation_key="corosive",
),
ScreenLogicPushBinarySensorDescription(
subscription_code=CODE.CHEMISTRY_CHANGED,
data_root=(DEVICE.INTELLICHEM, GROUP.WATER_BALANCE),
key=VALUE.SCALING,
device_class=BinarySensorDeviceClass.PROBLEM,
+ translation_key="scaling",
),
]
@@ -161,6 +187,7 @@ SUPPORTED_SCG_SENSORS = [
ScreenLogicBinarySensorDescription(
data_root=(DEVICE.SCG, GROUP.SENSOR),
key=VALUE.STATE,
+ translation_key="scg_state",
)
]
diff --git a/homeassistant/components/screenlogic/climate.py b/homeassistant/components/screenlogic/climate.py
index 4d93dcf81d3..e44d9b18ae1 100644
--- a/homeassistant/components/screenlogic/climate.py
+++ b/homeassistant/components/screenlogic/climate.py
@@ -56,6 +56,7 @@ async def async_setup_entry(
subscription_code=CODE.STATUS_CHANGED,
data_root=(DEVICE.BODY,),
key=body_index,
+ translation_key=f"body_{body_index}",
),
)
for body_index in gateway.get_data(DEVICE.BODY)
@@ -80,7 +81,6 @@ class ScreenLogicClimate(ScreenLogicPushEntity, ClimateEntity, RestoreEntity):
| ClimateEntityFeature.TURN_OFF
| ClimateEntityFeature.TURN_ON
)
- _enable_turn_on_off_backwards_compatibility = False
def __init__(self, coordinator, entity_description) -> None:
"""Initialize a ScreenLogic climate entity."""
@@ -93,12 +93,11 @@ 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]
self._attr_max_temp = self.entity_data[ATTR.MAX_SETPOINT]
- self._attr_name = self.entity_data[VALUE.HEAT_STATE][ATTR.NAME]
self._last_preset = None
@property
@@ -138,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/entity.py b/homeassistant/components/screenlogic/entity.py
index 0f7530b7289..746abc2fde6 100644
--- a/homeassistant/components/screenlogic/entity.py
+++ b/homeassistant/components/screenlogic/entity.py
@@ -55,7 +55,8 @@ class ScreenLogicEntity(CoordinatorEntity[ScreenlogicDataUpdateCoordinator]):
self._data_path = (*self.entity_description.data_root, self._data_key)
mac = self.mac
self._attr_unique_id = f"{mac}_{generate_unique_id(*self._data_path)}"
- self._attr_name = self.entity_data[ATTR.NAME]
+ if not entity_description.translation_key:
+ self._attr_name = self.entity_data[ATTR.NAME]
assert mac is not None
self._attr_device_info = DeviceInfo(
connections={(dr.CONNECTION_NETWORK_MAC, mac)},
diff --git a/homeassistant/components/screenlogic/number.py b/homeassistant/components/screenlogic/number.py
index d0eb6a71ec8..3634147e509 100644
--- a/homeassistant/components/screenlogic/number.py
+++ b/homeassistant/components/screenlogic/number.py
@@ -57,6 +57,7 @@ SUPPORTED_INTELLICHEM_NUMBERS = [
key=VALUE.CALCIUM_HARDNESS,
entity_category=EntityCategory.CONFIG,
mode=NumberMode.BOX,
+ translation_key="calcium_hardness",
),
ScreenLogicPushNumberDescription(
subscription_code=CODE.CHEMISTRY_CHANGED,
@@ -64,6 +65,7 @@ SUPPORTED_INTELLICHEM_NUMBERS = [
key=VALUE.CYA,
entity_category=EntityCategory.CONFIG,
mode=NumberMode.BOX,
+ translation_key="cya",
),
ScreenLogicPushNumberDescription(
subscription_code=CODE.CHEMISTRY_CHANGED,
@@ -71,6 +73,7 @@ SUPPORTED_INTELLICHEM_NUMBERS = [
key=VALUE.TOTAL_ALKALINITY,
entity_category=EntityCategory.CONFIG,
mode=NumberMode.BOX,
+ translation_key="total_alkalinity",
),
ScreenLogicPushNumberDescription(
subscription_code=CODE.CHEMISTRY_CHANGED,
@@ -78,6 +81,7 @@ SUPPORTED_INTELLICHEM_NUMBERS = [
key=VALUE.SALT_TDS_PPM,
entity_category=EntityCategory.CONFIG,
mode=NumberMode.BOX,
+ translation_key="salt_tds_ppm",
),
]
@@ -86,11 +90,13 @@ SUPPORTED_SCG_NUMBERS = [
data_root=(DEVICE.SCG, GROUP.CONFIGURATION),
key=VALUE.POOL_SETPOINT,
entity_category=EntityCategory.CONFIG,
+ translation_key="pool_setpoint",
),
ScreenLogicNumberDescription(
data_root=(DEVICE.SCG, GROUP.CONFIGURATION),
key=VALUE.SPA_SETPOINT,
entity_category=EntityCategory.CONFIG,
+ translation_key="spa_setpoint",
),
]
diff --git a/homeassistant/components/screenlogic/sensor.py b/homeassistant/components/screenlogic/sensor.py
index c580204221f..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)
@@ -58,6 +58,19 @@ SUPPORTED_CORE_SENSORS = [
key=VALUE.AIR_TEMPERATURE,
device_class=SensorDeviceClass.TEMPERATURE,
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",
),
]
@@ -97,12 +110,16 @@ SUPPORTED_INTELLICHEM_SENSORS = [
data_root=(DEVICE.INTELLICHEM, GROUP.SENSOR),
key=VALUE.ORP_NOW,
state_class=SensorStateClass.MEASUREMENT,
+ translation_key="chem_now",
+ translation_placeholders={"chem": "ORP"},
),
ScreenLogicPushSensorDescription(
subscription_code=CODE.CHEMISTRY_CHANGED,
data_root=(DEVICE.INTELLICHEM, GROUP.SENSOR),
key=VALUE.PH_NOW,
state_class=SensorStateClass.MEASUREMENT,
+ translation_key="chem_now",
+ translation_placeholders={"chem": "pH"},
),
ScreenLogicPushSensorDescription(
subscription_code=CODE.CHEMISTRY_CHANGED,
@@ -110,6 +127,8 @@ SUPPORTED_INTELLICHEM_SENSORS = [
key=VALUE.ORP_SUPPLY_LEVEL,
state_class=SensorStateClass.MEASUREMENT,
value_mod=lambda val: int(val) - 1,
+ translation_key="chem_supply_level",
+ translation_placeholders={"chem": "ORP"},
),
ScreenLogicPushSensorDescription(
subscription_code=CODE.CHEMISTRY_CHANGED,
@@ -117,6 +136,8 @@ SUPPORTED_INTELLICHEM_SENSORS = [
key=VALUE.PH_SUPPLY_LEVEL,
state_class=SensorStateClass.MEASUREMENT,
value_mod=lambda val: int(val) - 1,
+ translation_key="chem_supply_level",
+ translation_placeholders={"chem": "pH"},
),
ScreenLogicPushSensorDescription(
subscription_code=CODE.CHEMISTRY_CHANGED,
@@ -124,54 +145,66 @@ SUPPORTED_INTELLICHEM_SENSORS = [
key=VALUE.PH_PROBE_WATER_TEMP,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
+ translation_key="ph_probe_water_temp",
),
ScreenLogicPushSensorDescription(
subscription_code=CODE.CHEMISTRY_CHANGED,
data_root=(DEVICE.INTELLICHEM, GROUP.SENSOR),
key=VALUE.SATURATION,
state_class=SensorStateClass.MEASUREMENT,
+ translation_key="saturation",
),
ScreenLogicPushSensorDescription(
subscription_code=CODE.CHEMISTRY_CHANGED,
data_root=(DEVICE.INTELLICHEM, GROUP.CONFIGURATION),
key=VALUE.CALCIUM_HARDNESS,
entity_registry_enabled_default=False, # Superseded by number entity
+ translation_key="calcium_hardness",
),
ScreenLogicPushSensorDescription(
subscription_code=CODE.CHEMISTRY_CHANGED,
data_root=(DEVICE.INTELLICHEM, GROUP.CONFIGURATION),
key=VALUE.CYA,
entity_registry_enabled_default=False, # Superseded by number entity
+ translation_key="cya",
),
ScreenLogicPushSensorDescription(
subscription_code=CODE.CHEMISTRY_CHANGED,
data_root=(DEVICE.INTELLICHEM, GROUP.CONFIGURATION),
key=VALUE.ORP_SETPOINT,
+ translation_key="chem_setpoint",
+ translation_placeholders={"chem": "ORP"},
),
ScreenLogicPushSensorDescription(
subscription_code=CODE.CHEMISTRY_CHANGED,
data_root=(DEVICE.INTELLICHEM, GROUP.CONFIGURATION),
key=VALUE.PH_SETPOINT,
+ translation_key="chem_setpoint",
+ translation_placeholders={"chem": "pH"},
),
ScreenLogicPushSensorDescription(
subscription_code=CODE.CHEMISTRY_CHANGED,
data_root=(DEVICE.INTELLICHEM, GROUP.CONFIGURATION),
key=VALUE.TOTAL_ALKALINITY,
entity_registry_enabled_default=False, # Superseded by number entity
+ translation_key="total_alkalinity",
),
ScreenLogicPushSensorDescription(
subscription_code=CODE.CHEMISTRY_CHANGED,
data_root=(DEVICE.INTELLICHEM, GROUP.CONFIGURATION),
key=VALUE.SALT_TDS_PPM,
entity_registry_enabled_default=False, # Superseded by number entity
+ translation_key="salt_tds_ppm",
),
ScreenLogicPushSensorDescription(
subscription_code=CODE.CHEMISTRY_CHANGED,
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"},
),
ScreenLogicPushSensorDescription(
subscription_code=CODE.CHEMISTRY_CHANGED,
@@ -179,6 +212,8 @@ SUPPORTED_INTELLICHEM_SENSORS = [
key=VALUE.ORP_LAST_DOSE_TIME,
device_class=SensorDeviceClass.DURATION,
state_class=SensorStateClass.TOTAL_INCREASING,
+ translation_key="chem_last_dose_time",
+ translation_placeholders={"chem": "ORP"},
),
ScreenLogicPushSensorDescription(
subscription_code=CODE.CHEMISTRY_CHANGED,
@@ -186,14 +221,18 @@ SUPPORTED_INTELLICHEM_SENSORS = [
key=VALUE.ORP_LAST_DOSE_VOLUME,
device_class=SensorDeviceClass.VOLUME,
state_class=SensorStateClass.TOTAL_INCREASING,
+ translation_key="chem_last_dose_volume",
+ translation_placeholders={"chem": "ORP"},
),
ScreenLogicPushSensorDescription(
subscription_code=CODE.CHEMISTRY_CHANGED,
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"},
),
ScreenLogicPushSensorDescription(
subscription_code=CODE.CHEMISTRY_CHANGED,
@@ -201,6 +240,8 @@ SUPPORTED_INTELLICHEM_SENSORS = [
key=VALUE.PH_LAST_DOSE_TIME,
device_class=SensorDeviceClass.DURATION,
state_class=SensorStateClass.TOTAL_INCREASING,
+ translation_key="chem_last_dose_time",
+ translation_placeholders={"chem": "pH"},
),
ScreenLogicPushSensorDescription(
subscription_code=CODE.CHEMISTRY_CHANGED,
@@ -208,6 +249,8 @@ SUPPORTED_INTELLICHEM_SENSORS = [
key=VALUE.PH_LAST_DOSE_VOLUME,
device_class=SensorDeviceClass.VOLUME,
state_class=SensorStateClass.TOTAL_INCREASING,
+ translation_key="chem_last_dose_volume",
+ translation_placeholders={"chem": "pH"},
),
]
@@ -216,10 +259,12 @@ SUPPORTED_SCG_SENSORS = [
data_root=(DEVICE.SCG, GROUP.SENSOR),
key=VALUE.SALT_PPM,
state_class=SensorStateClass.MEASUREMENT,
+ translation_key="salt_ppm",
),
ScreenLogicSensorDescription(
data_root=(DEVICE.SCG, GROUP.CONFIGURATION),
key=VALUE.SUPER_CHLOR_TIMER,
+ translation_key="super_chlor_timer",
),
]
@@ -311,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 91395a0e86d..09e64808dfe 100644
--- a/homeassistant/components/screenlogic/strings.json
+++ b/homeassistant/components/screenlogic/strings.json
@@ -1,4 +1,12 @@
{
+ "common": {
+ "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_preferred": "Solar Preferred",
+ "climate_preset_heater": "Heater",
+ "climate_preset_dont_change": "Don't Change"
+ },
"config": {
"flow_title": "{name}",
"error": {
@@ -42,8 +50,8 @@
"description": "Sets the color mode for all color-capable lights attached to this ScreenLogic gateway.",
"fields": {
"config_entry": {
- "name": "Config Entry",
- "description": "The config entry to use for this action."
+ "name": "[%key:component::screenlogic::common::service_config_entry_name%]",
+ "description": "[%key:component::screenlogic::common::service_config_entry_description%]"
},
"color_mode": {
"name": "Color Mode",
@@ -56,8 +64,8 @@
"description": "Begins super chlorination, running for the specified period or 24 hours if none is specified.",
"fields": {
"config_entry": {
- "name": "Config Entry",
- "description": "The config entry to use for this action."
+ "name": "[%key:component::screenlogic::common::service_config_entry_name%]",
+ "description": "[%key:component::screenlogic::common::service_config_entry_description%]"
},
"runtime": {
"name": "Run Time",
@@ -70,10 +78,167 @@
"description": "Stops super chlorination.",
"fields": {
"config_entry": {
- "name": "Config Entry",
- "description": "The config entry to use for this action."
+ "name": "[%key:component::screenlogic::common::service_config_entry_name%]",
+ "description": "[%key:component::screenlogic::common::service_config_entry_description%]"
}
}
}
+ },
+ "entity": {
+ "binary_sensor": {
+ "active_alert": {
+ "name": "Active alert"
+ },
+ "pool_delay": {
+ "name": "Pool delay"
+ },
+ "spa_delay": {
+ "name": "Spa delay"
+ },
+ "cleaner_delay": {
+ "name": "Cleaner delay"
+ },
+ "freeze_mode": {
+ "name": "Freeze mode"
+ },
+ "flow_alarm": {
+ "name": "Flow alarm"
+ },
+ "chem_high_alarm": {
+ "name": "{chem} high alarm"
+ },
+ "chem_low_alarm": {
+ "name": "{chem} low alarm"
+ },
+ "chem_supply_alarm": {
+ "name": "{chem} supply alarm"
+ },
+ "probe_fault_alarm": {
+ "name": "Probe fault"
+ },
+ "chem_limit": {
+ "name": "{chem} dose limit reached"
+ },
+ "ph_lockout": {
+ "name": "pH lockout"
+ },
+ "corosive": {
+ "name": "SI corrosive"
+ },
+ "scaling": {
+ "name": "SI scaling"
+ },
+ "scg_state": {
+ "name": "Chlorinator"
+ }
+ },
+ "climate": {
+ "body_0": {
+ "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",
+ "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": {
+ "calcium_hardness": {
+ "name": "Calcium hardness"
+ },
+ "cya": {
+ "name": "Cyanuric acid"
+ },
+ "total_alkalinity": {
+ "name": "Total alkalinity"
+ },
+ "salt_tds_ppm": {
+ "name": "Salt/TDS"
+ },
+ "pool_setpoint": {
+ "name": "Pool chlorinator setpoint"
+ },
+ "spa_setpoint": {
+ "name": "Spa chlorinator setpoint"
+ }
+ },
+ "sensor": {
+ "air_temperature": {
+ "name": "Air temperature"
+ },
+ "controller_state": {
+ "name": "Controller state",
+ "state": {
+ "ready": "Ready",
+ "sync": "Sync",
+ "service": "Service"
+ }
+ },
+ "chem_now": {
+ "name": "{chem} now"
+ },
+ "chem_supply_level": {
+ "name": "{chem} supply level"
+ },
+ "ph_probe_water_temp": {
+ "name": "pH probe water temperature"
+ },
+ "saturation": {
+ "name": "Saturation index"
+ },
+ "chem_setpoint": {
+ "name": "{chem} setpoint"
+ },
+ "calcium_hardness": {
+ "name": "[%key:component::screenlogic::entity::number::calcium_hardness::name%]"
+ },
+ "cya": {
+ "name": "[%key:component::screenlogic::entity::number::cya::name%]"
+ },
+ "total_alkalinity": {
+ "name": "[%key:component::screenlogic::entity::number::total_alkalinity::name%]"
+ },
+ "salt_tds_ppm": {
+ "name": "[%key:component::screenlogic::entity::number::salt_tds_ppm::name%]"
+ },
+ "chem_dose_state": {
+ "name": "{chem} dosing state",
+ "state": {
+ "dosing": "Dosing",
+ "mixing": "Mixing",
+ "monitoring": "Monitoring"
+ }
+ },
+ "chem_last_dose_time": {
+ "name": "{chem} last dose time"
+ },
+ "chem_last_dose_volume": {
+ "name": "{chem} last dose volume"
+ },
+ "salt_ppm": {
+ "name": "Chlorinator salt"
+ },
+ "super_chlor_timer": {
+ "name": "Super chlorination timer"
+ }
+ }
}
}
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/manifest.json b/homeassistant/components/scsgate/manifest.json
index 3f20762cf73..a3b08f86719 100644
--- a/homeassistant/components/scsgate/manifest.json
+++ b/homeassistant/components/scsgate/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/scsgate",
"iot_class": "local_polling",
"loggers": ["scsgate"],
+ "quality_scale": "legacy",
"requirements": ["scsgate==0.1.0"]
}
diff --git a/homeassistant/components/sendgrid/manifest.json b/homeassistant/components/sendgrid/manifest.json
index c38952e1a04..ec89ae0a363 100644
--- a/homeassistant/components/sendgrid/manifest.json
+++ b/homeassistant/components/sendgrid/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/sendgrid",
"iot_class": "cloud_push",
"loggers": ["sendgrid"],
+ "quality_scale": "legacy",
"requirements": ["sendgrid==6.8.2"]
}
diff --git a/homeassistant/components/sense/manifest.json b/homeassistant/components/sense/manifest.json
index df2317c3a6c..966488b6a48 100644
--- a/homeassistant/components/sense/manifest.json
+++ b/homeassistant/components/sense/manifest.json
@@ -20,5 +20,5 @@
"documentation": "https://www.home-assistant.io/integrations/sense",
"iot_class": "cloud_polling",
"loggers": ["sense_energy"],
- "requirements": ["sense-energy==0.13.3"]
+ "requirements": ["sense-energy==0.13.4"]
}
diff --git a/homeassistant/components/sensibo/__init__.py b/homeassistant/components/sensibo/__init__.py
index b2b6ac15958..06b5ea6588a 100644
--- a/homeassistant/components/sensibo/__init__.py
+++ b/homeassistant/components/sensibo/__init__.py
@@ -21,7 +21,7 @@ type SensiboConfigEntry = ConfigEntry[SensiboDataUpdateCoordinator]
async def async_setup_entry(hass: HomeAssistant, entry: SensiboConfigEntry) -> bool:
"""Set up Sensibo from a config entry."""
- coordinator = SensiboDataUpdateCoordinator(hass)
+ coordinator = SensiboDataUpdateCoordinator(hass, entry)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
@@ -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..8d47fb11526 100644
--- a/homeassistant/components/sensibo/binary_sensor.py
+++ b/homeassistant/components/sensibo/binary_sensor.py
@@ -26,14 +26,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]
diff --git a/homeassistant/components/sensibo/button.py b/homeassistant/components/sensibo/button.py
index 9ac504537fa..7adafe2e7fc 100644
--- a/homeassistant/components/sensibo/button.py
+++ b/homeassistant/components/sensibo/button.py
@@ -37,7 +37,7 @@ 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
@@ -48,7 +48,7 @@ async def async_setup_entry(
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 390ebc080b8..9a2f265041f 100644
--- a/homeassistant/components/sensibo/climate.py
+++ b/homeassistant/components/sensibo/climate.py
@@ -1,4 +1,4 @@
-"""Support for Sensibo wifi-enabled home thermostats."""
+"""Support for Sensibo climate devices."""
from __future__ import annotations
@@ -9,6 +9,7 @@ import voluptuous as vol
from homeassistant.components.climate import (
ATTR_FAN_MODE,
+ ATTR_HVAC_MODE,
ATTR_SWING_MODE,
ClimateEntity,
ClimateEntityFeature,
@@ -21,7 +22,7 @@ from homeassistant.const import (
PRECISION_TENTHS,
UnitOfTemperature,
)
-from homeassistant.core import HomeAssistant
+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
@@ -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,10 +123,11 @@ AC_STATE_TO_DATA = {
"on": "device_on",
"mode": "hvac_mode",
"swing": "swing_mode",
+ "horizontalSwing": "horizontal_swing_mode",
}
-def _find_valid_target_temp(target: int, valid_targets: list[int]) -> int:
+def _find_valid_target_temp(target: float, valid_targets: list[int]) -> int:
if target <= valid_targets[0]:
return valid_targets[0]
if target >= valid_targets[-1]:
@@ -154,7 +173,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 +187,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,20 +199,25 @@ 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
_attr_translation_key = "climate_device"
- _enable_turn_on_off_backwards_compatibility = False
def __init__(
self, coordinator: SensiboDataUpdateCoordinator, device_id: str
@@ -207,12 +230,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
@@ -234,8 +257,7 @@ class SensiboClimate(SensiboDeviceBaseEntity, ClimateEntity):
"""Return the list of available hvac operation modes."""
if TYPE_CHECKING:
assert self.device_data.hvac_modes
- hvac_modes = [SENSIBO_TO_HA[mode] for mode in self.device_data.hvac_modes]
- return hvac_modes if hvac_modes else [HVACMode.OFF]
+ return [SENSIBO_TO_HA[mode] for mode in self.device_data.hvac_modes]
@property
def current_temperature(self) -> float | None:
@@ -260,72 +282,56 @@ class SensiboClimate(SensiboDeviceBaseEntity, ClimateEntity):
@property
def target_temperature(self) -> float | None:
"""Return the temperature we try to reach."""
- target_temp: int | None = self.device_data.target_temp
- return target_temp
+ return self.device_data.target_temp
@property
def target_temperature_step(self) -> float | None:
"""Return the supported step of target temperature."""
- target_temp_step: int = self.device_data.temp_step
- return target_temp_step
+ return self.device_data.temp_step
@property
def fan_mode(self) -> str | None:
"""Return the fan setting."""
- fan_mode: str | None = self.device_data.fan_mode
- return fan_mode
+ return self.device_data.fan_mode
@property
def fan_modes(self) -> list[str] | None:
"""Return the list of available fan modes."""
- if self.device_data.fan_modes:
- return self.device_data.fan_modes
- return None
+ return self.device_data.fan_modes
@property
def swing_mode(self) -> str | None:
"""Return the swing setting."""
- swing_mode: str | None = self.device_data.swing_mode
- return swing_mode
+ return self.device_data.swing_mode
@property
def swing_modes(self) -> list[str] | None:
"""Return the list of available swing modes."""
- if self.device_data.swing_modes:
- return self.device_data.swing_modes
- return None
+ 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."""
- min_temp: int = self.device_data.temp_list[0]
- return min_temp
+ return self.device_data.temp_list[0]
@property
def max_temp(self) -> float:
"""Return the maximum temperature."""
- max_temp: int = self.device_data.temp_list[-1]
- return max_temp
-
- @property
- def available(self) -> bool:
- """Return True if entity is available."""
- return self.device_data.available and super().available
+ return self.device_data.temp_list[-1]
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",
- )
-
- if (temperature := kwargs.get(ATTR_TEMPERATURE)) is None:
- raise ServiceValidationError(
- translation_domain=DOMAIN,
- translation_key="no_target_temperature",
- )
-
+ temperature: float = kwargs[ATTR_TEMPERATURE]
if temperature == self.target_temperature:
return
@@ -339,11 +345,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,
@@ -389,11 +390,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,
@@ -410,6 +406,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(
@@ -428,6 +444,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(
@@ -495,7 +531,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:
@@ -535,6 +571,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 d654a7cb072..e512935dfce 100644
--- a/homeassistant/components/sensibo/coordinator.py
+++ b/homeassistant/components/sensibo/coordinator.py
@@ -29,11 +29,12 @@ class SensiboDataUpdateCoordinator(DataUpdateCoordinator[SensiboData]):
config_entry: SensiboConfigEntry
- def __init__(self, hass: HomeAssistant) -> None:
+ def __init__(self, hass: HomeAssistant, config_entry: SensiboConfigEntry) -> None:
"""Initialize the Sensibo coordinator."""
super().__init__(
hass,
LOGGER,
+ config_entry=config_entry,
name=DOMAIN,
update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL),
# We don't want an immediate refresh since the device
@@ -53,10 +54,17 @@ class SensiboDataUpdateCoordinator(DataUpdateCoordinator[SensiboData]):
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")
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/manifest.json b/homeassistant/components/sensibo/manifest.json
index 610695aaf7b..e6398c5076e 100644
--- a/homeassistant/components/sensibo/manifest.json
+++ b/homeassistant/components/sensibo/manifest.json
@@ -14,6 +14,5 @@
},
"iot_class": "cloud_polling",
"loggers": ["pysensibo"],
- "quality_scale": "platinum",
"requirements": ["pysensibo==1.1.0"]
}
diff --git a/homeassistant/components/sensibo/quality_scale.yaml b/homeassistant/components/sensibo/quality_scale.yaml
new file mode 100644
index 00000000000..08632ddac0f
--- /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: todo
+ diagnostics:
+ status: done
+ comment: |
+ Change to only use redact once
+ exception-translations: done
+ icon-translations: done
+ reconfiguration-flow: done
+ dynamic-devices: todo
+ 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..12e7364d6ee 100644
--- a/homeassistant/components/sensibo/select.py
+++ b/homeassistant/components/sensibo/select.py
@@ -8,10 +8,22 @@ 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 +43,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 +70,55 @@ 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,
+ },
+ )
+
+ entities.extend(
+ [
+ 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
+ ]
)
+ async_add_entities(entities)
class SensiboSelect(SensiboDeviceBaseEntity, SelectEntity):
diff --git a/homeassistant/components/sensibo/sensor.py b/homeassistant/components/sensibo/sensor.py
index a6a70ea6c49..bea1326181c 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,
),
@@ -178,6 +185,7 @@ AIRQ_SENSOR_TYPES: tuple[SensiboDeviceSensorEntityDescription, ...] = (
value_fn=lambda data: data.co2,
extra_fn=None,
),
+ *DEVICE_SENSOR_TYPES,
)
ELEMENT_SENSOR_TYPES: tuple[SensiboDeviceSensorEntityDescription, ...] = (
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 bec402bee18..a1f60c247a3 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%]"
+ }
}
}
}
@@ -494,27 +509,66 @@
"description": "Choose between temperature/feels like/humidity."
}
}
+ },
+ "get_device_capabilities": {
+ "name": "Get device mode capabilities",
+ "description": "Retrieve 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_target_temperature": {
- "message": "No target temperature provided"
- },
- "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}"
},
@@ -526,6 +580,24 @@
},
"climate_react_not_available": {
"message": "Use Sensibo Enable Climate React action once to enable switch or 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/sensor/__init__.py b/homeassistant/components/sensor/__init__.py
index 31626b0b761..2933d779b4b 100644
--- a/homeassistant/components/sensor/__init__.py
+++ b/homeassistant/components/sensor/__init__.py
@@ -8,7 +8,6 @@ from contextlib import suppress
from dataclasses import dataclass
from datetime import UTC, date, datetime, timedelta
from decimal import Decimal, InvalidOperation as DecimalInvalidOperation
-from functools import partial
import logging
from math import ceil, floor, isfinite, log10
from typing import Any, Final, Self, cast, final, override
@@ -17,34 +16,6 @@ from propcache import cached_property
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ( # noqa: F401
- _DEPRECATED_DEVICE_CLASS_AQI,
- _DEPRECATED_DEVICE_CLASS_BATTERY,
- _DEPRECATED_DEVICE_CLASS_CO,
- _DEPRECATED_DEVICE_CLASS_CO2,
- _DEPRECATED_DEVICE_CLASS_CURRENT,
- _DEPRECATED_DEVICE_CLASS_DATE,
- _DEPRECATED_DEVICE_CLASS_ENERGY,
- _DEPRECATED_DEVICE_CLASS_FREQUENCY,
- _DEPRECATED_DEVICE_CLASS_GAS,
- _DEPRECATED_DEVICE_CLASS_HUMIDITY,
- _DEPRECATED_DEVICE_CLASS_ILLUMINANCE,
- _DEPRECATED_DEVICE_CLASS_MONETARY,
- _DEPRECATED_DEVICE_CLASS_NITROGEN_DIOXIDE,
- _DEPRECATED_DEVICE_CLASS_NITROGEN_MONOXIDE,
- _DEPRECATED_DEVICE_CLASS_NITROUS_OXIDE,
- _DEPRECATED_DEVICE_CLASS_OZONE,
- _DEPRECATED_DEVICE_CLASS_PM1,
- _DEPRECATED_DEVICE_CLASS_PM10,
- _DEPRECATED_DEVICE_CLASS_PM25,
- _DEPRECATED_DEVICE_CLASS_POWER,
- _DEPRECATED_DEVICE_CLASS_POWER_FACTOR,
- _DEPRECATED_DEVICE_CLASS_PRESSURE,
- _DEPRECATED_DEVICE_CLASS_SIGNAL_STRENGTH,
- _DEPRECATED_DEVICE_CLASS_SULPHUR_DIOXIDE,
- _DEPRECATED_DEVICE_CLASS_TEMPERATURE,
- _DEPRECATED_DEVICE_CLASS_TIMESTAMP,
- _DEPRECATED_DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS,
- _DEPRECATED_DEVICE_CLASS_VOLTAGE,
ATTR_UNIT_OF_MEASUREMENT,
CONF_UNIT_OF_MEASUREMENT,
EntityCategory,
@@ -53,11 +24,6 @@ from homeassistant.const import ( # noqa: F401
from homeassistant.core import HomeAssistant, State, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv, entity_registry as er
-from homeassistant.helpers.deprecation import (
- 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.entity_platform import EntityPlatform
@@ -68,9 +34,6 @@ from homeassistant.util.enum import try_parse_enum
from homeassistant.util.hass_dict import HassKey
from .const import ( # noqa: F401
- _DEPRECATED_STATE_CLASS_MEASUREMENT,
- _DEPRECATED_STATE_CLASS_TOTAL,
- _DEPRECATED_STATE_CLASS_TOTAL_INCREASING,
ATTR_LAST_RESET,
ATTR_OPTIONS,
ATTR_STATE_CLASS,
@@ -531,7 +494,20 @@ class SensorEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
):
return self.hass.config.units.temperature_unit
- # Fourth priority: Native unit
+ # Fourth priority: Unit translation
+ if (translation_key := self._unit_of_measurement_translation_key) and (
+ unit_of_measurement
+ := self.platform.default_language_platform_translations.get(translation_key)
+ ):
+ if native_unit_of_measurement is not None:
+ raise ValueError(
+ f"Sensor {type(self)} from integration '{self.platform.platform_name}' "
+ f"has a translation key for unit_of_measurement '{unit_of_measurement}', "
+ f"but also has a native_unit_of_measurement '{native_unit_of_measurement}'"
+ )
+ return unit_of_measurement
+
+ # Lowest priority: Native unit
return native_unit_of_measurement
@final
@@ -966,13 +942,3 @@ def async_rounded_state(hass: HomeAssistant, entity_id: str, state: State) -> st
value = f"{numerical_value:z.{precision}f}"
return value
-
-
-# As we import deprecated constants from the const module, we need to add these two functions
-# otherwise this module will be logged for using deprecated constants and not the custom component
-# 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/sensor/const.py b/homeassistant/components/sensor/const.py
index aa3d1906b21..8c3c3925513 100644
--- a/homeassistant/components/sensor/const.py
+++ b/homeassistant/components/sensor/const.py
@@ -3,7 +3,6 @@
from __future__ import annotations
from enum import StrEnum
-from functools import partial
from typing import Final
import voluptuous as vol
@@ -17,6 +16,8 @@ from homeassistant.const import (
SIGNAL_STRENGTH_DECIBELS,
SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
UnitOfApparentPower,
+ UnitOfArea,
+ UnitOfBloodGlucoseConcentration,
UnitOfConductivity,
UnitOfDataRate,
UnitOfElectricCurrent,
@@ -39,14 +40,10 @@ from homeassistant.const import (
UnitOfVolumeFlowRate,
UnitOfVolumetricFlux,
)
-from homeassistant.helpers.deprecation import (
- DeprecatedConstantEnum,
- all_with_deprecated_constants,
- check_if_deprecated_constant,
- dir_with_deprecated_constants,
-)
from homeassistant.util.unit_conversion import (
+ AreaConverter,
BaseUnitConverter,
+ BloodGlucoseConcentrationConverter,
ConductivityConverter,
DataRateConverter,
DistanceConverter,
@@ -115,6 +112,12 @@ class SensorDeviceClass(StrEnum):
Unit of measurement: `None`
"""
+ AREA = "area"
+ """Area
+
+ Unit of measurement: `UnitOfArea` units
+ """
+
ATMOSPHERIC_PRESSURE = "atmospheric_pressure"
"""Atmospheric pressure.
@@ -127,6 +130,12 @@ class SensorDeviceClass(StrEnum):
Unit of measurement: `%`
"""
+ BLOOD_GLUCOSE_CONCENTRATION = "blood_glucose_concentration"
+ """Blood glucose concentration.
+
+ Unit of measurement: `mg/dL`, `mmol/L`
+ """
+
CO = "carbon_monoxide"
"""Carbon Monoxide gas concentration.
@@ -182,7 +191,7 @@ class SensorDeviceClass(StrEnum):
Use this device class for sensors measuring energy consumption, for example
electric energy consumption.
- Unit of measurement: `J`, `kJ`, `MJ`, `GJ`, `Wh`, `kWh`, `MWh`, `GWh`, `TWh`, `cal`, `kcal`, `Mcal`, `Gcal`
+ Unit of measurement: `J`, `kJ`, `MJ`, `GJ`, `mWh`, `Wh`, `kWh`, `MWh`, `GWh`, `TWh`, `cal`, `kcal`, `Mcal`, `Gcal`
"""
ENERGY_STORAGE = "energy_storage"
@@ -191,7 +200,7 @@ class SensorDeviceClass(StrEnum):
Use this device class for sensors measuring stored energy, for example the amount
of electric energy currently stored in a battery or the capacity of a battery.
- Unit of measurement: `Wh`, `kWh`, `MWh`, `GWh`, `TWh`, `MJ`, `GJ`
+ Unit of measurement: `J`, `kJ`, `MJ`, `GJ`, `mWh`, `Wh`, `kWh`, `MWh`, `GWh`, `TWh`, `cal`, `kcal`, `Mcal`, `Gcal`
"""
FREQUENCY = "frequency"
@@ -299,7 +308,7 @@ class SensorDeviceClass(StrEnum):
POWER = "power"
"""Power.
- Unit of measurement: `W`, `kW`, `MW`, `GW`, `TW`
+ Unit of measurement: `mW`, `W`, `kW`, `MW`, `GW`, `TW`, `BTU/h`
"""
PRECIPITATION = "precipitation"
@@ -383,7 +392,7 @@ class SensorDeviceClass(StrEnum):
VOLTAGE = "voltage"
"""Voltage.
- Unit of measurement: `V`, `mV`
+ Unit of measurement: `V`, `mV`, `µV`
"""
VOLUME = "volume"
@@ -411,7 +420,7 @@ class SensorDeviceClass(StrEnum):
"""Generic flow rate
Unit of measurement: UnitOfVolumeFlowRate
- - SI / metric: `m³/h`, `L/min`
+ - SI / metric: `m³/h`, `L/min`, `mL/s`
- USCS / imperial: `ft³/min`, `gal/min`
"""
@@ -478,21 +487,12 @@ class SensorStateClass(StrEnum):
STATE_CLASSES_SCHEMA: Final = vol.All(vol.Lower, vol.Coerce(SensorStateClass))
-# STATE_CLASS* is deprecated as of 2021.12
-# use the SensorStateClass enum instead.
-_DEPRECATED_STATE_CLASS_MEASUREMENT: Final = DeprecatedConstantEnum(
- SensorStateClass.MEASUREMENT, "2025.1"
-)
-_DEPRECATED_STATE_CLASS_TOTAL: Final = DeprecatedConstantEnum(
- SensorStateClass.TOTAL, "2025.1"
-)
-_DEPRECATED_STATE_CLASS_TOTAL_INCREASING: Final = DeprecatedConstantEnum(
- SensorStateClass.TOTAL_INCREASING, "2025.1"
-)
STATE_CLASSES: Final[list[str]] = [cls.value for cls in SensorStateClass]
UNIT_CONVERTERS: dict[SensorDeviceClass | str | None, type[BaseUnitConverter]] = {
+ SensorDeviceClass.AREA: AreaConverter,
SensorDeviceClass.ATMOSPHERIC_PRESSURE: PressureConverter,
+ SensorDeviceClass.BLOOD_GLUCOSE_CONCENTRATION: BloodGlucoseConcentrationConverter,
SensorDeviceClass.CONDUCTIVITY: ConductivityConverter,
SensorDeviceClass.CURRENT: ElectricCurrentConverter,
SensorDeviceClass.DATA_RATE: DataRateConverter,
@@ -522,8 +522,10 @@ UNIT_CONVERTERS: dict[SensorDeviceClass | str | None, type[BaseUnitConverter]] =
DEVICE_CLASS_UNITS: dict[SensorDeviceClass, set[type[StrEnum] | str | None]] = {
SensorDeviceClass.APPARENT_POWER: set(UnitOfApparentPower),
SensorDeviceClass.AQI: {None},
+ SensorDeviceClass.AREA: set(UnitOfArea),
SensorDeviceClass.ATMOSPHERIC_PRESSURE: set(UnitOfPressure),
SensorDeviceClass.BATTERY: {PERCENTAGE},
+ SensorDeviceClass.BLOOD_GLUCOSE_CONCENTRATION: set(UnitOfBloodGlucoseConcentration),
SensorDeviceClass.CO: {CONCENTRATION_PARTS_PER_MILLION},
SensorDeviceClass.CO2: {CONCENTRATION_PARTS_PER_MILLION},
SensorDeviceClass.CONDUCTIVITY: set(UnitOfConductivity),
@@ -559,7 +561,13 @@ DEVICE_CLASS_UNITS: dict[SensorDeviceClass, set[type[StrEnum] | str | None]] = {
SensorDeviceClass.PM10: {CONCENTRATION_MICROGRAMS_PER_CUBIC_METER},
SensorDeviceClass.PM25: {CONCENTRATION_MICROGRAMS_PER_CUBIC_METER},
SensorDeviceClass.POWER_FACTOR: {PERCENTAGE, None},
- SensorDeviceClass.POWER: {UnitOfPower.WATT, UnitOfPower.KILO_WATT},
+ SensorDeviceClass.POWER: {
+ UnitOfPower.WATT,
+ UnitOfPower.KILO_WATT,
+ UnitOfPower.MEGA_WATT,
+ UnitOfPower.GIGA_WATT,
+ UnitOfPower.TERA_WATT,
+ },
SensorDeviceClass.PRECIPITATION: set(UnitOfPrecipitationDepth),
SensorDeviceClass.PRECIPITATION_INTENSITY: set(UnitOfVolumetricFlux),
SensorDeviceClass.PRESSURE: set(UnitOfPressure),
@@ -597,8 +605,10 @@ DEVICE_CLASS_UNITS: dict[SensorDeviceClass, set[type[StrEnum] | str | None]] = {
DEVICE_CLASS_STATE_CLASSES: dict[SensorDeviceClass, set[SensorStateClass]] = {
SensorDeviceClass.APPARENT_POWER: {SensorStateClass.MEASUREMENT},
SensorDeviceClass.AQI: {SensorStateClass.MEASUREMENT},
+ SensorDeviceClass.AREA: set(SensorStateClass),
SensorDeviceClass.ATMOSPHERIC_PRESSURE: {SensorStateClass.MEASUREMENT},
SensorDeviceClass.BATTERY: {SensorStateClass.MEASUREMENT},
+ SensorDeviceClass.BLOOD_GLUCOSE_CONCENTRATION: {SensorStateClass.MEASUREMENT},
SensorDeviceClass.CO: {SensorStateClass.MEASUREMENT},
SensorDeviceClass.CO2: {SensorStateClass.MEASUREMENT},
SensorDeviceClass.CONDUCTIVITY: {SensorStateClass.MEASUREMENT},
@@ -661,10 +671,3 @@ DEVICE_CLASS_STATE_CLASSES: dict[SensorDeviceClass, set[SensorStateClass]] = {
},
SensorDeviceClass.WIND_SPEED: {SensorStateClass.MEASUREMENT},
}
-
-# 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/sensor/device_condition.py b/homeassistant/components/sensor/device_condition.py
index f2b51899312..fc25dce18fc 100644
--- a/homeassistant/components/sensor/device_condition.py
+++ b/homeassistant/components/sensor/device_condition.py
@@ -35,8 +35,10 @@ DEVICE_CLASS_NONE = "none"
CONF_IS_APPARENT_POWER = "is_apparent_power"
CONF_IS_AQI = "is_aqi"
+CONF_IS_AREA = "is_area"
CONF_IS_ATMOSPHERIC_PRESSURE = "is_atmospheric_pressure"
CONF_IS_BATTERY_LEVEL = "is_battery_level"
+CONF_IS_BLOOD_GLUCOSE_CONCENTRATION = "is_blood_glucose_concentration"
CONF_IS_CO = "is_carbon_monoxide"
CONF_IS_CO2 = "is_carbon_dioxide"
CONF_IS_CONDUCTIVITY = "is_conductivity"
@@ -85,8 +87,12 @@ CONF_IS_WIND_SPEED = "is_wind_speed"
ENTITY_CONDITIONS = {
SensorDeviceClass.APPARENT_POWER: [{CONF_TYPE: CONF_IS_APPARENT_POWER}],
SensorDeviceClass.AQI: [{CONF_TYPE: CONF_IS_AQI}],
+ SensorDeviceClass.AREA: [{CONF_TYPE: CONF_IS_AREA}],
SensorDeviceClass.ATMOSPHERIC_PRESSURE: [{CONF_TYPE: CONF_IS_ATMOSPHERIC_PRESSURE}],
SensorDeviceClass.BATTERY: [{CONF_TYPE: CONF_IS_BATTERY_LEVEL}],
+ SensorDeviceClass.BLOOD_GLUCOSE_CONCENTRATION: [
+ {CONF_TYPE: CONF_IS_BLOOD_GLUCOSE_CONCENTRATION}
+ ],
SensorDeviceClass.CO: [{CONF_TYPE: CONF_IS_CO}],
SensorDeviceClass.CO2: [{CONF_TYPE: CONF_IS_CO2}],
SensorDeviceClass.CONDUCTIVITY: [{CONF_TYPE: CONF_IS_CONDUCTIVITY}],
@@ -149,8 +155,10 @@ CONDITION_SCHEMA = vol.All(
[
CONF_IS_APPARENT_POWER,
CONF_IS_AQI,
+ CONF_IS_AREA,
CONF_IS_ATMOSPHERIC_PRESSURE,
CONF_IS_BATTERY_LEVEL,
+ CONF_IS_BLOOD_GLUCOSE_CONCENTRATION,
CONF_IS_CO,
CONF_IS_CO2,
CONF_IS_CONDUCTIVITY,
diff --git a/homeassistant/components/sensor/device_trigger.py b/homeassistant/components/sensor/device_trigger.py
index b07b3fac11e..d75b3aa6e41 100644
--- a/homeassistant/components/sensor/device_trigger.py
+++ b/homeassistant/components/sensor/device_trigger.py
@@ -34,8 +34,10 @@ DEVICE_CLASS_NONE = "none"
CONF_APPARENT_POWER = "apparent_power"
CONF_AQI = "aqi"
+CONF_AREA = "area"
CONF_ATMOSPHERIC_PRESSURE = "atmospheric_pressure"
CONF_BATTERY_LEVEL = "battery_level"
+CONF_BLOOD_GLUCOSE_CONCENTRATION = "blood_glucose_concentration"
CONF_CO = "carbon_monoxide"
CONF_CO2 = "carbon_dioxide"
CONF_CONDUCTIVITY = "conductivity"
@@ -84,8 +86,12 @@ CONF_WIND_SPEED = "wind_speed"
ENTITY_TRIGGERS = {
SensorDeviceClass.APPARENT_POWER: [{CONF_TYPE: CONF_APPARENT_POWER}],
SensorDeviceClass.AQI: [{CONF_TYPE: CONF_AQI}],
+ SensorDeviceClass.AREA: [{CONF_TYPE: CONF_AREA}],
SensorDeviceClass.ATMOSPHERIC_PRESSURE: [{CONF_TYPE: CONF_ATMOSPHERIC_PRESSURE}],
SensorDeviceClass.BATTERY: [{CONF_TYPE: CONF_BATTERY_LEVEL}],
+ SensorDeviceClass.BLOOD_GLUCOSE_CONCENTRATION: [
+ {CONF_TYPE: CONF_BLOOD_GLUCOSE_CONCENTRATION}
+ ],
SensorDeviceClass.CO: [{CONF_TYPE: CONF_CO}],
SensorDeviceClass.CO2: [{CONF_TYPE: CONF_CO2}],
SensorDeviceClass.CONDUCTIVITY: [{CONF_TYPE: CONF_CONDUCTIVITY}],
@@ -149,8 +155,10 @@ TRIGGER_SCHEMA = vol.All(
[
CONF_APPARENT_POWER,
CONF_AQI,
+ CONF_AREA,
CONF_ATMOSPHERIC_PRESSURE,
CONF_BATTERY_LEVEL,
+ CONF_BLOOD_GLUCOSE_CONCENTRATION,
CONF_CO,
CONF_CO2,
CONF_CONDUCTIVITY,
diff --git a/homeassistant/components/sensor/icons.json b/homeassistant/components/sensor/icons.json
index 6132fcbc1e9..5f770765ee3 100644
--- a/homeassistant/components/sensor/icons.json
+++ b/homeassistant/components/sensor/icons.json
@@ -9,9 +9,15 @@
"aqi": {
"default": "mdi:air-filter"
},
+ "area": {
+ "default": "mdi:texture-box"
+ },
"atmospheric_pressure": {
"default": "mdi:thermometer-lines"
},
+ "blood_glucose_concentration": {
+ "default": "mdi:spoon-sugar"
+ },
"carbon_dioxide": {
"default": "mdi:molecule-co2"
},
diff --git a/homeassistant/components/sensor/strings.json b/homeassistant/components/sensor/strings.json
index 71bead342c4..d44d621f82d 100644
--- a/homeassistant/components/sensor/strings.json
+++ b/homeassistant/components/sensor/strings.json
@@ -4,8 +4,10 @@
"condition_type": {
"is_apparent_power": "Current {entity_name} apparent power",
"is_aqi": "Current {entity_name} air quality index",
+ "is_area": "Current {entity_name} area",
"is_atmospheric_pressure": "Current {entity_name} atmospheric pressure",
"is_battery_level": "Current {entity_name} battery level",
+ "is_blood_glucose_concentration": "Current {entity_name} blood glucose concentration",
"is_carbon_monoxide": "Current {entity_name} carbon monoxide concentration level",
"is_carbon_dioxide": "Current {entity_name} carbon dioxide concentration level",
"is_conductivity": "Current {entity_name} conductivity",
@@ -21,7 +23,7 @@
"is_illuminance": "Current {entity_name} illuminance",
"is_irradiance": "Current {entity_name} irradiance",
"is_moisture": "Current {entity_name} moisture",
- "is_monetary": "Current {entity_name} money",
+ "is_monetary": "Current {entity_name} balance",
"is_nitrogen_dioxide": "Current {entity_name} nitrogen dioxide concentration level",
"is_nitrogen_monoxide": "Current {entity_name} nitrogen monoxide concentration level",
"is_nitrous_oxide": "Current {entity_name} nitrous oxide concentration level",
@@ -54,8 +56,10 @@
"trigger_type": {
"apparent_power": "{entity_name} apparent power changes",
"aqi": "{entity_name} air quality index changes",
+ "area": "{entity_name} area changes",
"atmospheric_pressure": "{entity_name} atmospheric pressure changes",
"battery_level": "{entity_name} battery level changes",
+ "blood_glucose_concentration": "{entity_name} blood glucose concentration changes",
"carbon_monoxide": "{entity_name} carbon monoxide concentration changes",
"carbon_dioxide": "{entity_name} carbon dioxide concentration changes",
"conductivity": "{entity_name} conductivity changes",
@@ -71,7 +75,7 @@
"illuminance": "{entity_name} illuminance changes",
"irradiance": "{entity_name} irradiance changes",
"moisture": "{entity_name} moisture changes",
- "monetary": "{entity_name} money changes",
+ "monetary": "{entity_name} balance changes",
"nitrogen_dioxide": "{entity_name} nitrogen dioxide concentration changes",
"nitrogen_monoxide": "{entity_name} nitrogen monoxide concentration changes",
"nitrous_oxide": "{entity_name} nitrous oxide concentration changes",
@@ -143,12 +147,18 @@
"aqi": {
"name": "Air quality index"
},
+ "area": {
+ "name": "Area"
+ },
"atmospheric_pressure": {
"name": "Atmospheric pressure"
},
"battery": {
"name": "Battery"
},
+ "blood_glucose_concentration": {
+ "name": "Blood glucose concentration"
+ },
"carbon_monoxide": {
"name": "Carbon monoxide"
},
diff --git a/homeassistant/components/senz/climate.py b/homeassistant/components/senz/climate.py
index 3b834654ca6..d5749a3f040 100644
--- a/homeassistant/components/senz/climate.py
+++ b/homeassistant/components/senz/climate.py
@@ -46,7 +46,6 @@ class SENZClimate(CoordinatorEntity, ClimateEntity):
_attr_min_temp = 5
_attr_has_entity_name = True
_attr_name = None
- _enable_turn_on_off_backwards_compatibility = False
def __init__(
self,
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_pm/manifest.json b/homeassistant/components/serial_pm/manifest.json
index 9b61cb3d20b..25b3e61f93d 100644
--- a/homeassistant/components/serial_pm/manifest.json
+++ b/homeassistant/components/serial_pm/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/serial_pm",
"iot_class": "local_polling",
"loggers": ["pmsensor"],
+ "quality_scale": "legacy",
"requirements": ["pmsensor==0.4"]
}
diff --git a/homeassistant/components/sesame/manifest.json b/homeassistant/components/sesame/manifest.json
index d2204629cde..7ed370db082 100644
--- a/homeassistant/components/sesame/manifest.json
+++ b/homeassistant/components/sesame/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/sesame",
"iot_class": "cloud_polling",
"loggers": ["pysesame2"],
+ "quality_scale": "legacy",
"requirements": ["pysesame2==1.0.1"]
}
diff --git a/homeassistant/components/seven_segments/manifest.json b/homeassistant/components/seven_segments/manifest.json
index 2f39644d6d3..cdc3b16f95d 100644
--- a/homeassistant/components/seven_segments/manifest.json
+++ b/homeassistant/components/seven_segments/manifest.json
@@ -4,5 +4,6 @@
"codeowners": ["@fabaff"],
"documentation": "https://www.home-assistant.io/integrations/seven_segments",
"iot_class": "local_polling",
- "requirements": ["Pillow==10.4.0"]
+ "quality_scale": "legacy",
+ "requirements": ["Pillow==11.1.0"]
}
diff --git a/homeassistant/components/sharkiq/vacuum.py b/homeassistant/components/sharkiq/vacuum.py
index 8f0547980c3..873d3fbd290 100644
--- a/homeassistant/components/sharkiq/vacuum.py
+++ b/homeassistant/components/sharkiq/vacuum.py
@@ -9,12 +9,8 @@ from sharkiq import OperatingModes, PowerModes, Properties, SharkIqVacuum
import voluptuous as vol
from homeassistant.components.vacuum import (
- STATE_CLEANING,
- STATE_DOCKED,
- STATE_IDLE,
- STATE_PAUSED,
- STATE_RETURNING,
StateVacuumEntity,
+ VacuumActivity,
VacuumEntityFeature,
)
from homeassistant.config_entries import ConfigEntry
@@ -30,10 +26,10 @@ from .const import DOMAIN, LOGGER, SERVICE_CLEAN_ROOM, SHARK
from .coordinator import SharkIqUpdateCoordinator
OPERATING_STATE_MAP = {
- OperatingModes.PAUSE: STATE_PAUSED,
- OperatingModes.START: STATE_CLEANING,
- OperatingModes.STOP: STATE_IDLE,
- OperatingModes.RETURN: STATE_RETURNING,
+ OperatingModes.PAUSE: VacuumActivity.PAUSED,
+ OperatingModes.START: VacuumActivity.CLEANING,
+ OperatingModes.STOP: VacuumActivity.IDLE,
+ OperatingModes.RETURN: VacuumActivity.RETURNING,
}
FAN_SPEEDS_MAP = {
@@ -150,19 +146,13 @@ class SharkVacuumEntity(CoordinatorEntity[SharkIqUpdateCoordinator], StateVacuum
return None
return self.sharkiq.error_text
- @property
- def operating_mode(self) -> str | None:
- """Operating mode."""
- op_mode = self.sharkiq.get_property_value(Properties.OPERATING_MODE)
- return OPERATING_STATE_MAP.get(op_mode)
-
@property
def recharging_to_resume(self) -> int | None:
"""Return True if vacuum set to recharge and resume cleaning."""
return self.sharkiq.get_property_value(Properties.RECHARGING_TO_RESUME)
@property
- def state(self) -> str | None:
+ def activity(self) -> VacuumActivity | None:
"""Get the current vacuum state.
NB: Currently, we do not return an error state because they can be very, very stale.
@@ -170,8 +160,9 @@ class SharkVacuumEntity(CoordinatorEntity[SharkIqUpdateCoordinator], StateVacuum
user a notification.
"""
if self.sharkiq.get_property_value(Properties.CHARGING_STATUS):
- return STATE_DOCKED
- return self.operating_mode
+ return VacuumActivity.DOCKED
+ op_mode = self.sharkiq.get_property_value(Properties.OPERATING_MODE)
+ return OPERATING_STATE_MAP.get(op_mode)
@property
def available(self) -> bool:
diff --git a/homeassistant/components/shelly/climate.py b/homeassistant/components/shelly/climate.py
index b77f45afb3f..842abc5ecc4 100644
--- a/homeassistant/components/shelly/climate.py
+++ b/homeassistant/components/shelly/climate.py
@@ -172,7 +172,6 @@ class BlockSleepingClimate(
)
_attr_target_temperature_step = SHTRV_01_TEMPERATURE_SETTINGS["step"]
_attr_temperature_unit = UnitOfTemperature.CELSIUS
- _enable_turn_on_off_backwards_compatibility = False
def __init__(
self,
@@ -456,7 +455,6 @@ class RpcClimate(ShellyRpcEntity, ClimateEntity):
)
_attr_target_temperature_step = RPC_THERMOSTAT_SETTINGS["step"]
_attr_temperature_unit = UnitOfTemperature.CELSIUS
- _enable_turn_on_off_backwards_compatibility = False
def __init__(self, coordinator: ShellyRpcCoordinator, id_: int) -> None:
"""Initialize."""
diff --git a/homeassistant/components/shelly/config_flow.py b/homeassistant/components/shelly/config_flow.py
index 1daa4710f30..55686464637 100644
--- a/homeassistant/components/shelly/config_flow.py
+++ b/homeassistant/components/shelly/config_flow.py
@@ -12,6 +12,7 @@ from aioshelly.exceptions import (
CustomPortNotSupported,
DeviceConnectionError,
InvalidAuthError,
+ MacAddressMismatchError,
)
from aioshelly.rpc_device import RpcDevice
import voluptuous as vol
@@ -176,6 +177,8 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN):
)
except DeviceConnectionError:
errors["base"] = "cannot_connect"
+ except MacAddressMismatchError:
+ errors["base"] = "mac_address_mismatch"
except CustomPortNotSupported:
errors["base"] = "custom_port_not_supported"
except Exception: # noqa: BLE001
@@ -215,6 +218,8 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN):
errors["base"] = "invalid_auth"
except DeviceConnectionError:
errors["base"] = "cannot_connect"
+ except MacAddressMismatchError:
+ errors["base"] = "mac_address_mismatch"
except Exception: # noqa: BLE001
LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
@@ -378,6 +383,8 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN):
await validate_input(self.hass, host, port, info, user_input)
except (DeviceConnectionError, InvalidAuthError):
return self.async_abort(reason="reauth_unsuccessful")
+ except MacAddressMismatchError:
+ return self.async_abort(reason="mac_address_mismatch")
return self.async_update_reload_and_abort(
reauth_entry, data_updates=user_input
diff --git a/homeassistant/components/shelly/coordinator.py b/homeassistant/components/shelly/coordinator.py
index 6332e139244..8273c7626eb 100644
--- a/homeassistant/components/shelly/coordinator.py
+++ b/homeassistant/components/shelly/coordinator.py
@@ -11,7 +11,12 @@ from typing import Any, cast
from aioshelly.ble import async_ensure_ble_enabled, async_stop_scanner
from aioshelly.block_device import BlockDevice, BlockUpdateType
from aioshelly.const import MODEL_NAMES, MODEL_VALVE
-from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError, RpcCallError
+from aioshelly.exceptions import (
+ DeviceConnectionError,
+ InvalidAuthError,
+ MacAddressMismatchError,
+ RpcCallError,
+)
from aioshelly.rpc_device import RpcDevice, RpcUpdateType
from propcache import cached_property
@@ -173,7 +178,7 @@ class ShellyCoordinatorBase[_DeviceT: BlockDevice | RpcDevice](
try:
await self.device.initialize()
update_device_fw_info(self.hass, self.device, self.entry)
- except DeviceConnectionError as err:
+ except (DeviceConnectionError, MacAddressMismatchError) as err:
LOGGER.debug(
"Error connecting to Shelly device %s, error: %r", self.name, err
)
@@ -366,7 +371,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()
@@ -450,8 +455,8 @@ class ShellyRestCoordinator(ShellyCoordinatorBase[BlockDevice]):
if self.device.status["uptime"] > 2 * REST_SENSORS_UPDATE_INTERVAL:
return
await self.device.update_shelly()
- except DeviceConnectionError as err:
- raise UpdateFailed(f"Error fetching data: {err!r}") from err
+ except (DeviceConnectionError, MacAddressMismatchError) as err:
+ raise UpdateFailed(repr(err)) from err
except InvalidAuthError:
await self.async_shutdown_device_and_start_reauth()
else:
@@ -603,7 +608,7 @@ class ShellyRpcCoordinator(ShellyCoordinatorBase[RpcDevice]):
async def _async_update_data(self) -> None:
"""Fetch data."""
- if self.update_sleep_period():
+ if self.update_sleep_period() or self.hass.is_stopping:
return
if self.sleep_period:
diff --git a/homeassistant/components/shelly/manifest.json b/homeassistant/components/shelly/manifest.json
index 38437fb2137..29c8fd4c369 100644
--- a/homeassistant/components/shelly/manifest.json
+++ b/homeassistant/components/shelly/manifest.json
@@ -8,8 +8,7 @@
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["aioshelly"],
- "quality_scale": "platinum",
- "requirements": ["aioshelly==12.0.1"],
+ "requirements": ["aioshelly==12.2.0"],
"zeroconf": [
{
"type": "_http._tcp.local.",
diff --git a/homeassistant/components/shelly/sensor.py b/homeassistant/components/shelly/sensor.py
index dd0ace9a6b9..03ce080db8e 100644
--- a/homeassistant/components/shelly/sensor.py
+++ b/homeassistant/components/shelly/sensor.py
@@ -1116,6 +1116,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",
diff --git a/homeassistant/components/shelly/strings.json b/homeassistant/components/shelly/strings.json
index 342a7418b2a..eb869b54e4c 100644
--- a/homeassistant/components/shelly/strings.json
+++ b/homeassistant/components/shelly/strings.json
@@ -45,7 +45,8 @@
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"unknown": "[%key:common::config_flow::error::unknown%]",
"firmware_not_fully_provisioned": "Device not fully provisioned. Please contact Shelly support",
- "custom_port_not_supported": "Gen1 device does not support custom port."
+ "custom_port_not_supported": "Gen1 device does not support custom port.",
+ "mac_address_mismatch": "The MAC address of the device does not match the one in the configuration, please reboot the device and try again."
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
@@ -53,7 +54,8 @@
"reauth_unsuccessful": "Re-authentication was unsuccessful, please remove the integration and set it up again.",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
"another_device": "Re-configuration was unsuccessful, the IP address/hostname of another Shelly device was used.",
- "ipv6_not_supported": "IPv6 is not supported."
+ "ipv6_not_supported": "IPv6 is not supported.",
+ "mac_address_mismatch": "[%key:component::shelly::config::error::mac_address_mismatch%]"
}
},
"device_automation": {
diff --git a/homeassistant/components/shodan/manifest.json b/homeassistant/components/shodan/manifest.json
index 9155311a2ad..afd75e3fed5 100644
--- a/homeassistant/components/shodan/manifest.json
+++ b/homeassistant/components/shodan/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/shodan",
"iot_class": "cloud_polling",
"loggers": ["shodan"],
+ "quality_scale": "legacy",
"requirements": ["shodan==1.28.0"]
}
diff --git a/homeassistant/components/shopping_list/__init__.py b/homeassistant/components/shopping_list/__init__.py
index 20d3078228c..531bbf37980 100644
--- a/homeassistant/components/shopping_list/__init__.py
+++ b/homeassistant/components/shopping_list/__init__.py
@@ -320,15 +320,15 @@ class ShoppingData:
# Remove the item from mapping after it's appended in the result array.
del all_items_mapping[item_id]
# Append the rest of the items
- for key in all_items_mapping:
+ for value in all_items_mapping.values():
# All the unchecked items must be passed in the item_ids array,
# so all items left in the mapping should be checked items.
- if all_items_mapping[key]["complete"] is False:
+ if value["complete"] is False:
raise vol.Invalid(
"The item ids array doesn't contain all the unchecked shopping list"
" items."
)
- new_items.append(all_items_mapping[key])
+ new_items.append(value)
self.items = new_items
self.hass.async_add_executor_job(self.save)
self._async_notify()
diff --git a/homeassistant/components/shopping_list/strings.json b/homeassistant/components/shopping_list/strings.json
index c184a1d2227..8618d9241b4 100644
--- a/homeassistant/components/shopping_list/strings.json
+++ b/homeassistant/components/shopping_list/strings.json
@@ -62,7 +62,7 @@
},
"clear_completed_items": {
"name": "Clear completed items",
- "description": "Clears completed items from the shopping list."
+ "description": "Removes completed items from the shopping list."
},
"sort": {
"name": "Sort all items",
diff --git a/homeassistant/components/sigfox/manifest.json b/homeassistant/components/sigfox/manifest.json
index 3b581e4a081..f3f44bf8979 100644
--- a/homeassistant/components/sigfox/manifest.json
+++ b/homeassistant/components/sigfox/manifest.json
@@ -3,5 +3,6 @@
"name": "Sigfox",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/sigfox",
- "iot_class": "cloud_polling"
+ "iot_class": "cloud_polling",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/sighthound/manifest.json b/homeassistant/components/sighthound/manifest.json
index 875c98acb6d..e1226fd344d 100644
--- a/homeassistant/components/sighthound/manifest.json
+++ b/homeassistant/components/sighthound/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/sighthound",
"iot_class": "cloud_polling",
"loggers": ["simplehound"],
- "requirements": ["Pillow==10.4.0", "simplehound==0.3"]
+ "quality_scale": "legacy",
+ "requirements": ["Pillow==11.1.0", "simplehound==0.3"]
}
diff --git a/homeassistant/components/signal_messenger/manifest.json b/homeassistant/components/signal_messenger/manifest.json
index 217109bfa2c..5ff63052691 100644
--- a/homeassistant/components/signal_messenger/manifest.json
+++ b/homeassistant/components/signal_messenger/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/signal_messenger",
"iot_class": "cloud_push",
"loggers": ["pysignalclirestapi"],
+ "quality_scale": "legacy",
"requirements": ["pysignalclirestapi==0.3.24"]
}
diff --git a/homeassistant/components/simplisafe/__init__.py b/homeassistant/components/simplisafe/__init__.py
index b72519f9734..2f19c5117a4 100644
--- a/homeassistant/components/simplisafe/__init__.py
+++ b/homeassistant/components/simplisafe/__init__.py
@@ -485,7 +485,7 @@ class SimpliSafe:
except Exception as err: # noqa: BLE001
LOGGER.error("Unknown exception while connecting to websocket: %s", err)
- LOGGER.warning("Reconnecting to websocket")
+ LOGGER.debug("Reconnecting to websocket")
await self._async_cancel_websocket_loop()
self._websocket_reconnect_task = self._hass.async_create_task(
self._async_start_websocket_loop()
diff --git a/homeassistant/components/simulated/__init__.py b/homeassistant/components/simulated/__init__.py
deleted file mode 100644
index 35c6d106d03..00000000000
--- a/homeassistant/components/simulated/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-"""The simulated component."""
diff --git a/homeassistant/components/simulated/manifest.json b/homeassistant/components/simulated/manifest.json
deleted file mode 100644
index e76bf142086..00000000000
--- a/homeassistant/components/simulated/manifest.json
+++ /dev/null
@@ -1,8 +0,0 @@
-{
- "domain": "simulated",
- "name": "Simulated",
- "codeowners": [],
- "documentation": "https://www.home-assistant.io/integrations/simulated",
- "iot_class": "local_polling",
- "quality_scale": "internal"
-}
diff --git a/homeassistant/components/simulated/sensor.py b/homeassistant/components/simulated/sensor.py
deleted file mode 100644
index 22ce4bd7cea..00000000000
--- a/homeassistant/components/simulated/sensor.py
+++ /dev/null
@@ -1,175 +0,0 @@
-"""Adds a simulated sensor."""
-
-from __future__ import annotations
-
-from datetime import datetime
-import math
-from random import Random
-
-import voluptuous as vol
-
-from homeassistant.components.sensor import (
- PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA,
- SensorEntity,
-)
-from homeassistant.const import CONF_NAME
-from homeassistant.core import HomeAssistant
-from homeassistant.helpers import issue_registry as ir
-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
-
-CONF_AMP = "amplitude"
-CONF_FWHM = "spread"
-CONF_MEAN = "mean"
-CONF_PERIOD = "period"
-CONF_PHASE = "phase"
-CONF_SEED = "seed"
-CONF_UNIT = "unit"
-CONF_RELATIVE_TO_EPOCH = "relative_to_epoch"
-
-DEFAULT_AMP = 1
-DEFAULT_FWHM = 0
-DEFAULT_MEAN = 0
-DEFAULT_NAME = "simulated"
-DEFAULT_PERIOD = 60
-DEFAULT_PHASE = 0
-DEFAULT_SEED = 999
-DEFAULT_UNIT = "value"
-DEFAULT_RELATIVE_TO_EPOCH = True
-
-DOMAIN = "simulated"
-
-PLATFORM_SCHEMA = SENSOR_PLATFORM_SCHEMA.extend(
- {
- vol.Optional(CONF_AMP, default=DEFAULT_AMP): vol.Coerce(float),
- vol.Optional(CONF_FWHM, default=DEFAULT_FWHM): vol.Coerce(float),
- vol.Optional(CONF_MEAN, default=DEFAULT_MEAN): vol.Coerce(float),
- vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
- vol.Optional(CONF_PERIOD, default=DEFAULT_PERIOD): cv.positive_int,
- vol.Optional(CONF_PHASE, default=DEFAULT_PHASE): vol.Coerce(float),
- vol.Optional(CONF_SEED, default=DEFAULT_SEED): cv.positive_int,
- vol.Optional(CONF_UNIT, default=DEFAULT_UNIT): cv.string,
- vol.Optional(
- CONF_RELATIVE_TO_EPOCH, default=DEFAULT_RELATIVE_TO_EPOCH
- ): cv.boolean,
- }
-)
-
-
-async def async_setup_platform(
- hass: HomeAssistant,
- config: ConfigType,
- async_add_entities: AddEntitiesCallback,
- discovery_info: DiscoveryInfoType | None = None,
-) -> None:
- """Set up the simulated sensor."""
- # Simulated has been deprecated and will be removed in 2025.1
-
- ir.async_create_issue(
- hass,
- DOMAIN,
- DOMAIN,
- breaks_in_ha_version="2025.1.0",
- is_fixable=False,
- severity=ir.IssueSeverity.WARNING,
- translation_key="simulated_deprecation",
- translation_placeholders={"integration": DOMAIN},
- learn_more_url="https://www.home-assistant.io/integrations/simulated",
- )
-
- name = config.get(CONF_NAME)
- unit = config.get(CONF_UNIT)
- amp = config.get(CONF_AMP)
- mean = config.get(CONF_MEAN)
- period = config.get(CONF_PERIOD)
- phase = config.get(CONF_PHASE)
- fwhm = config.get(CONF_FWHM)
- seed = config.get(CONF_SEED)
- relative_to_epoch = config.get(CONF_RELATIVE_TO_EPOCH)
-
- sensor = SimulatedSensor(
- name, unit, amp, mean, period, phase, fwhm, seed, relative_to_epoch
- )
- async_add_entities([sensor], True)
-
-
-class SimulatedSensor(SensorEntity):
- """Class for simulated sensor."""
-
- _attr_icon = "mdi:chart-line"
-
- def __init__(
- self, name, unit, amp, mean, period, phase, fwhm, seed, relative_to_epoch
- ):
- """Init the class."""
- self._name = name
- self._unit = unit
- self._amp = amp
- self._mean = mean
- self._period = period
- self._phase = phase # phase in degrees
- self._fwhm = fwhm
- self._seed = seed
- self._random = Random(seed) # A local seeded Random
- self._start_time = (
- datetime(1970, 1, 1, tzinfo=dt_util.UTC)
- if relative_to_epoch
- else dt_util.utcnow()
- )
- self._relative_to_epoch = relative_to_epoch
- self._state = None
-
- def time_delta(self):
- """Return the time delta."""
- dt0 = self._start_time
- dt1 = dt_util.utcnow()
- return dt1 - dt0
-
- def signal_calc(self):
- """Calculate the signal."""
- mean = self._mean
- amp = self._amp
- time_delta = self.time_delta().total_seconds() * 1e6 # to milliseconds
- period = self._period * 1e6 # to milliseconds
- fwhm = self._fwhm / 2
- phase = math.radians(self._phase)
- if period == 0:
- periodic = 0
- else:
- periodic = amp * (math.sin((2 * math.pi * time_delta / period) + phase))
- noise = self._random.gauss(mu=0, sigma=fwhm)
- return round(mean + periodic + noise, 3)
-
- async def async_update(self) -> None:
- """Update the sensor."""
- self._state = self.signal_calc()
-
- @property
- def name(self):
- """Return the name of the sensor."""
- return self._name
-
- @property
- def native_value(self):
- """Return the state of the sensor."""
- return self._state
-
- @property
- def native_unit_of_measurement(self):
- """Return the unit this state is expressed in."""
- return self._unit
-
- @property
- def extra_state_attributes(self):
- """Return other details about the sensor state."""
- return {
- "amplitude": self._amp,
- "mean": self._mean,
- "period": self._period,
- "phase": self._phase,
- "spread": self._fwhm,
- "seed": self._seed,
- "relative_to_epoch": self._relative_to_epoch,
- }
diff --git a/homeassistant/components/simulated/strings.json b/homeassistant/components/simulated/strings.json
deleted file mode 100644
index d25a84f48a5..00000000000
--- a/homeassistant/components/simulated/strings.json
+++ /dev/null
@@ -1,8 +0,0 @@
-{
- "issues": {
- "simulated_deprecation": {
- "description": "The {integration} integration is deprecated",
- "title": "The {integration} integration has been deprecated and will be removed in 2025.1. Please remove the {integration} from your configuration.yaml settings and restart Home Assistant to fix this issue."
- }
- }
-}
diff --git a/homeassistant/components/sinch/manifest.json b/homeassistant/components/sinch/manifest.json
index 21a80f63b1f..4af90b759ee 100644
--- a/homeassistant/components/sinch/manifest.json
+++ b/homeassistant/components/sinch/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/sinch",
"iot_class": "cloud_push",
"loggers": ["clx"],
+ "quality_scale": "legacy",
"requirements": ["clx-sdk-xms==1.0.0"]
}
diff --git a/homeassistant/components/siren/__init__.py b/homeassistant/components/siren/__init__.py
index 91456d6fa3b..9ce6898fd93 100644
--- a/homeassistant/components/siren/__init__.py
+++ b/homeassistant/components/siren/__init__.py
@@ -3,7 +3,6 @@
from __future__ import annotations
from datetime import timedelta
-from functools import partial
import logging
from typing import Any, TypedDict, cast, final
@@ -14,22 +13,12 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import SERVICE_TOGGLE, SERVICE_TURN_OFF, SERVICE_TURN_ON
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.helpers import config_validation as cv
-from homeassistant.helpers.deprecation import (
- all_with_deprecated_constants,
- check_if_deprecated_constant,
- dir_with_deprecated_constants,
-)
from homeassistant.helpers.entity import ToggleEntity, ToggleEntityDescription
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.typing import ConfigType, VolDictType
from homeassistant.util.hass_dict import HassKey
-from .const import ( # noqa: F401
- _DEPRECATED_SUPPORT_DURATION,
- _DEPRECATED_SUPPORT_TONES,
- _DEPRECATED_SUPPORT_TURN_OFF,
- _DEPRECATED_SUPPORT_TURN_ON,
- _DEPRECATED_SUPPORT_VOLUME_SET,
+from .const import (
ATTR_AVAILABLE_TONES,
ATTR_DURATION,
ATTR_TONE,
@@ -202,19 +191,4 @@ class SirenEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
@cached_property
def supported_features(self) -> SirenEntityFeature:
"""Return the list of supported features."""
- features = self._attr_supported_features
- if type(features) is int: # noqa: E721
- new_features = SirenEntityFeature(features)
- self._report_deprecated_supported_features_values(new_features)
- return new_features
- return features
-
-
-# As we import deprecated constants from the const module, we need to add these two functions
-# otherwise this module will be logged for using deprecated constants and not the custom component
-# 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())
+ return self._attr_supported_features
diff --git a/homeassistant/components/siren/const.py b/homeassistant/components/siren/const.py
index 9e46d8dc997..26a158bd8ea 100644
--- a/homeassistant/components/siren/const.py
+++ b/homeassistant/components/siren/const.py
@@ -1,16 +1,8 @@
"""Constants for the siren component."""
from enum import IntFlag
-from functools import partial
from typing import Final
-from homeassistant.helpers.deprecation import (
- DeprecatedConstantEnum,
- all_with_deprecated_constants,
- check_if_deprecated_constant,
- dir_with_deprecated_constants,
-)
-
DOMAIN: Final = "siren"
ATTR_TONE: Final = "tone"
@@ -28,29 +20,3 @@ class SirenEntityFeature(IntFlag):
TONES = 4
VOLUME_SET = 8
DURATION = 16
-
-
-# These constants are deprecated as of Home Assistant 2022.5
-# Please use the SirenEntityFeature enum instead.
-_DEPRECATED_SUPPORT_TURN_ON: Final = DeprecatedConstantEnum(
- SirenEntityFeature.TURN_ON, "2025.1"
-)
-_DEPRECATED_SUPPORT_TURN_OFF: Final = DeprecatedConstantEnum(
- SirenEntityFeature.TURN_OFF, "2025.1"
-)
-_DEPRECATED_SUPPORT_TONES: Final = DeprecatedConstantEnum(
- SirenEntityFeature.TONES, "2025.1"
-)
-_DEPRECATED_SUPPORT_VOLUME_SET: Final = DeprecatedConstantEnum(
- SirenEntityFeature.VOLUME_SET, "2025.1"
-)
-_DEPRECATED_SUPPORT_DURATION: Final = DeprecatedConstantEnum(
- SirenEntityFeature.DURATION, "2025.1"
-)
-
-# 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/sisyphus/manifest.json b/homeassistant/components/sisyphus/manifest.json
index 4e344c0b25e..f62d19b77c1 100644
--- a/homeassistant/components/sisyphus/manifest.json
+++ b/homeassistant/components/sisyphus/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/sisyphus",
"iot_class": "local_push",
"loggers": ["sisyphus_control"],
+ "quality_scale": "legacy",
"requirements": ["sisyphus-control==3.1.4"]
}
diff --git a/homeassistant/components/sky_hub/manifest.json b/homeassistant/components/sky_hub/manifest.json
index 541cc6e0b03..1030da4d0ff 100644
--- a/homeassistant/components/sky_hub/manifest.json
+++ b/homeassistant/components/sky_hub/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/sky_hub",
"iot_class": "local_polling",
"loggers": ["pyskyqhub"],
+ "quality_scale": "legacy",
"requirements": ["pyskyqhub==0.1.4"]
}
diff --git a/homeassistant/components/sky_remote/__init__.py b/homeassistant/components/sky_remote/__init__.py
new file mode 100644
index 00000000000..4daad78c558
--- /dev/null
+++ b/homeassistant/components/sky_remote/__init__.py
@@ -0,0 +1,39 @@
+"""The Sky Remote Control integration."""
+
+import logging
+
+from skyboxremote import RemoteControl, SkyBoxConnectionError
+
+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
+
+PLATFORMS = [Platform.REMOTE]
+
+_LOGGER = logging.getLogger(__name__)
+
+
+type SkyRemoteConfigEntry = ConfigEntry[RemoteControl]
+
+
+async def async_setup_entry(hass: HomeAssistant, entry: SkyRemoteConfigEntry) -> bool:
+ """Set up Sky remote."""
+ host = entry.data[CONF_HOST]
+ port = entry.data[CONF_PORT]
+
+ _LOGGER.debug("Setting up Host: %s, Port: %s", host, port)
+ remote = RemoteControl(host, port)
+ try:
+ await remote.check_connectable()
+ except SkyBoxConnectionError as e:
+ raise ConfigEntryNotReady from e
+
+ entry.runtime_data = remote
+ 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/sky_remote/config_flow.py b/homeassistant/components/sky_remote/config_flow.py
new file mode 100644
index 00000000000..a55dfb2a52b
--- /dev/null
+++ b/homeassistant/components/sky_remote/config_flow.py
@@ -0,0 +1,64 @@
+"""Config flow for sky_remote."""
+
+import logging
+from typing import Any
+
+from skyboxremote import RemoteControl, SkyBoxConnectionError
+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 .const import DEFAULT_PORT, DOMAIN, LEGACY_PORT
+
+DATA_SCHEMA = vol.Schema(
+ {
+ vol.Required(CONF_HOST): cv.string,
+ }
+)
+
+
+async def async_find_box_port(host: str) -> int:
+ """Find port box uses for communication."""
+ logging.debug("Attempting to find port to connect to %s on", host)
+ remote = RemoteControl(host, DEFAULT_PORT)
+ try:
+ await remote.check_connectable()
+ except SkyBoxConnectionError:
+ # Try legacy port if the default one failed
+ remote = RemoteControl(host, LEGACY_PORT)
+ await remote.check_connectable()
+ return LEGACY_PORT
+ return DEFAULT_PORT
+
+
+class SkyRemoteConfigFlow(ConfigFlow, domain=DOMAIN):
+ """Handle a config flow for Sky Remote."""
+
+ VERSION = 1
+ MINOR_VERSION = 1
+
+ async def async_step_user(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Handle the user step."""
+
+ errors: dict[str, str] = {}
+ if user_input is not None:
+ logging.debug("user_input: %s", user_input)
+ self._async_abort_entries_match(user_input)
+ try:
+ port = await async_find_box_port(user_input[CONF_HOST])
+ except SkyBoxConnectionError:
+ logging.exception("while finding port of skybox")
+ errors["base"] = "cannot_connect"
+ else:
+ return self.async_create_entry(
+ title=user_input[CONF_HOST],
+ data={**user_input, CONF_PORT: port},
+ )
+
+ return self.async_show_form(
+ step_id="user", data_schema=DATA_SCHEMA, errors=errors
+ )
diff --git a/homeassistant/components/sky_remote/const.py b/homeassistant/components/sky_remote/const.py
new file mode 100644
index 00000000000..e67744a741b
--- /dev/null
+++ b/homeassistant/components/sky_remote/const.py
@@ -0,0 +1,6 @@
+"""Constants."""
+
+DOMAIN = "sky_remote"
+
+DEFAULT_PORT = 49160
+LEGACY_PORT = 5900
diff --git a/homeassistant/components/sky_remote/manifest.json b/homeassistant/components/sky_remote/manifest.json
new file mode 100644
index 00000000000..b00ff309b10
--- /dev/null
+++ b/homeassistant/components/sky_remote/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "sky_remote",
+ "name": "Sky Remote Control",
+ "codeowners": ["@dunnmj", "@saty9"],
+ "config_flow": true,
+ "documentation": "https://www.home-assistant.io/integrations/sky_remote",
+ "integration_type": "device",
+ "iot_class": "assumed_state",
+ "requirements": ["skyboxremote==0.0.6"]
+}
diff --git a/homeassistant/components/sky_remote/remote.py b/homeassistant/components/sky_remote/remote.py
new file mode 100644
index 00000000000..05a464f73a6
--- /dev/null
+++ b/homeassistant/components/sky_remote/remote.py
@@ -0,0 +1,70 @@
+"""Home Assistant integration to control a sky box using the remote platform."""
+
+from collections.abc import Iterable
+import logging
+from typing import Any
+
+from skyboxremote import VALID_KEYS, RemoteControl
+
+from homeassistant.components.remote import RemoteEntity
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import ServiceValidationError
+from homeassistant.helpers.device_registry import DeviceInfo
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+
+from . import SkyRemoteConfigEntry
+from .const import DOMAIN
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ config: SkyRemoteConfigEntry,
+ async_add_entities: AddEntitiesCallback,
+) -> None:
+ """Set up the Sky remote platform."""
+ async_add_entities(
+ [SkyRemote(config.runtime_data, config.entry_id)],
+ True,
+ )
+
+
+class SkyRemote(RemoteEntity):
+ """Representation of a Sky Remote."""
+
+ _attr_has_entity_name = True
+ _attr_name = None
+
+ def __init__(self, remote: RemoteControl, unique_id: str) -> None:
+ """Initialize the Sky Remote."""
+ self._remote = remote
+ self._attr_unique_id = unique_id
+ self._attr_device_info = DeviceInfo(
+ identifiers={(DOMAIN, unique_id)},
+ manufacturer="SKY",
+ model="Sky Box",
+ name=remote.host,
+ )
+
+ def turn_on(self, activity: str | None = None, **kwargs: Any) -> None:
+ """Send the power on command."""
+ self.send_command(["sky"])
+
+ def turn_off(self, activity: str | None = None, **kwargs: Any) -> None:
+ """Send the power command."""
+ self.send_command(["power"])
+
+ def send_command(self, command: Iterable[str], **kwargs: Any) -> None:
+ """Send a list of commands to the device."""
+ for cmd in command:
+ if cmd not in VALID_KEYS:
+ raise ServiceValidationError(
+ f"{cmd} is not in Valid Keys: {VALID_KEYS}"
+ )
+ try:
+ self._remote.send_keys(command)
+ except ValueError as err:
+ _LOGGER.error("Invalid command: %s. Error: %s", command, err)
+ return
+ _LOGGER.debug("Successfully sent command %s", command)
diff --git a/homeassistant/components/sky_remote/strings.json b/homeassistant/components/sky_remote/strings.json
new file mode 100644
index 00000000000..af794490c43
--- /dev/null
+++ b/homeassistant/components/sky_remote/strings.json
@@ -0,0 +1,21 @@
+{
+ "config": {
+ "error": {
+ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
+ },
+ "abort": {
+ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
+ },
+ "step": {
+ "user": {
+ "title": "Add Sky Remote",
+ "data": {
+ "host": "[%key:common::config_flow::data::host%]"
+ },
+ "data_description": {
+ "host": "Hostname or IP address of your Sky device"
+ }
+ }
+ }
+ }
+}
diff --git a/homeassistant/components/skybeacon/manifest.json b/homeassistant/components/skybeacon/manifest.json
index deda02f64f7..379f10e8873 100644
--- a/homeassistant/components/skybeacon/manifest.json
+++ b/homeassistant/components/skybeacon/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/skybeacon",
"iot_class": "local_polling",
"loggers": ["pygatt"],
+ "quality_scale": "legacy",
"requirements": ["pygatt[GATTTOOL]==4.0.5"]
}
diff --git a/homeassistant/components/slide/manifest.json b/homeassistant/components/slide/manifest.json
index 111bc9bd7a9..2b56185efa1 100644
--- a/homeassistant/components/slide/manifest.json
+++ b/homeassistant/components/slide/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/slide",
"iot_class": "cloud_polling",
"loggers": ["goslideapi"],
+ "quality_scale": "legacy",
"requirements": ["goslide-api==0.7.0"]
}
diff --git a/homeassistant/components/slide_local/__init__.py b/homeassistant/components/slide_local/__init__.py
new file mode 100644
index 00000000000..5b4867bf337
--- /dev/null
+++ b/homeassistant/components/slide_local/__init__.py
@@ -0,0 +1,38 @@
+"""Component for the Slide local API."""
+
+from __future__ import annotations
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import Platform
+from homeassistant.core import HomeAssistant
+
+from .coordinator import SlideCoordinator
+
+PLATFORMS = [Platform.BUTTON, Platform.COVER, Platform.SWITCH]
+type SlideConfigEntry = ConfigEntry[SlideCoordinator]
+
+
+async def async_setup_entry(hass: HomeAssistant, entry: SlideConfigEntry) -> bool:
+ """Set up the slide_local integration."""
+
+ coordinator = SlideCoordinator(hass, entry)
+
+ await coordinator.async_config_entry_first_refresh()
+
+ entry.runtime_data = coordinator
+
+ await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
+
+ entry.async_on_unload(entry.add_update_listener(update_listener))
+
+ return True
+
+
+async def update_listener(hass: HomeAssistant, entry: SlideConfigEntry) -> None:
+ """Handle options update."""
+ await hass.config_entries.async_reload(entry.entry_id)
+
+
+async def async_unload_entry(hass: HomeAssistant, entry: SlideConfigEntry) -> bool:
+ """Unload a config entry."""
+ return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
diff --git a/homeassistant/components/slide_local/button.py b/homeassistant/components/slide_local/button.py
new file mode 100644
index 00000000000..795cd4f1c2e
--- /dev/null
+++ b/homeassistant/components/slide_local/button.py
@@ -0,0 +1,62 @@
+"""Support for Slide button."""
+
+from __future__ import annotations
+
+from goslideapi.goslideapi import (
+ AuthenticationFailed,
+ ClientConnectionError,
+ ClientTimeoutError,
+ DigestAuthCalcError,
+)
+
+from homeassistant.components.button import ButtonEntity
+from homeassistant.const import EntityCategory
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+
+from . import SlideConfigEntry
+from .const import DOMAIN
+from .coordinator import SlideCoordinator
+from .entity import SlideEntity
+
+PARALLEL_UPDATES = 1
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: SlideConfigEntry,
+ async_add_entities: AddEntitiesCallback,
+) -> None:
+ """Set up button for Slide platform."""
+
+ coordinator = entry.runtime_data
+
+ async_add_entities([SlideButton(coordinator)])
+
+
+class SlideButton(SlideEntity, ButtonEntity):
+ """Defines a Slide button."""
+
+ _attr_entity_category = EntityCategory.CONFIG
+ _attr_translation_key = "calibrate"
+
+ def __init__(self, coordinator: SlideCoordinator) -> None:
+ """Initialize the slide button."""
+ super().__init__(coordinator)
+ self._attr_unique_id = f"{coordinator.data["mac"]}-calibrate"
+
+ async def async_press(self) -> None:
+ """Send out a calibrate command."""
+ try:
+ await self.coordinator.slide.slide_calibrate(self.coordinator.host)
+ except (
+ ClientConnectionError,
+ AuthenticationFailed,
+ ClientTimeoutError,
+ DigestAuthCalcError,
+ ) as ex:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="calibration_error",
+ ) from ex
diff --git a/homeassistant/components/slide_local/config_flow.py b/homeassistant/components/slide_local/config_flow.py
new file mode 100644
index 00000000000..a4255f0769f
--- /dev/null
+++ b/homeassistant/components/slide_local/config_flow.py
@@ -0,0 +1,255 @@
+"""Config flow for slide_local integration."""
+
+from __future__ import annotations
+
+import logging
+from typing import Any
+
+from goslideapi.goslideapi import (
+ AuthenticationFailed,
+ ClientConnectionError,
+ ClientTimeoutError,
+ DigestAuthCalcError,
+ GoSlideLocal as SlideLocalApi,
+)
+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 . import SlideConfigEntry
+from .const import CONF_INVERT_POSITION, DOMAIN
+
+_LOGGER = logging.getLogger(__name__)
+
+
+class SlideConfigFlow(ConfigFlow, domain=DOMAIN):
+ """Handle a config flow for slide_local."""
+
+ _mac: str = ""
+ _host: str = ""
+ _api_version: int | None = None
+
+ VERSION = 1
+ MINOR_VERSION = 1
+
+ @staticmethod
+ @callback
+ def async_get_options_flow(
+ config_entry: SlideConfigEntry,
+ ) -> SlideOptionsFlowHandler:
+ """Get the options flow for this handler."""
+ return SlideOptionsFlowHandler()
+
+ async def async_test_connection(
+ self, user_input: dict[str, str | int]
+ ) -> dict[str, str]:
+ """Reusable Auth Helper."""
+ slide = SlideLocalApi()
+
+ # first test, if API version 2 is working
+ await slide.slide_add(
+ user_input[CONF_HOST],
+ user_input.get(CONF_PASSWORD, ""),
+ 2,
+ )
+
+ try:
+ result = await slide.slide_info(user_input[CONF_HOST])
+ except (ClientConnectionError, ClientTimeoutError):
+ return {"base": "cannot_connect"}
+ except (AuthenticationFailed, DigestAuthCalcError):
+ return {"base": "invalid_auth"}
+ except Exception: # noqa: BLE001
+ _LOGGER.exception("Exception occurred during connection test")
+ return {"base": "unknown"}
+
+ if result is not None:
+ self._api_version = 2
+ self._mac = format_mac(result["mac"])
+ return {}
+
+ # API version 2 is not working, try API version 1 instead
+ await slide.slide_add(
+ user_input[CONF_HOST],
+ user_input.get(CONF_PASSWORD, ""),
+ 1,
+ )
+
+ try:
+ result = await slide.slide_info(user_input[CONF_HOST])
+ except (ClientConnectionError, ClientTimeoutError):
+ return {"base": "cannot_connect"}
+ except (AuthenticationFailed, DigestAuthCalcError):
+ return {"base": "invalid_auth"}
+ except Exception: # noqa: BLE001
+ _LOGGER.exception("Exception occurred during connection test")
+ return {"base": "unknown"}
+
+ if result is None:
+ # API version 1 isn't working either
+ return {"base": "unknown"}
+
+ self._api_version = 1
+ self._mac = format_mac(result["mac"])
+
+ return {}
+
+ async def async_step_user(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Handle the user step."""
+ errors: dict[str, str] = {}
+ if user_input is not None:
+ if not (errors := await self.async_test_connection(user_input)):
+ await self.async_set_unique_id(self._mac)
+ self._abort_if_unique_id_configured()
+ user_input |= {
+ CONF_MAC: self._mac,
+ CONF_API_VERSION: self._api_version,
+ }
+
+ return self.async_create_entry(
+ title=user_input[CONF_HOST],
+ data=user_input,
+ options={CONF_INVERT_POSITION: False},
+ )
+
+ if user_input is not None and user_input.get(CONF_HOST) is not None:
+ self._host = user_input[CONF_HOST]
+
+ return self.async_show_form(
+ step_id="user",
+ data_schema=self.add_suggested_values_to_schema(
+ vol.Schema(
+ {
+ vol.Required(CONF_HOST): str,
+ vol.Optional(CONF_PASSWORD): str,
+ }
+ ),
+ {CONF_HOST: self._host},
+ ),
+ 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] = {}
+
+ if user_input is not None:
+ if not (errors := await self.async_test_connection(user_input)):
+ await self.async_set_unique_id(self._mac)
+ self._abort_if_unique_id_mismatch(
+ description_placeholders={CONF_MAC: self._mac}
+ )
+ user_input |= {
+ CONF_API_VERSION: self._api_version,
+ }
+
+ return self.async_update_reload_and_abort(
+ self._get_reconfigure_entry(),
+ data_updates=user_input,
+ )
+
+ entry: SlideConfigEntry = self._get_reconfigure_entry()
+
+ return self.async_show_form(
+ step_id="reconfigure",
+ data_schema=self.add_suggested_values_to_schema(
+ vol.Schema(
+ {
+ vol.Required(CONF_HOST): str,
+ }
+ ),
+ {
+ CONF_HOST: entry.data[CONF_HOST],
+ CONF_PASSWORD: entry.data.get(CONF_PASSWORD, ""),
+ },
+ ),
+ errors=errors,
+ )
+
+ async def async_step_zeroconf(
+ self, discovery_info: ZeroconfServiceInfo
+ ) -> ConfigFlowResult:
+ """Handle zeroconf discovery."""
+
+ # id is in the format 'slide_000000000000'
+ self._mac = format_mac(str(discovery_info.properties.get("id"))[6:])
+
+ await self.async_set_unique_id(self._mac)
+
+ ip = str(discovery_info.ip_address)
+ _LOGGER.debug("Slide device discovered, ip %s", ip)
+
+ self._abort_if_unique_id_configured({CONF_HOST: ip}, reload_on_update=True)
+
+ errors = {}
+ if errors := await self.async_test_connection(
+ {
+ CONF_HOST: ip,
+ }
+ ):
+ return self.async_abort(
+ reason="discovery_connection_failed",
+ description_placeholders={
+ "error": errors["base"],
+ },
+ )
+
+ self._host = ip
+
+ return await self.async_step_zeroconf_confirm()
+
+ async def async_step_zeroconf_confirm(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Confirm discovery."""
+
+ if user_input is not None:
+ user_input |= {
+ CONF_HOST: self._host,
+ CONF_API_VERSION: 2,
+ CONF_MAC: format_mac(self._mac),
+ }
+ return self.async_create_entry(
+ title=user_input[CONF_HOST],
+ data=user_input,
+ options={CONF_INVERT_POSITION: False},
+ )
+
+ self._set_confirm_only()
+ return self.async_show_form(
+ step_id="zeroconf_confirm",
+ description_placeholders={
+ "host": self._host,
+ },
+ )
+
+
+class SlideOptionsFlowHandler(OptionsFlow):
+ """Handle a options flow for slide_local."""
+
+ async def async_step_init(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Manage the options."""
+ if user_input is not None:
+ return self.async_create_entry(data=user_input)
+
+ return self.async_show_form(
+ step_id="init",
+ data_schema=self.add_suggested_values_to_schema(
+ vol.Schema(
+ {
+ vol.Required(CONF_INVERT_POSITION): bool,
+ }
+ ),
+ {CONF_INVERT_POSITION: self.config_entry.options[CONF_INVERT_POSITION]},
+ ),
+ )
diff --git a/homeassistant/components/slide_local/const.py b/homeassistant/components/slide_local/const.py
new file mode 100644
index 00000000000..9dc6d4ac925
--- /dev/null
+++ b/homeassistant/components/slide_local/const.py
@@ -0,0 +1,13 @@
+"""Define constants for the Slide component."""
+
+API_LOCAL = "api_local"
+ATTR_TOUCHGO = "touchgo"
+CONF_INVERT_POSITION = "invert_position"
+CONF_VERIFY_SSL = "verify_ssl"
+DOMAIN = "slide_local"
+SLIDES = "slides"
+SLIDES_LOCAL = "slides_local"
+DEFAULT_OFFSET = 0.15
+DEFAULT_RETRY = 120
+SERVICE_CALIBRATE = "calibrate"
+SERVICE_TOUCHGO = "touchgo"
diff --git a/homeassistant/components/slide_local/coordinator.py b/homeassistant/components/slide_local/coordinator.py
new file mode 100644
index 00000000000..e5311967198
--- /dev/null
+++ b/homeassistant/components/slide_local/coordinator.py
@@ -0,0 +1,112 @@
+"""DataUpdateCoordinator for slide_local integration."""
+
+from __future__ import annotations
+
+from datetime import timedelta
+import logging
+from typing import TYPE_CHECKING, Any
+
+from goslideapi.goslideapi import (
+ AuthenticationFailed,
+ ClientConnectionError,
+ ClientTimeoutError,
+ DigestAuthCalcError,
+ GoSlideLocal as SlideLocalApi,
+)
+
+from homeassistant.const import (
+ CONF_API_VERSION,
+ CONF_HOST,
+ CONF_MAC,
+ CONF_PASSWORD,
+ STATE_CLOSED,
+ STATE_CLOSING,
+ STATE_OPEN,
+ STATE_OPENING,
+)
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
+
+from .const import DEFAULT_OFFSET, DOMAIN
+
+_LOGGER = logging.getLogger(__name__)
+
+if TYPE_CHECKING:
+ from . import SlideConfigEntry
+
+
+class SlideCoordinator(DataUpdateCoordinator[dict[str, Any]]):
+ """Get and update the latest data."""
+
+ def __init__(self, hass: HomeAssistant, entry: SlideConfigEntry) -> None:
+ """Initialize the data object."""
+ super().__init__(
+ hass, _LOGGER, name="Slide", update_interval=timedelta(seconds=15)
+ )
+ self.slide = SlideLocalApi()
+ self.api_version = entry.data[CONF_API_VERSION]
+ self.mac = entry.data[CONF_MAC]
+ self.host = entry.data[CONF_HOST]
+ self.password = entry.data[CONF_PASSWORD] if self.api_version == 1 else ""
+
+ async def _async_setup(self) -> None:
+ """Do initialization logic for Slide coordinator."""
+ _LOGGER.debug("Initializing Slide coordinator")
+
+ await self.slide.slide_add(
+ self.host,
+ self.password,
+ self.api_version,
+ )
+
+ _LOGGER.debug("Slide coordinator initialized")
+
+ async def _async_update_data(self) -> dict[str, Any]:
+ """Update the data from the Slide device."""
+ _LOGGER.debug("Start data update")
+
+ try:
+ data = await self.slide.slide_info(self.host)
+ except (
+ ClientConnectionError,
+ AuthenticationFailed,
+ ClientTimeoutError,
+ DigestAuthCalcError,
+ ) as ex:
+ raise UpdateFailed(
+ translation_domain=DOMAIN,
+ translation_key="update_error",
+ ) from ex
+
+ if data is None:
+ raise UpdateFailed(
+ translation_domain=DOMAIN,
+ translation_key="update_error",
+ )
+
+ if "pos" in data:
+ if self.data is None:
+ oldpos = None
+ else:
+ oldpos = self.data.get("pos")
+
+ data["pos"] = max(0, min(1, data["pos"]))
+
+ if oldpos is None or oldpos == data["pos"]:
+ data["state"] = (
+ STATE_CLOSED if data["pos"] > (1 - DEFAULT_OFFSET) else STATE_OPEN
+ )
+ elif oldpos < data["pos"]:
+ data["state"] = (
+ STATE_CLOSED
+ if data["pos"] >= (1 - DEFAULT_OFFSET)
+ else STATE_CLOSING
+ )
+ else:
+ data["state"] = (
+ STATE_OPEN if data["pos"] <= DEFAULT_OFFSET else STATE_OPENING
+ )
+
+ _LOGGER.debug("Data successfully updated: %s", data)
+
+ return data
diff --git a/homeassistant/components/slide_local/cover.py b/homeassistant/components/slide_local/cover.py
new file mode 100644
index 00000000000..cf04f46d139
--- /dev/null
+++ b/homeassistant/components/slide_local/cover.py
@@ -0,0 +1,113 @@
+"""Support for Slide covers."""
+
+from __future__ import annotations
+
+import logging
+from typing import Any
+
+from homeassistant.components.cover import ATTR_POSITION, CoverDeviceClass, CoverEntity
+from homeassistant.const import STATE_CLOSED, STATE_CLOSING, STATE_OPENING
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+
+from . import SlideConfigEntry
+from .const import CONF_INVERT_POSITION, DEFAULT_OFFSET
+from .coordinator import SlideCoordinator
+from .entity import SlideEntity
+
+_LOGGER = logging.getLogger(__name__)
+
+PARALLEL_UPDATES = 0
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: SlideConfigEntry,
+ async_add_entities: AddEntitiesCallback,
+) -> None:
+ """Set up cover(s) for Slide platform."""
+
+ coordinator = entry.runtime_data
+
+ async_add_entities(
+ [
+ SlideCoverLocal(
+ coordinator,
+ entry,
+ )
+ ]
+ )
+
+
+class SlideCoverLocal(SlideEntity, CoverEntity):
+ """Representation of a Slide Local API cover."""
+
+ _attr_assumed_state = True
+ _attr_device_class = CoverDeviceClass.CURTAIN
+
+ def __init__(
+ self,
+ coordinator: SlideCoordinator,
+ entry: SlideConfigEntry,
+ ) -> None:
+ """Initialize the cover."""
+ super().__init__(coordinator)
+
+ self._attr_name = None
+ self.invert = entry.options[CONF_INVERT_POSITION]
+ self._attr_unique_id = coordinator.data["mac"]
+
+ @property
+ def is_opening(self) -> bool:
+ """Return if the cover is opening or not."""
+ return self.coordinator.data["state"] == STATE_OPENING
+
+ @property
+ def is_closing(self) -> bool:
+ """Return if the cover is closing or not."""
+ return self.coordinator.data["state"] == STATE_CLOSING
+
+ @property
+ def is_closed(self) -> bool:
+ """Return None if status is unknown, True if closed, else False."""
+ return self.coordinator.data["state"] == STATE_CLOSED
+
+ @property
+ def current_cover_position(self) -> int | None:
+ """Return the current position of cover shutter."""
+ pos = self.coordinator.data["pos"]
+ if pos is not None:
+ if (1 - pos) <= DEFAULT_OFFSET or pos <= DEFAULT_OFFSET:
+ pos = round(pos)
+ if not self.invert:
+ pos = 1 - pos
+ pos = int(pos * 100)
+ return pos
+
+ async def async_open_cover(self, **kwargs: Any) -> None:
+ """Open the cover."""
+ self.coordinator.data["state"] = STATE_OPENING
+ await self.coordinator.slide.slide_open(self.coordinator.host)
+
+ async def async_close_cover(self, **kwargs: Any) -> None:
+ """Close the cover."""
+ self.coordinator.data["state"] = STATE_CLOSING
+ await self.coordinator.slide.slide_close(self.coordinator.host)
+
+ async def async_stop_cover(self, **kwargs: Any) -> None:
+ """Stop the cover."""
+ await self.coordinator.slide.slide_stop(self.coordinator.host)
+
+ async def async_set_cover_position(self, **kwargs: Any) -> None:
+ """Move the cover to a specific position."""
+ position = kwargs[ATTR_POSITION] / 100
+ if not self.invert:
+ position = 1 - position
+
+ if self.coordinator.data["pos"] is not None:
+ if position > self.coordinator.data["pos"]:
+ self.coordinator.data["state"] = STATE_CLOSING
+ else:
+ self.coordinator.data["state"] = STATE_OPENING
+
+ await self.coordinator.slide.slide_set_position(self.coordinator.host, position)
diff --git a/homeassistant/components/slide_local/diagnostics.py b/homeassistant/components/slide_local/diagnostics.py
new file mode 100644
index 00000000000..2655cb5fada
--- /dev/null
+++ b/homeassistant/components/slide_local/diagnostics.py
@@ -0,0 +1,27 @@
+"""Provides diagnostics for slide_local."""
+
+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
+
+from . import SlideConfigEntry
+
+TO_REDACT = [
+ CONF_PASSWORD,
+]
+
+
+async def async_get_config_entry_diagnostics(
+ hass: HomeAssistant, config_entry: SlideConfigEntry
+) -> dict[str, Any]:
+ """Return diagnostics for a config entry."""
+ data = config_entry.runtime_data.data
+
+ return {
+ "config_entry": async_redact_data(config_entry.as_dict(), TO_REDACT),
+ "slide_data": data,
+ }
diff --git a/homeassistant/components/slide_local/entity.py b/homeassistant/components/slide_local/entity.py
new file mode 100644
index 00000000000..51269649add
--- /dev/null
+++ b/homeassistant/components/slide_local/entity.py
@@ -0,0 +1,27 @@
+"""Entities for slide_local integration."""
+
+from homeassistant.helpers import device_registry as dr
+from homeassistant.helpers.device_registry import DeviceInfo
+from homeassistant.helpers.update_coordinator import CoordinatorEntity
+
+from .coordinator import SlideCoordinator
+
+
+class SlideEntity(CoordinatorEntity[SlideCoordinator]):
+ """Base class of a Slide local API cover."""
+
+ _attr_has_entity_name = True
+
+ def __init__(self, coordinator: SlideCoordinator) -> None:
+ """Initialize the Slide device."""
+ super().__init__(coordinator)
+
+ self._attr_device_info = DeviceInfo(
+ manufacturer="Innovation in Motion",
+ connections={(dr.CONNECTION_NETWORK_MAC, coordinator.data["mac"])},
+ name=coordinator.data["device_name"],
+ sw_version=coordinator.api_version,
+ hw_version=coordinator.data["board_rev"],
+ serial_number=coordinator.data["mac"],
+ configuration_url=f"http://{coordinator.host}",
+ )
diff --git a/homeassistant/components/slide_local/icons.json b/homeassistant/components/slide_local/icons.json
new file mode 100644
index 00000000000..70d53e7f7a3
--- /dev/null
+++ b/homeassistant/components/slide_local/icons.json
@@ -0,0 +1,9 @@
+{
+ "entity": {
+ "button": {
+ "calibrate": {
+ "default": "mdi:tape-measure"
+ }
+ }
+ }
+}
diff --git a/homeassistant/components/slide_local/manifest.json b/homeassistant/components/slide_local/manifest.json
new file mode 100644
index 00000000000..7e524c54a25
--- /dev/null
+++ b/homeassistant/components/slide_local/manifest.json
@@ -0,0 +1,17 @@
+{
+ "domain": "slide_local",
+ "name": "Slide Local",
+ "codeowners": ["@dontinelli"],
+ "config_flow": true,
+ "documentation": "https://www.home-assistant.io/integrations/slide_local",
+ "integration_type": "device",
+ "iot_class": "local_polling",
+ "quality_scale": "gold",
+ "requirements": ["goslide-api==0.7.0"],
+ "zeroconf": [
+ {
+ "type": "_http._tcp.local.",
+ "name": "slide*"
+ }
+ ]
+}
diff --git a/homeassistant/components/slide_local/quality_scale.yaml b/homeassistant/components/slide_local/quality_scale.yaml
new file mode 100644
index 00000000000..0bb30ee8269
--- /dev/null
+++ b/homeassistant/components/slide_local/quality_scale.yaml
@@ -0,0 +1,75 @@
+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: No explicit event subscriptions.
+ dependency-transparency: done
+ action-setup: done
+ 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:
+ status: exempt
+ comment: |
+ The password used is the device code and can't change. No reauth required.
+ parallel-updates: done
+ test-coverage: done
+ integration-owner: done
+ docs-installation-parameters: done
+ docs-configuration-parameters: done
+
+ # Gold
+ entity-translations: done
+ entity-device-class: done
+ devices: done
+ entity-category: done
+ entity-disabled-by-default: done
+ discovery: done
+ stale-devices:
+ status: done
+ comment: |
+ Slide_local represents a single physical device, no removal stale devices required (besides removal of instance itself).
+ diagnostics: done
+ exception-translations: done
+ icon-translations: done
+ reconfiguration-flow: done
+ dynamic-devices:
+ status: exempt
+ comment: |
+ Slide_local represents a single physical device, no dynamic changes of devices possible (besides removal of instance itself).
+ discovery-update-info: done
+ repair-issues:
+ status: exempt
+ comment: No issues/repairs.
+ docs-use-cases: done
+ docs-supported-devices: done
+ docs-supported-functions: done
+ docs-data-update: done
+ docs-known-limitations: done
+ docs-troubleshooting:
+ status: exempt
+ comment: |
+ This integration doesn't have known issues that could be resolved by the user.
+ docs-examples: done
+ # Platinum
+ async-dependency: done
+ inject-websession: todo
+ strict-typing: todo
diff --git a/homeassistant/components/slide_local/strings.json b/homeassistant/components/slide_local/strings.json
new file mode 100644
index 00000000000..67514ff0d50
--- /dev/null
+++ b/homeassistant/components/slide_local/strings.json
@@ -0,0 +1,80 @@
+{
+ "config": {
+ "step": {
+ "user": {
+ "description": "Provide information to connect to the Slide device",
+ "data": {
+ "host": "[%key:common::config_flow::data::host%]",
+ "password": "[%key:common::config_flow::data::password%]"
+ },
+ "data_description": {
+ "host": "The hostname or IP address of your local Slide",
+ "password": "The device code of your Slide (inside of the Slide or in the box, length is 8 characters). If your Slide runs firmware version 2 this is optional, as it is not used by the local API."
+ }
+ },
+ "reconfigure": {
+ "description": "Reconfigure the information for your Slide device",
+ "data": {
+ "host": "[%key:common::config_flow::data::host%]",
+ "password": "[%key:common::config_flow::data::password%]"
+ },
+ "data_description": {
+ "host": "[%key:component::slide_local::config::step::user::data_description::host%]",
+ "password": "[%key:component::slide_local::config::step::user::data_description::password%]"
+ }
+ },
+ "zeroconf_confirm": {
+ "title": "Confirm setup for Slide",
+ "description": "Do you want to setup {host}?"
+ }
+ },
+ "abort": {
+ "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."
+ },
+ "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%]"
+ }
+ },
+ "options": {
+ "step": {
+ "init": {
+ "title": "Configure Slide",
+ "description": "Reconfigure the Slide device",
+ "data": {
+ "invert_position": "Invert position"
+ },
+ "data_description": {
+ "invert_position": "Inverts the position of your Slide cover."
+ }
+ }
+ }
+ },
+ "entity": {
+ "button": {
+ "calibrate": {
+ "name": "Calibrate"
+ }
+ },
+ "switch": {
+ "touchgo": {
+ "name": "TouchGo"
+ }
+ }
+ },
+ "exceptions": {
+ "calibration_error": {
+ "message": "Error while sending the calibration request to the device."
+ },
+ "touchgo_error": {
+ "message": "Error while sending the request setting Touch&Go to {state} to the device."
+ },
+ "update_error": {
+ "message": "Error while updating data from the API."
+ }
+ }
+}
diff --git a/homeassistant/components/slide_local/switch.py b/homeassistant/components/slide_local/switch.py
new file mode 100644
index 00000000000..f1c33f9a76f
--- /dev/null
+++ b/homeassistant/components/slide_local/switch.py
@@ -0,0 +1,93 @@
+"""Support for Slide switch."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from goslideapi.goslideapi import (
+ AuthenticationFailed,
+ ClientConnectionError,
+ ClientTimeoutError,
+ DigestAuthCalcError,
+)
+
+from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity
+from homeassistant.const import EntityCategory
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+
+from . import SlideConfigEntry
+from .const import DOMAIN
+from .coordinator import SlideCoordinator
+from .entity import SlideEntity
+
+PARALLEL_UPDATES = 1
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: SlideConfigEntry,
+ async_add_entities: AddEntitiesCallback,
+) -> None:
+ """Set up switch for Slide platform."""
+
+ coordinator = entry.runtime_data
+
+ async_add_entities([SlideSwitch(coordinator)])
+
+
+class SlideSwitch(SlideEntity, SwitchEntity):
+ """Defines a Slide switch."""
+
+ _attr_entity_category = EntityCategory.CONFIG
+ _attr_translation_key = "touchgo"
+ _attr_device_class = SwitchDeviceClass.SWITCH
+
+ def __init__(self, coordinator: SlideCoordinator) -> None:
+ """Initialize the slide switch."""
+ super().__init__(coordinator)
+ self._attr_unique_id = f"{coordinator.data["mac"]}-touchgo"
+
+ @property
+ def is_on(self) -> bool:
+ """Return if switch is on."""
+ return self.coordinator.data["touch_go"]
+
+ async def async_turn_off(self, **kwargs: Any) -> None:
+ """Turn off touchgo."""
+ try:
+ await self.coordinator.slide.slide_set_touchgo(self.coordinator.host, False)
+ except (
+ ClientConnectionError,
+ AuthenticationFailed,
+ ClientTimeoutError,
+ DigestAuthCalcError,
+ ) as ex:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="touchgo_error",
+ translation_placeholders={
+ "state": "off",
+ },
+ ) from ex
+ await self.coordinator.async_request_refresh()
+
+ async def async_turn_on(self, **kwargs: Any) -> None:
+ """Turn on touchgo."""
+ try:
+ await self.coordinator.slide.slide_set_touchgo(self.coordinator.host, True)
+ except (
+ ClientConnectionError,
+ AuthenticationFailed,
+ ClientTimeoutError,
+ DigestAuthCalcError,
+ ) as ex:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="touchgo_error",
+ translation_placeholders={
+ "state": "on",
+ },
+ ) from ex
+ await self.coordinator.async_request_refresh()
diff --git a/homeassistant/components/smartthings/climate.py b/homeassistant/components/smartthings/climate.py
index 073a1470c21..d9535272295 100644
--- a/homeassistant/components/smartthings/climate.py
+++ b/homeassistant/components/smartthings/climate.py
@@ -164,8 +164,6 @@ def get_capabilities(capabilities: Sequence[str]) -> Sequence[str] | None:
class SmartThingsThermostat(SmartThingsEntity, ClimateEntity):
"""Define a SmartThings climate entities."""
- _enable_turn_on_off_backwards_compatibility = False
-
def __init__(self, device):
"""Init the class."""
super().__init__(device)
@@ -347,7 +345,6 @@ class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity):
"""Define a SmartThings Air Conditioner."""
_hvac_modes: list[HVACMode]
- _enable_turn_on_off_backwards_compatibility = False
def __init__(self, device) -> None:
"""Init the class."""
diff --git a/homeassistant/components/smartthings/fan.py b/homeassistant/components/smartthings/fan.py
index 131cccdd869..61e30589273 100644
--- a/homeassistant/components/smartthings/fan.py
+++ b/homeassistant/components/smartthings/fan.py
@@ -70,7 +70,6 @@ class SmartThingsFan(SmartThingsEntity, FanEntity):
"""Define a SmartThings Fan."""
_attr_speed_count = int_states_in_range(SPEED_RANGE)
- _enable_turn_on_off_backwards_compatibility = False
def __init__(self, device):
"""Init the class."""
diff --git a/homeassistant/components/smartthings/light.py b/homeassistant/components/smartthings/light.py
index fd4b87f0ee7..eb7c9af246b 100644
--- a/homeassistant/components/smartthings/light.py
+++ b/homeassistant/components/smartthings/light.py
@@ -10,7 +10,7 @@ from pysmartthings import Capability
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
- ATTR_COLOR_TEMP,
+ ATTR_COLOR_TEMP_KELVIN,
ATTR_HS_COLOR,
ATTR_TRANSITION,
ColorMode,
@@ -21,7 +21,6 @@ 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 .const import DATA_BROKERS, DOMAIN
from .entity import SmartThingsEntity
@@ -79,12 +78,12 @@ class SmartThingsLight(SmartThingsEntity, LightEntity):
# SmartThings does not expose this attribute, instead it's
# implemented within each device-type handler. This value is the
# lowest kelvin found supported across 20+ handlers.
- _attr_max_mireds = 500 # 2000K
+ _attr_min_color_temp_kelvin = 2000 # 500 mireds
# SmartThings does not expose this attribute, instead it's
# implemented within each device-type handler. This value is the
# highest kelvin found supported across 20+ handlers.
- _attr_min_mireds = 111 # 9000K
+ _attr_max_color_temp_kelvin = 9000 # 111 mireds
def __init__(self, device):
"""Initialize a SmartThingsLight."""
@@ -122,8 +121,8 @@ class SmartThingsLight(SmartThingsEntity, LightEntity):
"""Turn the light on."""
tasks = []
# Color temperature
- if ATTR_COLOR_TEMP in kwargs:
- tasks.append(self.async_set_color_temp(kwargs[ATTR_COLOR_TEMP]))
+ if ATTR_COLOR_TEMP_KELVIN in kwargs:
+ tasks.append(self.async_set_color_temp(kwargs[ATTR_COLOR_TEMP_KELVIN]))
# Color
if ATTR_HS_COLOR in kwargs:
tasks.append(self.async_set_color(kwargs[ATTR_HS_COLOR]))
@@ -164,9 +163,7 @@ class SmartThingsLight(SmartThingsEntity, LightEntity):
)
# Color Temperature
if ColorMode.COLOR_TEMP in self._attr_supported_color_modes:
- self._attr_color_temp = color_util.color_temperature_kelvin_to_mired(
- self._device.status.color_temperature
- )
+ self._attr_color_temp_kelvin = self._device.status.color_temperature
# Color
if ColorMode.HS in self._attr_supported_color_modes:
self._attr_hs_color = (
@@ -181,10 +178,9 @@ class SmartThingsLight(SmartThingsEntity, LightEntity):
saturation = max(min(float(hs_color[1]), 100.0), 0.0)
await self._device.set_color(hue, saturation, set_status=True)
- async def async_set_color_temp(self, value: float):
+ async def async_set_color_temp(self, value: int):
"""Set the color temperature of the device."""
- kelvin = color_util.color_temperature_mired_to_kelvin(value)
- kelvin = max(min(kelvin, 30000), 1)
+ kelvin = max(min(value, 30000), 1)
await self._device.set_color_temperature(kelvin, set_status=True)
async def async_set_level(self, brightness: int, transition: int):
diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py
index b73d3b43764..8bd0421d2bc 100644
--- a/homeassistant/components/smartthings/sensor.py
+++ b/homeassistant/components/smartthings/sensor.py
@@ -15,11 +15,11 @@ from homeassistant.components.sensor import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
- AREA_SQUARE_METERS,
CONCENTRATION_PARTS_PER_MILLION,
LIGHT_LUX,
PERCENTAGE,
EntityCategory,
+ UnitOfArea,
UnitOfElectricPotential,
UnitOfEnergy,
UnitOfMass,
@@ -95,7 +95,7 @@ CAPABILITY_TO_SENSORS: dict[str, list[Map]] = {
Map(
Attribute.bmi_measurement,
"Body Mass Index",
- f"{UnitOfMass.KILOGRAMS}/{AREA_SQUARE_METERS}",
+ f"{UnitOfMass.KILOGRAMS}/{UnitOfArea.SQUARE_METERS}",
None,
SensorStateClass.MEASUREMENT,
None,
diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json
index 7fbf966fa89..de94e5adfcd 100644
--- a/homeassistant/components/smartthings/strings.json
+++ b/homeassistant/components/smartthings/strings.json
@@ -7,14 +7,14 @@
},
"pat": {
"title": "Enter Personal Access Token",
- "description": "Please enter a SmartThings [Personal Access Token]({token_url}) that has been created per the [instructions]({component_url}). This will be used to create the Home Assistant integration within your SmartThings account.",
+ "description": "Please enter a SmartThings [Personal Access Token]({token_url}) that has been created per the [instructions]({component_url}). This will be used to create the Home Assistant integration within your SmartThings account.",
"data": {
"access_token": "[%key:common::config_flow::data::access_token%]"
}
},
"select_location": {
"title": "Select Location",
- "description": "Please select the SmartThings Location you wish to add to Home Assistant. We will then open a new window and ask you to login and authorize installation of the Home Assistant integration into the selected location.",
+ "description": "Please select the SmartThings Location you wish to add to Home Assistant. We will then open a new window and ask you to login and authorize installation of the Home Assistant integration into the selected location.",
"data": { "location_id": "[%key:common::config_flow::data::location%]" }
},
"authorize": { "title": "Authorize Home Assistant" }
@@ -27,7 +27,7 @@
"token_invalid_format": "The token must be in the UID/GUID format",
"token_unauthorized": "The token is invalid or no longer authorized.",
"token_forbidden": "The token does not have the required OAuth scopes.",
- "app_setup_error": "Unable to set up the SmartApp. Please try again.",
+ "app_setup_error": "Unable to set up the SmartApp. Please try again.",
"webhook_error": "SmartThings could not validate the webhook URL. Please ensure the webhook URL is reachable from the internet and try again."
}
}
diff --git a/homeassistant/components/smarttub/climate.py b/homeassistant/components/smarttub/climate.py
index f0bb84b3390..7f3163834e0 100644
--- a/homeassistant/components/smarttub/climate.py
+++ b/homeassistant/components/smarttub/climate.py
@@ -68,7 +68,6 @@ class SmartTubThermostat(SmartTubEntity, ClimateEntity):
)
_attr_temperature_unit = UnitOfTemperature.CELSIUS
_attr_preset_modes = list(PRESET_MODES.values())
- _enable_turn_on_off_backwards_compatibility = False
def __init__(self, coordinator, spa):
"""Initialize the entity."""
diff --git a/homeassistant/components/smarttub/manifest.json b/homeassistant/components/smarttub/manifest.json
index f2514063a40..d5102f14437 100644
--- a/homeassistant/components/smarttub/manifest.json
+++ b/homeassistant/components/smarttub/manifest.json
@@ -6,6 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/smarttub",
"iot_class": "cloud_polling",
"loggers": ["smarttub"],
- "quality_scale": "platinum",
- "requirements": ["python-smarttub==0.0.36"]
+ "requirements": ["python-smarttub==0.0.38"]
}
diff --git a/homeassistant/components/smarty/__init__.py b/homeassistant/components/smarty/__init__.py
index 0e5ca216621..0d043804c3d 100644
--- a/homeassistant/components/smarty/__init__.py
+++ b/homeassistant/components/smarty/__init__.py
@@ -30,7 +30,13 @@ CONFIG_SCHEMA = vol.Schema(
extra=vol.ALLOW_EXTRA,
)
-PLATFORMS = [Platform.BINARY_SENSOR, Platform.FAN, Platform.SENSOR, Platform.SWITCH]
+PLATFORMS = [
+ Platform.BINARY_SENSOR,
+ Platform.BUTTON,
+ Platform.FAN,
+ Platform.SENSOR,
+ Platform.SWITCH,
+]
async def async_setup(hass: HomeAssistant, hass_config: ConfigType) -> bool:
diff --git a/homeassistant/components/smarty/button.py b/homeassistant/components/smarty/button.py
new file mode 100644
index 00000000000..b8e31cf6fc8
--- /dev/null
+++ b/homeassistant/components/smarty/button.py
@@ -0,0 +1,74 @@
+"""Platform to control a Salda Smarty XP/XV ventilation unit."""
+
+from __future__ import annotations
+
+from collections.abc import Callable
+from dataclasses import dataclass
+import logging
+from typing import Any
+
+from pysmarty2 import Smarty
+
+from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+
+from .coordinator import SmartyConfigEntry, SmartyCoordinator
+from .entity import SmartyEntity
+
+_LOGGER = logging.getLogger(__name__)
+
+
+@dataclass(frozen=True, kw_only=True)
+class SmartyButtonDescription(ButtonEntityDescription):
+ """Class describing Smarty button."""
+
+ press_fn: Callable[[Smarty], bool | None]
+
+
+ENTITIES: tuple[SmartyButtonDescription, ...] = (
+ SmartyButtonDescription(
+ key="reset_filters_timer",
+ translation_key="reset_filters_timer",
+ press_fn=lambda smarty: smarty.reset_filters_timer(),
+ ),
+)
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: SmartyConfigEntry,
+ async_add_entities: AddEntitiesCallback,
+) -> None:
+ """Set up the Smarty Button Platform."""
+
+ coordinator = entry.runtime_data
+
+ async_add_entities(
+ SmartyButton(coordinator, description) for description in ENTITIES
+ )
+
+
+class SmartyButton(SmartyEntity, ButtonEntity):
+ """Representation of a Smarty Button."""
+
+ entity_description: SmartyButtonDescription
+
+ def __init__(
+ self,
+ coordinator: SmartyCoordinator,
+ entity_description: SmartyButtonDescription,
+ ) -> None:
+ """Initialize the entity."""
+ super().__init__(coordinator)
+ self.entity_description = entity_description
+ self._attr_unique_id = (
+ f"{coordinator.config_entry.entry_id}_{entity_description.key}"
+ )
+
+ async def async_press(self, **kwargs: Any) -> None:
+ """Press the button."""
+ await self.hass.async_add_executor_job(
+ self.entity_description.press_fn, self.coordinator.client
+ )
+ await self.coordinator.async_refresh()
diff --git a/homeassistant/components/smarty/fan.py b/homeassistant/components/smarty/fan.py
index 378585a33e1..2804f14ee15 100644
--- a/homeassistant/components/smarty/fan.py
+++ b/homeassistant/components/smarty/fan.py
@@ -48,7 +48,6 @@ class SmartyFan(SmartyEntity, FanEntity):
| FanEntityFeature.TURN_OFF
| FanEntityFeature.TURN_ON
)
- _enable_turn_on_off_backwards_compatibility = False
def __init__(self, coordinator: SmartyCoordinator) -> None:
"""Initialize the entity."""
diff --git a/homeassistant/components/smarty/strings.json b/homeassistant/components/smarty/strings.json
index 5553a1c0135..341a300a26e 100644
--- a/homeassistant/components/smarty/strings.json
+++ b/homeassistant/components/smarty/strings.json
@@ -28,6 +28,10 @@
"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."
+ },
+ "deprecated_yaml_import_issue_cannot_connect": {
+ "title": "YAML import failed due to a connection error",
+ "description": "Configuring {integration_title} using YAML is being removed but there was a connect 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."
}
},
"entity": {
@@ -42,6 +46,11 @@
"name": "Boost state"
}
},
+ "button": {
+ "reset_filters_timer": {
+ "name": "Reset filters timer"
+ }
+ },
"sensor": {
"supply_air_temperature": {
"name": "Supply air temperature"
diff --git a/homeassistant/components/smlight/config_flow.py b/homeassistant/components/smlight/config_flow.py
index 32efc729dc2..92b543e0441 100644
--- a/homeassistant/components/smlight/config_flow.py
+++ b/homeassistant/components/smlight/config_flow.py
@@ -34,10 +34,11 @@ 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
- self.host: str | None = None
async def async_step_user(
self, user_input: dict[str, Any] | None = None
@@ -46,9 +47,8 @@ class SmlightConfigFlow(ConfigFlow, domain=DOMAIN):
errors: dict[str, str] = {}
if user_input is not None:
- host = user_input[CONF_HOST]
- self.client = Api2(host, session=async_get_clientsession(self.hass))
- self.host = host
+ self.host = user_input[CONF_HOST]
+ self.client = Api2(self.host, session=async_get_clientsession(self.hass))
try:
if not await self._async_check_auth_required(user_input):
@@ -138,9 +138,8 @@ class SmlightConfigFlow(ConfigFlow, domain=DOMAIN):
) -> ConfigFlowResult:
"""Handle reauth when API Authentication failed."""
- host = entry_data[CONF_HOST]
- self.client = Api2(host, session=async_get_clientsession(self.hass))
- self.host = host
+ self.host = entry_data[CONF_HOST]
+ self.client = Api2(self.host, session=async_get_clientsession(self.hass))
return await self.async_step_reauth_confirm()
diff --git a/homeassistant/components/smlight/manifest.json b/homeassistant/components/smlight/manifest.json
index c1eca45871b..cb791ac111b 100644
--- a/homeassistant/components/smlight/manifest.json
+++ b/homeassistant/components/smlight/manifest.json
@@ -6,7 +6,7 @@
"documentation": "https://www.home-assistant.io/integrations/smlight",
"integration_type": "device",
"iot_class": "local_push",
- "requirements": ["pysmlight==0.1.3"],
+ "requirements": ["pysmlight==0.1.4"],
"zeroconf": [
{
"type": "_slzb-06._tcp.local."
diff --git a/homeassistant/components/smtp/manifest.json b/homeassistant/components/smtp/manifest.json
index 0e0bba707ac..66954eebccc 100644
--- a/homeassistant/components/smtp/manifest.json
+++ b/homeassistant/components/smtp/manifest.json
@@ -3,5 +3,6 @@
"name": "SMTP",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/smtp",
- "iot_class": "cloud_push"
+ "iot_class": "cloud_push",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/snapcast/__init__.py b/homeassistant/components/snapcast/__init__.py
index a4163355944..b853535b525 100644
--- a/homeassistant/components/snapcast/__init__.py
+++ b/homeassistant/components/snapcast/__init__.py
@@ -1,37 +1,28 @@
"""Snapcast Integration."""
-import logging
-
-import snapcast.control
-
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_PORT
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from .const import DOMAIN, PLATFORMS
-from .server import HomeAssistantSnapcast
-
-_LOGGER = logging.getLogger(__name__)
+from .coordinator import SnapcastUpdateCoordinator
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Snapcast from a config entry."""
host = entry.data[CONF_HOST]
port = entry.data[CONF_PORT]
+ coordinator = SnapcastUpdateCoordinator(hass, host, port)
+
try:
- server = await snapcast.control.create_server(
- hass.loop, host, port, reconnect=True
- )
+ await coordinator.async_config_entry_first_refresh()
except OSError as ex:
raise ConfigEntryNotReady(
f"Could not connect to Snapcast server at {host}:{port}"
) from ex
- hass.data.setdefault(DOMAIN, {})[entry.entry_id] = HomeAssistantSnapcast(
- hass, server, f"{host}:{port}", entry.entry_id
- )
-
+ hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
diff --git a/homeassistant/components/snapcast/coordinator.py b/homeassistant/components/snapcast/coordinator.py
new file mode 100644
index 00000000000..5bb9ae4e51f
--- /dev/null
+++ b/homeassistant/components/snapcast/coordinator.py
@@ -0,0 +1,72 @@
+"""Data update coordinator for Snapcast server."""
+
+from __future__ import annotations
+
+import logging
+
+from snapcast.control.server import Snapserver
+
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
+
+_LOGGER = logging.getLogger(__name__)
+
+
+class SnapcastUpdateCoordinator(DataUpdateCoordinator[None]):
+ """Data update coordinator for pushed data from Snapcast server."""
+
+ def __init__(self, hass: HomeAssistant, host: str, port: int) -> None:
+ """Initialize coordinator."""
+ super().__init__(
+ hass,
+ logger=_LOGGER,
+ name=f"{host}:{port}",
+ update_interval=None, # Disable update interval as server pushes
+ )
+
+ self._server = Snapserver(hass.loop, host, port, True)
+ self.last_update_success = False
+
+ self._server.set_on_update_callback(self._on_update)
+ self._server.set_new_client_callback(self._on_update)
+ self._server.set_on_connect_callback(self._on_connect)
+ self._server.set_on_disconnect_callback(self._on_disconnect)
+
+ def _on_update(self) -> None:
+ """Snapserver on_update callback."""
+ # Assume availability if an update is received.
+ self.last_update_success = True
+ self.async_update_listeners()
+
+ def _on_connect(self) -> None:
+ """Snapserver on_connect callback."""
+ self.last_update_success = True
+ self.async_update_listeners()
+
+ def _on_disconnect(self, ex):
+ """Snapsever on_disconnect callback."""
+ self.async_set_update_error(ex)
+
+ async def _async_setup(self) -> None:
+ """Perform async setup for the coordinator."""
+ # Start the server
+ try:
+ await self._server.start()
+ except OSError as ex:
+ raise UpdateFailed from ex
+
+ async def _async_update_data(self) -> None:
+ """Empty update method since data is pushed."""
+
+ async def disconnect(self) -> None:
+ """Disconnect from the server."""
+ self._server.set_on_update_callback(None)
+ self._server.set_on_connect_callback(None)
+ self._server.set_on_disconnect_callback(None)
+ self._server.set_new_client_callback(None)
+ self._server.stop()
+
+ @property
+ def server(self) -> Snapserver:
+ """Get the Snapserver object."""
+ return self._server
diff --git a/homeassistant/components/snapcast/entity.py b/homeassistant/components/snapcast/entity.py
new file mode 100644
index 00000000000..cceeb6227fd
--- /dev/null
+++ b/homeassistant/components/snapcast/entity.py
@@ -0,0 +1,11 @@
+"""Coordinator entity for Snapcast server."""
+
+from __future__ import annotations
+
+from homeassistant.helpers.update_coordinator import CoordinatorEntity
+
+from .coordinator import SnapcastUpdateCoordinator
+
+
+class SnapcastCoordinatorEntity(CoordinatorEntity[SnapcastUpdateCoordinator]):
+ """Coordinator entity for Snapcast."""
diff --git a/homeassistant/components/snapcast/media_player.py b/homeassistant/components/snapcast/media_player.py
index bda411acde3..0ec27c1ad9c 100644
--- a/homeassistant/components/snapcast/media_player.py
+++ b/homeassistant/components/snapcast/media_player.py
@@ -2,18 +2,29 @@
from __future__ import annotations
-from snapcast.control.server import Snapserver
+from collections.abc import Mapping
+import logging
+from typing import Any
+
+from snapcast.control.client import Snapclient
+from snapcast.control.group import Snapgroup
import voluptuous as vol
from homeassistant.components.media_player import (
+ DOMAIN as MEDIA_PLAYER_DOMAIN,
MediaPlayerEntity,
MediaPlayerEntityFeature,
MediaPlayerState,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_PORT
-from homeassistant.core import HomeAssistant
-from homeassistant.helpers import config_validation as cv, entity_platform
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.exceptions import ServiceValidationError
+from homeassistant.helpers import (
+ config_validation as cv,
+ entity_platform,
+ entity_registry as er,
+)
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import (
@@ -30,6 +41,8 @@ from .const import (
SERVICE_SNAPSHOT,
SERVICE_UNJOIN,
)
+from .coordinator import SnapcastUpdateCoordinator
+from .entity import SnapcastCoordinatorEntity
STREAM_STATUS = {
"idle": MediaPlayerState.IDLE,
@@ -37,21 +50,23 @@ STREAM_STATUS = {
"unknown": None,
}
+_LOGGER = logging.getLogger(__name__)
-def register_services():
+
+def register_services() -> None:
"""Register snapcast services."""
platform = entity_platform.async_get_current_platform()
platform.async_register_entity_service(SERVICE_SNAPSHOT, None, "snapshot")
platform.async_register_entity_service(SERVICE_RESTORE, None, "async_restore")
platform.async_register_entity_service(
- SERVICE_JOIN, {vol.Required(ATTR_MASTER): cv.entity_id}, handle_async_join
+ SERVICE_JOIN, {vol.Required(ATTR_MASTER): cv.entity_id}, "async_join"
)
- platform.async_register_entity_service(SERVICE_UNJOIN, None, handle_async_unjoin)
+ platform.async_register_entity_service(SERVICE_UNJOIN, None, "async_unjoin")
platform.async_register_entity_service(
SERVICE_SET_LATENCY,
{vol.Required(ATTR_LATENCY): cv.positive_int},
- handle_set_latency,
+ "async_set_latency",
)
@@ -61,51 +76,103 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the snapcast config entry."""
- snapcast_server: Snapserver = hass.data[DOMAIN][config_entry.entry_id].server
+
+ # Fetch coordinator from global data
+ coordinator: SnapcastUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id]
+
+ # Create an ID for the Snapserver
+ host = config_entry.data[CONF_HOST]
+ port = config_entry.data[CONF_PORT]
+ host_id = f"{host}:{port}"
register_services()
- host = config_entry.data[CONF_HOST]
- port = config_entry.data[CONF_PORT]
- hpid = f"{host}:{port}"
+ _known_group_ids: set[str] = set()
+ _known_client_ids: set[str] = set()
- groups: list[MediaPlayerEntity] = [
- SnapcastGroupDevice(group, hpid, config_entry.entry_id)
- for group in snapcast_server.groups
- ]
- clients: list[MediaPlayerEntity] = [
- SnapcastClientDevice(client, hpid, config_entry.entry_id)
- for client in snapcast_server.clients
- ]
- async_add_entities(clients + groups)
- hass.data[DOMAIN][
- config_entry.entry_id
- ].hass_async_add_entities = async_add_entities
+ @callback
+ def _check_entities() -> None:
+ nonlocal _known_group_ids, _known_client_ids
+
+ def _update_known_ids(known_ids, ids) -> tuple[set[str], set[str]]:
+ ids_to_add = ids - known_ids
+ ids_to_remove = known_ids - ids
+
+ # Update known IDs
+ known_ids.difference_update(ids_to_remove)
+ known_ids.update(ids_to_add)
+
+ return ids_to_add, ids_to_remove
+
+ group_ids = {g.identifier for g in coordinator.server.groups}
+ groups_to_add, groups_to_remove = _update_known_ids(_known_group_ids, group_ids)
+
+ client_ids = {c.identifier for c in coordinator.server.clients}
+ clients_to_add, clients_to_remove = _update_known_ids(
+ _known_client_ids, client_ids
+ )
+
+ # Exit early if no changes
+ if not (groups_to_add | groups_to_remove | clients_to_add | clients_to_remove):
+ return
+
+ _LOGGER.debug(
+ "New clients: %s",
+ str([coordinator.server.client(c).friendly_name for c in clients_to_add]),
+ )
+ _LOGGER.debug(
+ "New groups: %s",
+ str([coordinator.server.group(g).friendly_name for g in groups_to_add]),
+ )
+ _LOGGER.debug(
+ "Remove client IDs: %s",
+ str([list(clients_to_remove)]),
+ )
+ _LOGGER.debug(
+ "Remove group IDs: %s",
+ str(list(groups_to_remove)),
+ )
+
+ # Add new entities
+ async_add_entities(
+ [
+ SnapcastGroupDevice(
+ coordinator, coordinator.server.group(group_id), host_id
+ )
+ for group_id in groups_to_add
+ ]
+ + [
+ SnapcastClientDevice(
+ coordinator, coordinator.server.client(client_id), host_id
+ )
+ for client_id in clients_to_add
+ ]
+ )
+
+ # Remove stale entities
+ entity_registry = er.async_get(hass)
+ for group_id in groups_to_remove:
+ if entity_id := entity_registry.async_get_entity_id(
+ MEDIA_PLAYER_DOMAIN,
+ DOMAIN,
+ SnapcastGroupDevice.get_unique_id(host_id, group_id),
+ ):
+ entity_registry.async_remove(entity_id)
+
+ for client_id in clients_to_remove:
+ if entity_id := entity_registry.async_get_entity_id(
+ MEDIA_PLAYER_DOMAIN,
+ DOMAIN,
+ SnapcastClientDevice.get_unique_id(host_id, client_id),
+ ):
+ entity_registry.async_remove(entity_id)
+
+ coordinator.async_add_listener(_check_entities)
+ _check_entities()
-async def handle_async_join(entity, service_call):
- """Handle the entity service join."""
- if not isinstance(entity, SnapcastClientDevice):
- raise TypeError("Entity is not a client. Can only join clients.")
- await entity.async_join(service_call.data[ATTR_MASTER])
-
-
-async def handle_async_unjoin(entity, service_call):
- """Handle the entity service unjoin."""
- if not isinstance(entity, SnapcastClientDevice):
- raise TypeError("Entity is not a client. Can only unjoin clients.")
- await entity.async_unjoin()
-
-
-async def handle_set_latency(entity, service_call):
- """Handle the entity service set_latency."""
- if not isinstance(entity, SnapcastClientDevice):
- raise TypeError("Latency can only be set for a Snapcast client.")
- await entity.async_set_latency(service_call.data[ATTR_LATENCY])
-
-
-class SnapcastGroupDevice(MediaPlayerEntity):
- """Representation of a Snapcast group device."""
+class SnapcastBaseDevice(SnapcastCoordinatorEntity, MediaPlayerEntity):
+ """Base class representing a Snapcast device."""
_attr_should_poll = False
_attr_supported_features = (
@@ -114,166 +181,172 @@ class SnapcastGroupDevice(MediaPlayerEntity):
| MediaPlayerEntityFeature.SELECT_SOURCE
)
- def __init__(self, group, uid_part, entry_id):
- """Initialize the Snapcast group device."""
- self._attr_available = True
- self._group = group
- self._entry_id = entry_id
- self._attr_unique_id = f"{GROUP_PREFIX}{uid_part}_{self._group.identifier}"
+ def __init__(
+ self,
+ coordinator: SnapcastUpdateCoordinator,
+ device: Snapgroup | Snapclient,
+ host_id: str,
+ ) -> None:
+ """Initialize the base device."""
+ super().__init__(coordinator)
+
+ self._device = device
+ self._attr_unique_id = self.get_unique_id(host_id, device.identifier)
+
+ @classmethod
+ def get_unique_id(cls, host, id) -> str:
+ """Build a unique ID."""
+ raise NotImplementedError
+
+ @property
+ def _current_group(self) -> Snapgroup:
+ """Return the group."""
+ raise NotImplementedError
async def async_added_to_hass(self) -> None:
- """Subscribe to group events."""
- self._group.set_callback(self.schedule_update_ha_state)
- self.hass.data[DOMAIN][self._entry_id].groups.append(self)
+ """Subscribe to events."""
+ await super().async_added_to_hass()
+ self._device.set_callback(self.schedule_update_ha_state)
async def async_will_remove_from_hass(self) -> None:
- """Disconnect group object when removed."""
- self._group.set_callback(None)
- self.hass.data[DOMAIN][self._entry_id].groups.remove(self)
+ """Disconnect object when removed."""
+ self._device.set_callback(None)
- def set_availability(self, available: bool) -> None:
- """Set availability of group."""
- self._attr_available = available
- self.schedule_update_ha_state()
+ @property
+ def identifier(self) -> str:
+ """Return the snapcast identifier."""
+ return self._device.identifier
+
+ @property
+ def source(self) -> str | None:
+ """Return the current input source."""
+ return self._current_group.stream
+
+ @property
+ def source_list(self) -> list[str]:
+ """List of available input sources."""
+ return list(self._current_group.streams_by_name().keys())
+
+ async def async_select_source(self, source: str) -> None:
+ """Set input source."""
+ streams = self._current_group.streams_by_name()
+ if source in streams:
+ await self._current_group.set_stream(streams[source].identifier)
+ self.async_write_ha_state()
+
+ @property
+ def is_volume_muted(self) -> bool:
+ """Volume muted."""
+ return self._device.muted
+
+ async def async_mute_volume(self, mute: bool) -> None:
+ """Send the mute command."""
+ await self._device.set_muted(mute)
+ self.async_write_ha_state()
+
+ @property
+ def volume_level(self) -> float:
+ """Return the volume level."""
+ return self._device.volume / 100
+
+ async def async_set_volume_level(self, volume: float) -> None:
+ """Set the volume level."""
+ await self._device.set_volume(round(volume * 100))
+ self.async_write_ha_state()
+
+ def snapshot(self) -> None:
+ """Snapshot the group state."""
+ self._device.snapshot()
+
+ async def async_restore(self) -> None:
+ """Restore the group state."""
+ await self._device.restore()
+ self.async_write_ha_state()
+
+ async def async_set_latency(self, latency) -> None:
+ """Handle the set_latency service."""
+ raise NotImplementedError
+
+ async def async_join(self, master) -> None:
+ """Handle the join service."""
+ raise NotImplementedError
+
+ async def async_unjoin(self) -> None:
+ """Handle the unjoin service."""
+ raise NotImplementedError
+
+
+class SnapcastGroupDevice(SnapcastBaseDevice):
+ """Representation of a Snapcast group device."""
+
+ _device: Snapgroup
+
+ @classmethod
+ def get_unique_id(cls, host, id) -> str:
+ """Get a unique ID for a group."""
+ return f"{GROUP_PREFIX}{host}_{id}"
+
+ @property
+ def _current_group(self) -> Snapgroup:
+ """Return the group."""
+ return self._device
+
+ @property
+ def name(self) -> str:
+ """Return the name of the device."""
+ return f"{self._device.friendly_name} {GROUP_SUFFIX}"
@property
def state(self) -> MediaPlayerState | None:
"""Return the state of the player."""
if self.is_volume_muted:
return MediaPlayerState.IDLE
- return STREAM_STATUS.get(self._group.stream_status)
+ return STREAM_STATUS.get(self._device.stream_status)
- @property
- def identifier(self):
- """Return the snapcast identifier."""
- return self._group.identifier
+ async def async_set_latency(self, latency) -> None:
+ """Handle the set_latency service."""
+ raise ServiceValidationError("Latency can only be set for a Snapcast client.")
- @property
- def name(self):
- """Return the name of the device."""
- return f"{self._group.friendly_name} {GROUP_SUFFIX}"
+ async def async_join(self, master) -> None:
+ """Handle the join service."""
+ raise ServiceValidationError("Entity is not a client. Can only join clients.")
- @property
- def source(self):
- """Return the current input source."""
- return self._group.stream
-
- @property
- def volume_level(self):
- """Return the volume level."""
- return self._group.volume / 100
-
- @property
- def is_volume_muted(self):
- """Volume muted."""
- return self._group.muted
-
- @property
- def source_list(self):
- """List of available input sources."""
- return list(self._group.streams_by_name().keys())
-
- async def async_select_source(self, source: str) -> None:
- """Set input source."""
- streams = self._group.streams_by_name()
- if source in streams:
- await self._group.set_stream(streams[source].identifier)
- self.async_write_ha_state()
-
- async def async_mute_volume(self, mute: bool) -> None:
- """Send the mute command."""
- await self._group.set_muted(mute)
- self.async_write_ha_state()
-
- async def async_set_volume_level(self, volume: float) -> None:
- """Set the volume level."""
- await self._group.set_volume(round(volume * 100))
- self.async_write_ha_state()
-
- def snapshot(self):
- """Snapshot the group state."""
- self._group.snapshot()
-
- async def async_restore(self):
- """Restore the group state."""
- await self._group.restore()
- self.async_write_ha_state()
+ async def async_unjoin(self) -> None:
+ """Handle the unjoin service."""
+ raise ServiceValidationError("Entity is not a client. Can only unjoin clients.")
-class SnapcastClientDevice(MediaPlayerEntity):
+class SnapcastClientDevice(SnapcastBaseDevice):
"""Representation of a Snapcast client device."""
- _attr_should_poll = False
- _attr_supported_features = (
- MediaPlayerEntityFeature.VOLUME_MUTE
- | MediaPlayerEntityFeature.VOLUME_SET
- | MediaPlayerEntityFeature.SELECT_SOURCE
- )
+ _device: Snapclient
- def __init__(self, client, uid_part, entry_id):
- """Initialize the Snapcast client device."""
- self._attr_available = True
- self._client = client
- # Note: Host part is needed, when using multiple snapservers
- self._attr_unique_id = f"{CLIENT_PREFIX}{uid_part}_{self._client.identifier}"
- self._entry_id = entry_id
-
- async def async_added_to_hass(self) -> None:
- """Subscribe to client events."""
- self._client.set_callback(self.schedule_update_ha_state)
- self.hass.data[DOMAIN][self._entry_id].clients.append(self)
-
- async def async_will_remove_from_hass(self) -> None:
- """Disconnect client object when removed."""
- self._client.set_callback(None)
- self.hass.data[DOMAIN][self._entry_id].clients.remove(self)
-
- def set_availability(self, available: bool) -> None:
- """Set availability of group."""
- self._attr_available = available
- self.schedule_update_ha_state()
+ @classmethod
+ def get_unique_id(cls, host, id) -> str:
+ """Get a unique ID for a client."""
+ return f"{CLIENT_PREFIX}{host}_{id}"
@property
- def identifier(self):
- """Return the snapcast identifier."""
- return self._client.identifier
+ def _current_group(self) -> Snapgroup:
+ """Return the group the client is associated with."""
+ return self._device.group
@property
- def name(self):
+ def name(self) -> str:
"""Return the name of the device."""
- return f"{self._client.friendly_name} {CLIENT_SUFFIX}"
-
- @property
- def source(self):
- """Return the current input source."""
- return self._client.group.stream
-
- @property
- def volume_level(self):
- """Return the volume level."""
- return self._client.volume / 100
-
- @property
- def is_volume_muted(self):
- """Volume muted."""
- return self._client.muted
-
- @property
- def source_list(self):
- """List of available input sources."""
- return list(self._client.group.streams_by_name().keys())
+ return f"{self._device.friendly_name} {CLIENT_SUFFIX}"
@property
def state(self) -> MediaPlayerState | None:
"""Return the state of the player."""
- if self._client.connected:
- if self.is_volume_muted or self._client.group.muted:
+ if self._device.connected:
+ if self.is_volume_muted or self._current_group.muted:
return MediaPlayerState.IDLE
- return STREAM_STATUS.get(self._client.group.stream_status)
+ return STREAM_STATUS.get(self._current_group.stream_status)
return MediaPlayerState.STANDBY
@property
- def extra_state_attributes(self):
+ def extra_state_attributes(self) -> Mapping[str, Any]:
"""Return the state attributes."""
state_attrs = {}
if self.latency is not None:
@@ -281,60 +354,40 @@ class SnapcastClientDevice(MediaPlayerEntity):
return state_attrs
@property
- def latency(self):
+ def latency(self) -> float | None:
"""Latency for Client."""
- return self._client.latency
+ return self._device.latency
- async def async_select_source(self, source: str) -> None:
- """Set input source."""
- streams = self._client.group.streams_by_name()
- if source in streams:
- await self._client.group.set_stream(streams[source].identifier)
- self.async_write_ha_state()
-
- async def async_mute_volume(self, mute: bool) -> None:
- """Send the mute command."""
- await self._client.set_muted(mute)
+ async def async_set_latency(self, latency) -> None:
+ """Set the latency of the client."""
+ await self._device.set_latency(latency)
self.async_write_ha_state()
- async def async_set_volume_level(self, volume: float) -> None:
- """Set the volume level."""
- await self._client.set_volume(round(volume * 100))
- self.async_write_ha_state()
-
- async def async_join(self, master):
+ async def async_join(self, master) -> None:
"""Join the group of the master player."""
- master_entity = next(
- entity
- for entity in self.hass.data[DOMAIN][self._entry_id].clients
- if entity.entity_id == master
- )
- if not isinstance(master_entity, SnapcastClientDevice):
- raise TypeError("Master is not a client device. Can only join clients.")
+ entity_registry = er.async_get(self.hass)
+ master_entity = entity_registry.async_get(master)
+ if master_entity is None:
+ raise ServiceValidationError(f"Master entity '{master}' not found.")
+ # Validate master entity is a client
+ unique_id = master_entity.unique_id
+ if not unique_id.startswith(CLIENT_PREFIX):
+ raise ServiceValidationError(
+ "Master is not a client device. Can only join clients."
+ )
+
+ # Extract the client ID and locate it's group
+ identifier = unique_id.split("_")[-1]
master_group = next(
group
- for group in self._client.groups_available()
- if master_entity.identifier in group.clients
+ for group in self._device.groups_available()
+ if identifier in group.clients
)
- await master_group.add_client(self._client.identifier)
+ await master_group.add_client(self._device.identifier)
self.async_write_ha_state()
- async def async_unjoin(self):
+ async def async_unjoin(self) -> None:
"""Unjoin the group the player is currently in."""
- await self._client.group.remove_client(self._client.identifier)
- self.async_write_ha_state()
-
- def snapshot(self):
- """Snapshot the client state."""
- self._client.snapshot()
-
- async def async_restore(self):
- """Restore the client state."""
- await self._client.restore()
- self.async_write_ha_state()
-
- async def async_set_latency(self, latency):
- """Set the latency of the client."""
- await self._client.set_latency(latency)
+ await self._current_group.remove_client(self._device.identifier)
self.async_write_ha_state()
diff --git a/homeassistant/components/snapcast/server.py b/homeassistant/components/snapcast/server.py
deleted file mode 100644
index ab4091e30af..00000000000
--- a/homeassistant/components/snapcast/server.py
+++ /dev/null
@@ -1,143 +0,0 @@
-"""Snapcast Integration."""
-
-from __future__ import annotations
-
-import logging
-
-import snapcast.control
-from snapcast.control.client import Snapclient
-
-from homeassistant.components.media_player import MediaPlayerEntity
-from homeassistant.core import HomeAssistant
-from homeassistant.helpers import entity_registry as er
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
-
-from .media_player import SnapcastClientDevice, SnapcastGroupDevice
-
-_LOGGER = logging.getLogger(__name__)
-
-
-class HomeAssistantSnapcast:
- """Snapcast server and data stored in the Home Assistant data object."""
-
- hass: HomeAssistant
-
- def __init__(
- self,
- hass: HomeAssistant,
- server: snapcast.control.Snapserver,
- hpid: str,
- entry_id: str,
- ) -> None:
- """Initialize the HomeAssistantSnapcast object.
-
- Parameters
- ----------
- hass: HomeAssistant
- hass object
- server : snapcast.control.Snapserver
- Snapcast server
- hpid : str
- host and port
- entry_id: str
- ConfigEntry entry_id
-
- Returns
- -------
- None
-
- """
- self.hass: HomeAssistant = hass
- self.server: snapcast.control.Snapserver = server
- self.hpid: str = hpid
- self._entry_id = entry_id
- self.clients: list[SnapcastClientDevice] = []
- self.groups: list[SnapcastGroupDevice] = []
- self.hass_async_add_entities: AddEntitiesCallback
- # connect callbacks
- self.server.set_on_update_callback(self.on_update)
- self.server.set_on_connect_callback(self.on_connect)
- self.server.set_on_disconnect_callback(self.on_disconnect)
- self.server.set_new_client_callback(self.on_add_client)
-
- async def disconnect(self) -> None:
- """Disconnect from server."""
- self.server.set_on_update_callback(None)
- self.server.set_on_connect_callback(None)
- self.server.set_on_disconnect_callback(None)
- self.server.set_new_client_callback(None)
- self.server.stop()
-
- def on_update(self) -> None:
- """Update all entities.
-
- Retrieve all groups/clients from server and add/update/delete entities.
- """
- if not self.hass_async_add_entities:
- return
- new_groups: list[MediaPlayerEntity] = []
- groups: list[MediaPlayerEntity] = []
- hass_groups = {g.identifier: g for g in self.groups}
- for group in self.server.groups:
- if group.identifier in hass_groups:
- groups.append(hass_groups[group.identifier])
- hass_groups[group.identifier].async_schedule_update_ha_state()
- else:
- new_groups.append(SnapcastGroupDevice(group, self.hpid, self._entry_id))
- new_clients: list[MediaPlayerEntity] = []
- clients: list[MediaPlayerEntity] = []
- hass_clients = {c.identifier: c for c in self.clients}
- for client in self.server.clients:
- if client.identifier in hass_clients:
- clients.append(hass_clients[client.identifier])
- hass_clients[client.identifier].async_schedule_update_ha_state()
- else:
- new_clients.append(
- SnapcastClientDevice(client, self.hpid, self._entry_id)
- )
- del_entities: list[MediaPlayerEntity] = [
- x for x in self.groups if x not in groups
- ]
- del_entities.extend([x for x in self.clients if x not in clients])
-
- _LOGGER.debug("New clients: %s", str([c.name for c in new_clients]))
- _LOGGER.debug("New groups: %s", str([g.name for g in new_groups]))
- _LOGGER.debug("Delete: %s", str(del_entities))
-
- ent_reg = er.async_get(self.hass)
- for entity in del_entities:
- ent_reg.async_remove(entity.entity_id)
- self.hass_async_add_entities(new_clients + new_groups)
-
- def on_connect(self) -> None:
- """Activate all entities and update."""
- for client in self.clients:
- client.set_availability(True)
- for group in self.groups:
- group.set_availability(True)
- _LOGGER.debug("Server connected: %s", self.hpid)
- self.on_update()
-
- def on_disconnect(self, ex: Exception | None) -> None:
- """Deactivate all entities."""
- for client in self.clients:
- client.set_availability(False)
- for group in self.groups:
- group.set_availability(False)
- _LOGGER.warning(
- "Server disconnected: %s. Trying to reconnect. %s", self.hpid, str(ex or "")
- )
-
- def on_add_client(self, client: Snapclient) -> None:
- """Add a Snapcast client.
-
- Parameters
- ----------
- client : Snapclient
- Snapcast client to be added to HA.
-
- """
- if not self.hass_async_add_entities:
- return
- clients = [SnapcastClientDevice(client, self.hpid, self._entry_id)]
- self.hass_async_add_entities(clients)
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/snips/manifest.json b/homeassistant/components/snips/manifest.json
index 16620eb4bfb..ec768b2b3d4 100644
--- a/homeassistant/components/snips/manifest.json
+++ b/homeassistant/components/snips/manifest.json
@@ -4,5 +4,6 @@
"codeowners": [],
"dependencies": ["mqtt"],
"documentation": "https://www.home-assistant.io/integrations/snips",
- "iot_class": "local_push"
+ "iot_class": "local_push",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/snmp/manifest.json b/homeassistant/components/snmp/manifest.json
index 0b8863c8e58..a2a4405a1b5 100644
--- a/homeassistant/components/snmp/manifest.json
+++ b/homeassistant/components/snmp/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/snmp",
"iot_class": "local_polling",
"loggers": ["pyasn1", "pysmi", "pysnmp"],
+ "quality_scale": "legacy",
"requirements": ["pysnmp==6.2.6"]
}
diff --git a/homeassistant/components/snooz/fan.py b/homeassistant/components/snooz/fan.py
index 8c721432709..bfe773b4780 100644
--- a/homeassistant/components/snooz/fan.py
+++ b/homeassistant/components/snooz/fan.py
@@ -83,7 +83,6 @@ class SnoozFan(FanEntity, RestoreEntity):
_attr_should_poll = False
_is_on: bool | None = None
_percentage: int | None = None
- _enable_turn_on_off_backwards_compatibility = False
def __init__(self, data: SnoozConfigurationData) -> None:
"""Initialize a Snooz fan entity."""
diff --git a/homeassistant/components/solaredge_local/manifest.json b/homeassistant/components/solaredge_local/manifest.json
index d65aa06ea0a..61c08b3b152 100644
--- a/homeassistant/components/solaredge_local/manifest.json
+++ b/homeassistant/components/solaredge_local/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/solaredge_local",
"iot_class": "local_polling",
"loggers": ["solaredge_local"],
+ "quality_scale": "legacy",
"requirements": ["solaredge-local==0.2.3"]
}
diff --git a/homeassistant/components/solarlog/config_flow.py b/homeassistant/components/solarlog/config_flow.py
index a61f825aa5e..767079ea1f8 100644
--- a/homeassistant/components/solarlog/config_flow.py
+++ b/homeassistant/components/solarlog/config_flow.py
@@ -1,7 +1,6 @@
"""Config flow for solarlog integration."""
from collections.abc import Mapping
-import logging
from typing import Any
from urllib.parse import ParseResult, urlparse
@@ -14,12 +13,9 @@ from solarlog_cli.solarlog_exceptions import (
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
-from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD
-from homeassistant.util import slugify
+from homeassistant.const import CONF_HOST, CONF_PASSWORD
-from .const import CONF_HAS_PWD, DEFAULT_HOST, DEFAULT_NAME, DOMAIN
-
-_LOGGER = logging.getLogger(__name__)
+from .const import CONF_HAS_PWD, DEFAULT_HOST, DOMAIN
class SolarLogConfigFlow(ConfigFlow, domain=DOMAIN):
@@ -84,24 +80,21 @@ class SolarLogConfigFlow(ConfigFlow, domain=DOMAIN):
self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]})
- user_input[CONF_NAME] = slugify(user_input[CONF_NAME])
-
if await self._test_connection(user_input[CONF_HOST]):
if user_input[CONF_HAS_PWD]:
self._user_input = user_input
return await self.async_step_password()
return self.async_create_entry(
- title=user_input[CONF_NAME], data=user_input
+ title=user_input[CONF_HOST], data=user_input
)
else:
- user_input = {CONF_NAME: DEFAULT_NAME, CONF_HOST: DEFAULT_HOST}
+ user_input = {CONF_HOST: DEFAULT_HOST}
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{
- vol.Required(CONF_NAME, default=user_input[CONF_NAME]): str,
vol.Required(CONF_HOST, default=user_input[CONF_HOST]): str,
vol.Required(CONF_HAS_PWD, default=False): bool,
}
@@ -120,7 +113,7 @@ class SolarLogConfigFlow(ConfigFlow, domain=DOMAIN):
):
self._user_input |= user_input
return self.async_create_entry(
- title=self._user_input[CONF_NAME], data=self._user_input
+ title=self._user_input[CONF_HOST], data=self._user_input
)
else:
user_input = {CONF_PASSWORD: ""}
diff --git a/homeassistant/components/solarlog/const.py b/homeassistant/components/solarlog/const.py
index f86d103f830..3e814705589 100644
--- a/homeassistant/components/solarlog/const.py
+++ b/homeassistant/components/solarlog/const.py
@@ -6,6 +6,5 @@ DOMAIN = "solarlog"
# Default config for solarlog.
DEFAULT_HOST = "http://solar-log"
-DEFAULT_NAME = "solarlog"
CONF_HAS_PWD = "has_password"
diff --git a/homeassistant/components/solarlog/coordinator.py b/homeassistant/components/solarlog/coordinator.py
index 5fdf89c9e74..11f268db32a 100644
--- a/homeassistant/components/solarlog/coordinator.py
+++ b/homeassistant/components/solarlog/coordinator.py
@@ -19,6 +19,7 @@ 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.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
@@ -51,13 +52,13 @@ class SolarLogCoordinator(DataUpdateCoordinator[SolarlogData]):
path = url.path if url.netloc else ""
url = ParseResult("http", netloc, path, *url[3:])
self.unique_id = entry.entry_id
- self.name = entry.title
self.host = url.geturl()
self.solarlog = SolarLogConnector(
self.host,
tz=hass.config.time_zone,
password=password,
+ session=async_get_clientsession(hass),
)
async def _async_setup(self) -> None:
@@ -81,15 +82,27 @@ class SolarLogCoordinator(DataUpdateCoordinator[SolarlogData]):
await self.solarlog.update_device_list()
data.inverter_data = await self.solarlog.update_inverter_data()
except SolarLogConnectionError as ex:
- raise ConfigEntryNotReady(ex) from ex
+ raise ConfigEntryNotReady(
+ translation_domain=DOMAIN,
+ translation_key="config_entry_not_ready",
+ ) from ex
except SolarLogAuthenticationError as ex:
if await self.renew_authentication():
# login was successful, update availability of extended data, retry data update
await self.solarlog.test_extended_data_available()
- raise ConfigEntryNotReady from ex
- raise ConfigEntryAuthFailed from ex
+ raise ConfigEntryNotReady(
+ translation_domain=DOMAIN,
+ translation_key="config_entry_not_ready",
+ ) from ex
+ raise ConfigEntryAuthFailed(
+ translation_domain=DOMAIN,
+ translation_key="auth_failed",
+ ) from ex
except SolarLogUpdateError as ex:
- raise UpdateFailed(ex) from ex
+ raise UpdateFailed(
+ translation_domain=DOMAIN,
+ translation_key="update_failed",
+ ) from ex
_LOGGER.debug("Data successfully updated")
@@ -148,9 +161,15 @@ class SolarLogCoordinator(DataUpdateCoordinator[SolarlogData]):
try:
logged_in = await self.solarlog.login()
except SolarLogAuthenticationError as ex:
- raise ConfigEntryAuthFailed from ex
+ raise ConfigEntryAuthFailed(
+ translation_domain=DOMAIN,
+ translation_key="auth_failed",
+ ) from ex
except (SolarLogConnectionError, SolarLogUpdateError) as ex:
- raise ConfigEntryNotReady from ex
+ raise ConfigEntryNotReady(
+ translation_domain=DOMAIN,
+ translation_key="config_entry_not_ready",
+ ) from ex
_LOGGER.debug("Credentials successfully updated? %s", logged_in)
diff --git a/homeassistant/components/solarlog/entity.py b/homeassistant/components/solarlog/entity.py
index b0f3ddf99f9..bfdc52dccf1 100644
--- a/homeassistant/components/solarlog/entity.py
+++ b/homeassistant/components/solarlog/entity.py
@@ -43,7 +43,7 @@ class SolarLogCoordinatorEntity(SolarLogBaseEntity):
manufacturer="Solar-Log",
model="Controller",
identifiers={(DOMAIN, coordinator.unique_id)},
- name=coordinator.name,
+ name="SolarLog",
configuration_url=coordinator.host,
)
diff --git a/homeassistant/components/solarlog/manifest.json b/homeassistant/components/solarlog/manifest.json
index 9f80b749d08..486b30edfd3 100644
--- a/homeassistant/components/solarlog/manifest.json
+++ b/homeassistant/components/solarlog/manifest.json
@@ -6,5 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/solarlog",
"iot_class": "local_polling",
"loggers": ["solarlog_cli"],
- "requirements": ["solarlog_cli==0.3.2"]
+ "quality_scale": "platinum",
+ "requirements": ["solarlog_cli==0.4.0"]
}
diff --git a/homeassistant/components/solarlog/quality_scale.yaml b/homeassistant/components/solarlog/quality_scale.yaml
new file mode 100644
index 00000000000..543889ee18c
--- /dev/null
+++ b/homeassistant/components/solarlog/quality_scale.yaml
@@ -0,0 +1,81 @@
+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: No explicit event subscriptions.
+ dependency-transparency: done
+ action-setup:
+ status: exempt
+ comment: No custom action.
+ common-modules: done
+ docs-high-level-description: done
+ docs-installation-instructions: done
+ docs-removal-instructions: done
+ docs-actions:
+ status: exempt
+ comment: No custom action.
+ brands: done
+
+ # Silver
+ config-entry-unloading: done
+ log-when-unavailable: done
+ entity-unavailable: done
+ action-exceptions:
+ status: exempt
+ comment: No custom action.
+ reauthentication-flow: done
+ parallel-updates:
+ status: exempt
+ comment: Coordinator and sensor only platform.
+ test-coverage: done
+ integration-owner: done
+ docs-installation-parameters: done
+ docs-configuration-parameters:
+ status: exempt
+ comment: No options flow.
+
+ # Gold
+ entity-translations: done
+ entity-device-class: done
+ devices: done
+ entity-category: done
+ entity-disabled-by-default: done
+ discovery:
+ status: exempt
+ comment: Solar-Log device cannot be discovered.
+ stale-devices: done
+ diagnostics: done
+ exception-translations: done
+ icon-translations: done
+ reconfiguration-flow: done
+ dynamic-devices: done
+ discovery-update-info:
+ status: exempt
+ comment: Solar-Log device cannot be discovered.
+ 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: done
+ docs-data-update: done
+ docs-known-limitations: done
+ docs-troubleshooting:
+ status: exempt
+ comment: |
+ This integration doesn't have known issues that could be resolved by the user.
+ docs-examples: done
+ # Platinum
+ async-dependency: done
+ inject-websession: done
+ strict-typing: done
diff --git a/homeassistant/components/solarlog/strings.json b/homeassistant/components/solarlog/strings.json
index 723af6cb277..bf87b0b0938 100644
--- a/homeassistant/components/solarlog/strings.json
+++ b/homeassistant/components/solarlog/strings.json
@@ -5,7 +5,6 @@
"title": "Define your Solar-Log connection",
"data": {
"host": "[%key:common::config_flow::data::host%]",
- "name": "The prefix to be used for your Solar-Log sensors",
"has_password": "I have the password for the Solar-Log user account."
},
"data_description": {
@@ -27,6 +26,10 @@
"data": {
"has_password": "[%key:component::solarlog::config::step::user::data::has_password%]",
"password": "[%key:common::config_flow::data::password%]"
+ },
+ "data_description": {
+ "has_password": "[%key:component::solarlog::config::step::user::data_description::has_password%]",
+ "password": "[%key:component::solarlog::config::step::password::data_description::password%]"
}
},
"reconfigure": {
@@ -34,6 +37,10 @@
"data": {
"has_password": "[%key:component::solarlog::config::step::user::data::has_password%]",
"password": "[%key:common::config_flow::data::password%]"
+ },
+ "data_description": {
+ "has_password": "[%key:component::solarlog::config::step::user::data_description::has_password%]",
+ "password": "[%key:component::solarlog::config::step::password::data_description::password%]"
}
}
},
@@ -121,5 +128,16 @@
"name": "Usage"
}
}
+ },
+ "exceptions": {
+ "update_error": {
+ "message": "Error while updating data from the API."
+ },
+ "config_entry_not_ready": {
+ "message": "Error while loading the config entry."
+ },
+ "auth_failed": {
+ "message": "Error while logging in to the API."
+ }
}
}
diff --git a/homeassistant/components/solax/manifest.json b/homeassistant/components/solax/manifest.json
index 2ca246a4e77..925f11e4c65 100644
--- a/homeassistant/components/solax/manifest.json
+++ b/homeassistant/components/solax/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/solax",
"iot_class": "local_polling",
"loggers": ["solax"],
- "requirements": ["solax==3.1.1"]
+ "requirements": ["solax==3.2.3"]
}
diff --git a/homeassistant/components/sonarr/config_flow.py b/homeassistant/components/sonarr/config_flow.py
index c868c04f7d0..e1cedba10e7 100644
--- a/homeassistant/components/sonarr/config_flow.py
+++ b/homeassistant/components/sonarr/config_flow.py
@@ -93,6 +93,13 @@ class SonarrConfigFlow(ConfigFlow, domain=DOMAIN):
errors = {}
if user_input is not None:
+ # aiopyarr defaults to the service port if one isn't given
+ # this is counter to standard practice where http = 80
+ # and https = 443.
+ if CONF_URL in user_input:
+ url = yarl.URL(user_input[CONF_URL])
+ user_input[CONF_URL] = f"{url.scheme}://{url.host}:{url.port}{url.path}"
+
if self.source == SOURCE_REAUTH:
user_input = {**self._get_reauth_entry().data, **user_input}
diff --git a/homeassistant/components/sonarr/manifest.json b/homeassistant/components/sonarr/manifest.json
index bfc2b6f787f..c81dc9c3972 100644
--- a/homeassistant/components/sonarr/manifest.json
+++ b/homeassistant/components/sonarr/manifest.json
@@ -6,6 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/sonarr",
"iot_class": "local_polling",
"loggers": ["aiopyarr"],
- "quality_scale": "silver",
"requirements": ["aiopyarr==23.4.0"]
}
diff --git a/homeassistant/components/songpal/config_flow.py b/homeassistant/components/songpal/config_flow.py
index 762de39aa30..1c13013108f 100644
--- a/homeassistant/components/songpal/config_flow.py
+++ b/homeassistant/components/songpal/config_flow.py
@@ -24,6 +24,8 @@ class SongpalConfig:
def __init__(self, name: str, host: str | None, endpoint: str) -> None:
"""Initialize Configuration."""
self.name = name
+ if TYPE_CHECKING:
+ assert host is not None
self.host = host
self.endpoint = endpoint
@@ -114,7 +116,7 @@ class SongpalConfigFlow(ConfigFlow, domain=DOMAIN):
]
# Ignore Bravia TVs
- if "videoScreen" in service_types:
+ if "videoScreen" in service_types or "video" in service_types:
return self.async_abort(reason="not_songpal_device")
if TYPE_CHECKING:
diff --git a/homeassistant/components/songpal/manifest.json b/homeassistant/components/songpal/manifest.json
index c4dec6b938d..a04bea0c48d 100644
--- a/homeassistant/components/songpal/manifest.json
+++ b/homeassistant/components/songpal/manifest.json
@@ -6,7 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/songpal",
"iot_class": "local_push",
"loggers": ["songpal"],
- "quality_scale": "gold",
"requirements": ["python-songpal==0.16.2"],
"ssdp": [
{
diff --git a/homeassistant/components/sonos/manifest.json b/homeassistant/components/sonos/manifest.json
index d6c5eb298d8..76a7d0bfa91 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.4", "sonos-websocket==0.1.3"],
+ "requirements": ["soco==0.30.6", "sonos-websocket==0.1.3"],
"ssdp": [
{
"st": "urn:schemas-upnp-org:device:ZonePlayer:1"
diff --git a/homeassistant/components/sonos/media_player.py b/homeassistant/components/sonos/media_player.py
index 7711a1e88ea..8d0917c5dba 100644
--- a/homeassistant/components/sonos/media_player.py
+++ b/homeassistant/components/sonos/media_player.py
@@ -782,9 +782,9 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity):
queue: list[DidlMusicTrack] = self.coordinator.soco.get_queue(max_items=0)
return [
{
- ATTR_MEDIA_TITLE: track.title,
- ATTR_MEDIA_ALBUM_NAME: track.album,
- ATTR_MEDIA_ARTIST: track.creator,
+ ATTR_MEDIA_TITLE: getattr(track, "title", None),
+ ATTR_MEDIA_ALBUM_NAME: getattr(track, "album", None),
+ ATTR_MEDIA_ARTIST: getattr(track, "creator", None),
ATTR_MEDIA_CONTENT_ID: track.get_uri(),
}
for track in queue
diff --git a/homeassistant/components/sony_projector/manifest.json b/homeassistant/components/sony_projector/manifest.json
index 5cf5df4c96f..f674f6fa56b 100644
--- a/homeassistant/components/sony_projector/manifest.json
+++ b/homeassistant/components/sony_projector/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/sony_projector",
"iot_class": "local_polling",
"loggers": ["pysdcp"],
+ "quality_scale": "legacy",
"requirements": ["pySDCP==1"]
}
diff --git a/homeassistant/components/soundtouch/config_flow.py b/homeassistant/components/soundtouch/config_flow.py
index 7e3fb2ca8c3..af45b8f6bdc 100644
--- a/homeassistant/components/soundtouch/config_flow.py
+++ b/homeassistant/components/soundtouch/config_flow.py
@@ -1,6 +1,5 @@
"""Config flow for Bose SoundTouch integration."""
-import logging
from typing import Any
from libsoundtouch import soundtouch_device
@@ -14,8 +13,6 @@ from homeassistant.helpers import config_validation as cv
from .const import DOMAIN
-_LOGGER = logging.getLogger(__name__)
-
class SoundtouchConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Bose SoundTouch."""
@@ -25,7 +22,7 @@ class SoundtouchConfigFlow(ConfigFlow, domain=DOMAIN):
def __init__(self) -> None:
"""Initialize a new SoundTouch config flow."""
self.host: str | None = None
- self.name = None
+ self.name: str | None = None
async def async_step_user(
self, user_input: dict[str, Any] | None = None
@@ -79,7 +76,7 @@ class SoundtouchConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_show_form(
step_id="zeroconf_confirm",
last_step=True,
- description_placeholders={"name": self.name},
+ description_placeholders={"name": self.name or "?"},
)
async def _async_get_device_id(self, raise_on_progress: bool = True) -> None:
@@ -94,10 +91,10 @@ class SoundtouchConfigFlow(ConfigFlow, domain=DOMAIN):
self.name = device.config.name
- async def _async_create_soundtouch_entry(self):
+ async def _async_create_soundtouch_entry(self) -> ConfigFlowResult:
"""Finish config flow and create a SoundTouch config entry."""
return self.async_create_entry(
- title=self.name,
+ title=self.name or "SoundTouch",
data={
CONF_HOST: self.host,
},
diff --git a/homeassistant/components/spaceapi/__init__.py b/homeassistant/components/spaceapi/__init__.py
index 93d448bd17f..90281fe311c 100644
--- a/homeassistant/components/spaceapi/__init__.py
+++ b/homeassistant/components/spaceapi/__init__.py
@@ -1,6 +1,7 @@
"""Support for the SpaceAPI."""
from contextlib import suppress
+import math
import voluptuous as vol
@@ -254,7 +255,17 @@ class APISpaceApiView(HomeAssistantView):
"""Get data from a sensor."""
if not (sensor_state := hass.states.get(sensor)):
return None
- sensor_data = {ATTR_NAME: sensor_state.name, ATTR_VALUE: sensor_state.state}
+
+ # SpaceAPI sensor values must be numbers
+ try:
+ state = float(sensor_state.state)
+ except ValueError:
+ state = math.nan
+ sensor_data = {
+ ATTR_NAME: sensor_state.name,
+ ATTR_VALUE: state,
+ }
+
if ATTR_SENSOR_LOCATION in sensor_state.attributes:
sensor_data[ATTR_LOCATION] = sensor_state.attributes[ATTR_SENSOR_LOCATION]
else:
diff --git a/homeassistant/components/spaceapi/manifest.json b/homeassistant/components/spaceapi/manifest.json
index 84add9bb4ed..798930bbef5 100644
--- a/homeassistant/components/spaceapi/manifest.json
+++ b/homeassistant/components/spaceapi/manifest.json
@@ -4,5 +4,6 @@
"codeowners": ["@fabaff"],
"dependencies": ["http"],
"documentation": "https://www.home-assistant.io/integrations/spaceapi",
- "iot_class": "cloud_polling"
+ "iot_class": "cloud_polling",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/spc/manifest.json b/homeassistant/components/spc/manifest.json
index a707e1a7804..b3c37ce2e2b 100644
--- a/homeassistant/components/spc/manifest.json
+++ b/homeassistant/components/spc/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/spc",
"iot_class": "local_push",
"loggers": ["pyspcwebgw"],
+ "quality_scale": "legacy",
"requirements": ["pyspcwebgw==0.7.0"]
}
diff --git a/homeassistant/components/splunk/manifest.json b/homeassistant/components/splunk/manifest.json
index 947af317b35..4b287c8950c 100644
--- a/homeassistant/components/splunk/manifest.json
+++ b/homeassistant/components/splunk/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/splunk",
"iot_class": "local_push",
"loggers": ["hass_splunk"],
+ "quality_scale": "legacy",
"requirements": ["hass-splunk==0.1.1"]
}
diff --git a/homeassistant/components/spotify/__init__.py b/homeassistant/components/spotify/__init__.py
index cfcc9011b37..37580ac432d 100644
--- a/homeassistant/components/spotify/__init__.py
+++ b/homeassistant/components/spotify/__init__.py
@@ -29,7 +29,7 @@ from .util import (
spotify_uri_from_media_browser_url,
)
-PLATFORMS = [Platform.MEDIA_PLAYER, Platform.SENSOR]
+PLATFORMS = [Platform.MEDIA_PLAYER]
__all__ = [
"async_browse_media",
diff --git a/homeassistant/components/spotify/browse_media.py b/homeassistant/components/spotify/browse_media.py
index 403ec608a7c..81cdfdfb3cf 100644
--- a/homeassistant/components/spotify/browse_media.py
+++ b/homeassistant/components/spotify/browse_media.py
@@ -14,6 +14,7 @@ from spotifyaio import (
SpotifyClient,
Track,
)
+from spotifyaio.models import ItemType, SimplifiedEpisode
import yarl
from homeassistant.components.media_player import (
@@ -90,6 +91,16 @@ def _get_track_item_payload(
}
+def _get_episode_item_payload(episode: SimplifiedEpisode) -> ItemPayload:
+ return {
+ "id": episode.episode_id,
+ "name": episode.name,
+ "type": MediaType.EPISODE,
+ "uri": episode.uri,
+ "thumbnail": fetch_image_url(episode.images),
+ }
+
+
class BrowsableMedia(StrEnum):
"""Enum of browsable media."""
@@ -101,8 +112,6 @@ class BrowsableMedia(StrEnum):
CURRENT_USER_RECENTLY_PLAYED = "current_user_recently_played"
CURRENT_USER_TOP_ARTISTS = "current_user_top_artists"
CURRENT_USER_TOP_TRACKS = "current_user_top_tracks"
- CATEGORIES = "categories"
- FEATURED_PLAYLISTS = "featured_playlists"
NEW_RELEASES = "new_releases"
@@ -115,8 +124,6 @@ LIBRARY_MAP = {
BrowsableMedia.CURRENT_USER_RECENTLY_PLAYED.value: "Recently played",
BrowsableMedia.CURRENT_USER_TOP_ARTISTS.value: "Top Artists",
BrowsableMedia.CURRENT_USER_TOP_TRACKS.value: "Top Tracks",
- BrowsableMedia.CATEGORIES.value: "Categories",
- BrowsableMedia.FEATURED_PLAYLISTS.value: "Featured Playlists",
BrowsableMedia.NEW_RELEASES.value: "New Releases",
}
@@ -153,18 +160,6 @@ CONTENT_TYPE_MEDIA_CLASS: dict[str, Any] = {
"parent": MediaClass.DIRECTORY,
"children": MediaClass.TRACK,
},
- BrowsableMedia.FEATURED_PLAYLISTS.value: {
- "parent": MediaClass.DIRECTORY,
- "children": MediaClass.PLAYLIST,
- },
- BrowsableMedia.CATEGORIES.value: {
- "parent": MediaClass.DIRECTORY,
- "children": MediaClass.GENRE,
- },
- "category_playlists": {
- "parent": MediaClass.DIRECTORY,
- "children": MediaClass.PLAYLIST,
- },
BrowsableMedia.NEW_RELEASES.value: {
"parent": MediaClass.DIRECTORY,
"children": MediaClass.ALBUM,
@@ -354,32 +349,6 @@ async def build_item_response( # noqa: C901
elif media_content_type == BrowsableMedia.CURRENT_USER_TOP_TRACKS:
if top_tracks := await spotify.get_top_tracks():
items = [_get_track_item_payload(track) for track in top_tracks]
- elif media_content_type == BrowsableMedia.FEATURED_PLAYLISTS:
- if featured_playlists := await spotify.get_featured_playlists():
- items = [
- _get_playlist_item_payload(playlist) for playlist in featured_playlists
- ]
- elif media_content_type == BrowsableMedia.CATEGORIES:
- if categories := await spotify.get_categories():
- items = [
- {
- "id": category.category_id,
- "name": category.name,
- "type": "category_playlists",
- "uri": category.category_id,
- "thumbnail": category.icons[0].url if category.icons else None,
- }
- for category in categories
- ]
- elif media_content_type == "category_playlists":
- if (
- playlists := await spotify.get_category_playlists(
- category_id=media_content_id
- )
- ) and (category := await spotify.get_category(media_content_id)):
- title = category.name
- image = category.icons[0].url if category.icons else None
- items = [_get_playlist_item_payload(playlist) for playlist in playlists]
elif media_content_type == BrowsableMedia.NEW_RELEASES:
if new_releases := await spotify.get_new_releases():
items = [_get_album_item_payload(album) for album in new_releases]
@@ -387,10 +356,15 @@ async def build_item_response( # noqa: C901
if playlist := await spotify.get_playlist(media_content_id):
title = playlist.name
image = playlist.images[0].url if playlist.images else None
- items = [
- _get_track_item_payload(playlist_track.track)
- for playlist_track in playlist.tracks.items
- ]
+ for playlist_item in playlist.tracks.items:
+ if playlist_item.track.type is ItemType.TRACK:
+ if TYPE_CHECKING:
+ assert isinstance(playlist_item.track, Track)
+ 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)
+ items.append(_get_episode_item_payload(playlist_item.track))
elif media_content_type == MediaType.ALBUM:
if album := await spotify.get_album(media_content_id):
title = album.name
@@ -412,16 +386,7 @@ async def build_item_response( # noqa: C901
):
title = show.name
image = show.images[0].url if show.images else None
- items = [
- {
- "id": episode.episode_id,
- "name": episode.name,
- "type": MediaType.EPISODE,
- "uri": episode.uri,
- "thumbnail": fetch_image_url(episode.images),
- }
- for episode in show_episodes
- ]
+ items = [_get_episode_item_payload(episode) for episode in show_episodes]
try:
media_class = CONTENT_TYPE_MEDIA_CLASS[media_content_type]
@@ -429,36 +394,6 @@ async def build_item_response( # noqa: C901
_LOGGER.debug("Unknown media type received: %s", media_content_type)
return None
- if media_content_type == BrowsableMedia.CATEGORIES:
- media_item = BrowseMedia(
- can_expand=True,
- can_play=False,
- children_media_class=media_class["children"],
- media_class=media_class["parent"],
- media_content_id=media_content_id,
- media_content_type=f"{MEDIA_PLAYER_PREFIX}{media_content_type}",
- title=LIBRARY_MAP.get(media_content_id, "Unknown"),
- )
-
- media_item.children = []
- for item in items:
- if (item_id := item["id"]) is None:
- _LOGGER.debug("Missing ID for media item: %s", item)
- continue
- media_item.children.append(
- BrowseMedia(
- can_expand=True,
- can_play=False,
- children_media_class=MediaClass.TRACK,
- media_class=MediaClass.PLAYLIST,
- media_content_id=item_id,
- media_content_type=f"{MEDIA_PLAYER_PREFIX}category_playlists",
- thumbnail=item["thumbnail"],
- title=item["name"],
- )
- )
- return media_item
-
if title is None:
title = LIBRARY_MAP.get(media_content_id, "Unknown")
diff --git a/homeassistant/components/spotify/coordinator.py b/homeassistant/components/spotify/coordinator.py
index 9e62d5f137e..099b1cb3ca8 100644
--- a/homeassistant/components/spotify/coordinator.py
+++ b/homeassistant/components/spotify/coordinator.py
@@ -7,14 +7,13 @@ from typing import TYPE_CHECKING
from spotifyaio import (
ContextType,
- ItemType,
PlaybackState,
Playlist,
SpotifyClient,
SpotifyConnectionError,
+ SpotifyNotFoundError,
UserProfile,
)
-from spotifyaio.models import AudioFeatures
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
@@ -39,7 +38,6 @@ class SpotifyCoordinatorData:
current_playback: PlaybackState | None
position_updated_at: datetime | None
playlist: Playlist | None
- audio_features: AudioFeatures | None
dj_playlist: bool = False
@@ -65,7 +63,7 @@ class SpotifyCoordinator(DataUpdateCoordinator[SpotifyCoordinatorData]):
)
self.client = client
self._playlist: Playlist | None = None
- self._currently_loaded_track: str | None = None
+ self._checked_playlist_id: str | None = None
async def _async_setup(self) -> None:
"""Set up the coordinator."""
@@ -84,39 +82,36 @@ class SpotifyCoordinator(DataUpdateCoordinator[SpotifyCoordinatorData]):
current_playback=None,
position_updated_at=None,
playlist=None,
- audio_features=None,
)
# Record the last updated time, because Spotify's timestamp property is unreliable
# and doesn't actually return the fetch time as is mentioned in the API description
position_updated_at = dt_util.utcnow()
- audio_features: AudioFeatures | None = None
- if (item := current.item) is not None and item.type == ItemType.TRACK:
- if item.uri != self._currently_loaded_track:
- try:
- audio_features = await self.client.get_audio_features(item.uri)
- except SpotifyConnectionError:
- _LOGGER.debug(
- "Unable to load audio features for track '%s'. "
- "Continuing without audio features",
- item.uri,
- )
- audio_features = None
- else:
- self._currently_loaded_track = item.uri
- else:
- audio_features = self.data.audio_features
dj_playlist = False
if (context := current.context) is not None:
- if self._playlist is None or self._playlist.uri != context.uri:
+ dj_playlist = context.uri == SPOTIFY_DJ_PLAYLIST_URI
+ if not (
+ context.uri
+ in (
+ self._checked_playlist_id,
+ SPOTIFY_DJ_PLAYLIST_URI,
+ )
+ or (self._playlist is None and context.uri == self._checked_playlist_id)
+ ):
+ self._checked_playlist_id = context.uri
self._playlist = None
- if context.uri == SPOTIFY_DJ_PLAYLIST_URI:
- dj_playlist = True
- elif context.context_type == ContextType.PLAYLIST:
+ if context.context_type == ContextType.PLAYLIST:
# Make sure any playlist lookups don't break the current
# playback state update
try:
self._playlist = await self.client.get_playlist(context.uri)
+ except SpotifyNotFoundError:
+ _LOGGER.debug(
+ "Spotify playlist '%s' not found. "
+ "Most likely a Spotify-created playlist",
+ context.uri,
+ )
+ self._playlist = None
except SpotifyConnectionError:
_LOGGER.debug(
"Unable to load spotify playlist '%s'. "
@@ -124,10 +119,10 @@ class SpotifyCoordinator(DataUpdateCoordinator[SpotifyCoordinatorData]):
context.uri,
)
self._playlist = None
+ self._checked_playlist_id = None
return SpotifyCoordinatorData(
current_playback=current,
position_updated_at=position_updated_at,
playlist=self._playlist,
- audio_features=audio_features,
dj_playlist=dj_playlist,
)
diff --git a/homeassistant/components/spotify/icons.json b/homeassistant/components/spotify/icons.json
index e1b08127e43..00c63141eae 100644
--- a/homeassistant/components/spotify/icons.json
+++ b/homeassistant/components/spotify/icons.json
@@ -4,41 +4,6 @@
"spotify": {
"default": "mdi:spotify"
}
- },
- "sensor": {
- "song_tempo": {
- "default": "mdi:metronome"
- },
- "danceability": {
- "default": "mdi:dance-ballroom"
- },
- "energy": {
- "default": "mdi:lightning-bolt"
- },
- "mode": {
- "default": "mdi:music"
- },
- "speechiness": {
- "default": "mdi:speaker-message"
- },
- "acousticness": {
- "default": "mdi:guitar-acoustic"
- },
- "instrumentalness": {
- "default": "mdi:guitar-electric"
- },
- "valence": {
- "default": "mdi:emoticon-happy"
- },
- "liveness": {
- "default": "mdi:music-note"
- },
- "time_signature": {
- "default": "mdi:music-clef-treble"
- },
- "key": {
- "default": "mdi:music-clef-treble"
- }
}
}
}
diff --git a/homeassistant/components/spotify/manifest.json b/homeassistant/components/spotify/manifest.json
index afe352904ce..27b8da7cecf 100644
--- a/homeassistant/components/spotify/manifest.json
+++ b/homeassistant/components/spotify/manifest.json
@@ -7,8 +7,7 @@
"documentation": "https://www.home-assistant.io/integrations/spotify",
"integration_type": "service",
"iot_class": "cloud_polling",
- "loggers": ["spotipy"],
- "quality_scale": "silver",
- "requirements": ["spotifyaio==0.8.7"],
+ "loggers": ["spotifyaio"],
+ "requirements": ["spotifyaio==0.8.11"],
"zeroconf": ["_spotify-connect._tcp.local."]
}
diff --git a/homeassistant/components/spotify/media_player.py b/homeassistant/components/spotify/media_player.py
index 7687936fe4c..20a634efb42 100644
--- a/homeassistant/components/spotify/media_player.py
+++ b/homeassistant/components/spotify/media_player.py
@@ -361,6 +361,8 @@ class SpotifyMediaPlayer(SpotifyEntity, MediaPlayerEntity):
"""Select playback device."""
for device in self.devices.data:
if device.name == source:
+ if TYPE_CHECKING:
+ assert device.device_id is not None
await self.coordinator.client.transfer_playback(device.device_id)
return
diff --git a/homeassistant/components/spotify/sensor.py b/homeassistant/components/spotify/sensor.py
deleted file mode 100644
index 3486a911b0d..00000000000
--- a/homeassistant/components/spotify/sensor.py
+++ /dev/null
@@ -1,179 +0,0 @@
-"""Sensor platform for Spotify."""
-
-from collections.abc import Callable
-from dataclasses import dataclass
-
-from spotifyaio.models import AudioFeatures, Key
-
-from homeassistant.components.sensor import (
- SensorDeviceClass,
- SensorEntity,
- SensorEntityDescription,
-)
-from homeassistant.const import PERCENTAGE
-from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
-
-from .coordinator import SpotifyConfigEntry, SpotifyCoordinator
-from .entity import SpotifyEntity
-
-
-@dataclass(frozen=True, kw_only=True)
-class SpotifyAudioFeaturesSensorEntityDescription(SensorEntityDescription):
- """Describes Spotify sensor entity."""
-
- value_fn: Callable[[AudioFeatures], float | str | None]
-
-
-KEYS: dict[Key, str] = {
- Key.C: "C",
- Key.C_SHARP_D_FLAT: "C♯/D♭",
- Key.D: "D",
- Key.D_SHARP_E_FLAT: "D♯/E♭",
- Key.E: "E",
- Key.F: "F",
- Key.F_SHARP_G_FLAT: "F♯/G♭",
- Key.G: "G",
- Key.G_SHARP_A_FLAT: "G♯/A♭",
- Key.A: "A",
- Key.A_SHARP_B_FLAT: "A♯/B♭",
- Key.B: "B",
-}
-
-KEY_OPTIONS = list(KEYS.values())
-
-
-def _get_key(audio_features: AudioFeatures) -> str | None:
- if audio_features.key is None:
- return None
- return KEYS[audio_features.key]
-
-
-AUDIO_FEATURE_SENSORS: tuple[SpotifyAudioFeaturesSensorEntityDescription, ...] = (
- SpotifyAudioFeaturesSensorEntityDescription(
- key="bpm",
- translation_key="song_tempo",
- native_unit_of_measurement="bpm",
- suggested_display_precision=0,
- value_fn=lambda audio_features: audio_features.tempo,
- ),
- SpotifyAudioFeaturesSensorEntityDescription(
- key="danceability",
- translation_key="danceability",
- native_unit_of_measurement=PERCENTAGE,
- suggested_display_precision=0,
- value_fn=lambda audio_features: audio_features.danceability * 100,
- entity_registry_enabled_default=False,
- ),
- SpotifyAudioFeaturesSensorEntityDescription(
- key="energy",
- translation_key="energy",
- native_unit_of_measurement=PERCENTAGE,
- suggested_display_precision=0,
- value_fn=lambda audio_features: audio_features.energy * 100,
- entity_registry_enabled_default=False,
- ),
- SpotifyAudioFeaturesSensorEntityDescription(
- key="mode",
- translation_key="mode",
- device_class=SensorDeviceClass.ENUM,
- options=["major", "minor"],
- value_fn=lambda audio_features: audio_features.mode.name.lower(),
- entity_registry_enabled_default=False,
- ),
- SpotifyAudioFeaturesSensorEntityDescription(
- key="speechiness",
- translation_key="speechiness",
- native_unit_of_measurement=PERCENTAGE,
- suggested_display_precision=0,
- value_fn=lambda audio_features: audio_features.speechiness * 100,
- entity_registry_enabled_default=False,
- ),
- SpotifyAudioFeaturesSensorEntityDescription(
- key="acousticness",
- translation_key="acousticness",
- native_unit_of_measurement=PERCENTAGE,
- suggested_display_precision=0,
- value_fn=lambda audio_features: audio_features.acousticness * 100,
- entity_registry_enabled_default=False,
- ),
- SpotifyAudioFeaturesSensorEntityDescription(
- key="instrumentalness",
- translation_key="instrumentalness",
- native_unit_of_measurement=PERCENTAGE,
- suggested_display_precision=0,
- value_fn=lambda audio_features: audio_features.instrumentalness * 100,
- entity_registry_enabled_default=False,
- ),
- SpotifyAudioFeaturesSensorEntityDescription(
- key="liveness",
- translation_key="liveness",
- native_unit_of_measurement=PERCENTAGE,
- suggested_display_precision=0,
- value_fn=lambda audio_features: audio_features.liveness * 100,
- entity_registry_enabled_default=False,
- ),
- SpotifyAudioFeaturesSensorEntityDescription(
- key="valence",
- translation_key="valence",
- native_unit_of_measurement=PERCENTAGE,
- suggested_display_precision=0,
- value_fn=lambda audio_features: audio_features.valence * 100,
- entity_registry_enabled_default=False,
- ),
- SpotifyAudioFeaturesSensorEntityDescription(
- key="time_signature",
- translation_key="time_signature",
- device_class=SensorDeviceClass.ENUM,
- options=["3/4", "4/4", "5/4", "6/4", "7/4"],
- value_fn=lambda audio_features: f"{audio_features.time_signature}/4",
- entity_registry_enabled_default=False,
- ),
- SpotifyAudioFeaturesSensorEntityDescription(
- key="key",
- translation_key="key",
- device_class=SensorDeviceClass.ENUM,
- options=KEY_OPTIONS,
- value_fn=_get_key,
- entity_registry_enabled_default=False,
- ),
-)
-
-
-async def async_setup_entry(
- hass: HomeAssistant,
- entry: SpotifyConfigEntry,
- async_add_entities: AddEntitiesCallback,
-) -> None:
- """Set up Spotify sensor based on a config entry."""
- coordinator = entry.runtime_data.coordinator
-
- async_add_entities(
- SpotifyAudioFeatureSensor(coordinator, description)
- for description in AUDIO_FEATURE_SENSORS
- )
-
-
-class SpotifyAudioFeatureSensor(SpotifyEntity, SensorEntity):
- """Representation of a Spotify sensor."""
-
- entity_description: SpotifyAudioFeaturesSensorEntityDescription
-
- def __init__(
- self,
- coordinator: SpotifyCoordinator,
- entity_description: SpotifyAudioFeaturesSensorEntityDescription,
- ) -> None:
- """Initialize."""
- super().__init__(coordinator)
- self._attr_unique_id = (
- f"{coordinator.current_user.user_id}_{entity_description.key}"
- )
- self.entity_description = entity_description
-
- @property
- def native_value(self) -> float | str | None:
- """Return the state of the sensor."""
- if (audio_features := self.coordinator.data.audio_features) is None:
- return None
- return self.entity_description.value_fn(audio_features)
diff --git a/homeassistant/components/spotify/strings.json b/homeassistant/components/spotify/strings.json
index faf20d740d9..90e573a1706 100644
--- a/homeassistant/components/spotify/strings.json
+++ b/homeassistant/components/spotify/strings.json
@@ -30,46 +30,5 @@
"info": {
"api_endpoint_reachable": "Spotify API endpoint reachable"
}
- },
- "entity": {
- "sensor": {
- "song_tempo": {
- "name": "Song tempo"
- },
- "danceability": {
- "name": "Song danceability"
- },
- "energy": {
- "name": "Song energy"
- },
- "mode": {
- "name": "Song mode",
- "state": {
- "minor": "Minor",
- "major": "Major"
- }
- },
- "speechiness": {
- "name": "Song speechiness"
- },
- "acousticness": {
- "name": "Song acousticness"
- },
- "instrumentalness": {
- "name": "Song instrumentalness"
- },
- "valence": {
- "name": "Song valence"
- },
- "liveness": {
- "name": "Song liveness"
- },
- "time_signature": {
- "name": "Song time signature"
- },
- "key": {
- "name": "Song key"
- }
- }
}
}
diff --git a/homeassistant/components/sql/manifest.json b/homeassistant/components/sql/manifest.json
index dcb5f47829c..01c95d6c5e4 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.31", "sqlparse==0.5.0"]
+ "requirements": ["SQLAlchemy==2.0.36", "sqlparse==0.5.0"]
}
diff --git a/homeassistant/components/sql/sensor.py b/homeassistant/components/sql/sensor.py
index 1d033728c0d..312b0cd345e 100644
--- a/homeassistant/components/sql/sensor.py
+++ b/homeassistant/components/sql/sensor.py
@@ -331,9 +331,16 @@ class SQLSensor(ManualTriggerSensorEntity):
entry_type=DeviceEntryType.SERVICE,
identifiers={(DOMAIN, unique_id)},
manufacturer="SQL",
- name=self.name,
+ name=self._rendered.get(CONF_NAME),
)
+ @property
+ def name(self) -> str | None:
+ """Name of the entity."""
+ if self.has_entity_name:
+ return self._attr_name
+ return self._rendered.get(CONF_NAME)
+
async def async_added_to_hass(self) -> None:
"""Call when entity about to be added to hass."""
await super().async_added_to_hass()
diff --git a/homeassistant/components/squeezebox/browse_media.py b/homeassistant/components/squeezebox/browse_media.py
index 4d1c98bc4fc..331bf383c70 100644
--- a/homeassistant/components/squeezebox/browse_media.py
+++ b/homeassistant/components/squeezebox/browse_media.py
@@ -115,6 +115,7 @@ async def build_item_response(
item_type = CONTENT_TYPE_TO_CHILD_TYPE[search_type]
children = []
+ list_playable = []
for item in result["items"]:
item_id = str(item["id"])
item_thumbnail: str | None = None
@@ -131,7 +132,7 @@ async def build_item_response(
child_media_class = CONTENT_TYPE_MEDIA_CLASS[MediaType.ALBUM]
can_expand = True
can_play = True
- elif item["hasitems"]:
+ elif item["hasitems"] and not item["isaudio"]:
child_item_type = "Favorites"
child_media_class = CONTENT_TYPE_MEDIA_CLASS["Favorites"]
can_expand = True
@@ -139,8 +140,8 @@ async def build_item_response(
else:
child_item_type = "Favorites"
child_media_class = CONTENT_TYPE_MEDIA_CLASS[MediaType.TRACK]
- can_expand = False
- can_play = True
+ can_expand = item["hasitems"]
+ can_play = item["isaudio"] and item.get("url")
if artwork_track_id := item.get("artwork_track_id"):
if internal_request:
@@ -166,6 +167,7 @@ async def build_item_response(
thumbnail=item_thumbnail,
)
)
+ list_playable.append(can_play)
if children is None:
raise BrowseError(f"Media not found: {search_type} / {search_id}")
@@ -179,7 +181,7 @@ async def build_item_response(
children_media_class=media_class["children"],
media_content_id=search_id,
media_content_type=search_type,
- can_play=search_type != "Favorites",
+ can_play=any(list_playable),
children=children,
can_expand=True,
)
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/sensor.py b/homeassistant/components/squeezebox/sensor.py
index ff9f86ccf1f..0ca33179f9f 100644
--- a/homeassistant/components/squeezebox/sensor.py
+++ b/homeassistant/components/squeezebox/sensor.py
@@ -33,12 +33,10 @@ SENSORS: tuple[SensorEntityDescription, ...] = (
SensorEntityDescription(
key=STATUS_SENSOR_INFO_TOTAL_ALBUMS,
state_class=SensorStateClass.TOTAL,
- native_unit_of_measurement="albums",
),
SensorEntityDescription(
key=STATUS_SENSOR_INFO_TOTAL_ARTISTS,
state_class=SensorStateClass.TOTAL,
- native_unit_of_measurement="artists",
),
SensorEntityDescription(
key=STATUS_SENSOR_INFO_TOTAL_DURATION,
@@ -49,12 +47,10 @@ SENSORS: tuple[SensorEntityDescription, ...] = (
SensorEntityDescription(
key=STATUS_SENSOR_INFO_TOTAL_GENRES,
state_class=SensorStateClass.TOTAL,
- native_unit_of_measurement="genres",
),
SensorEntityDescription(
key=STATUS_SENSOR_INFO_TOTAL_SONGS,
state_class=SensorStateClass.TOTAL,
- native_unit_of_measurement="songs",
),
SensorEntityDescription(
key=STATUS_SENSOR_LASTSCAN,
@@ -63,13 +59,11 @@ SENSORS: tuple[SensorEntityDescription, ...] = (
SensorEntityDescription(
key=STATUS_SENSOR_PLAYER_COUNT,
state_class=SensorStateClass.TOTAL,
- native_unit_of_measurement="players",
),
SensorEntityDescription(
key=STATUS_SENSOR_OTHER_PLAYER_COUNT,
state_class=SensorStateClass.TOTAL,
entity_registry_visible_default=False,
- native_unit_of_measurement="players",
),
)
diff --git a/homeassistant/components/squeezebox/strings.json b/homeassistant/components/squeezebox/strings.json
index b1b71cd8c1d..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",
@@ -76,25 +76,31 @@
"name": "Last scan"
},
"info_total_albums": {
- "name": "Total albums"
+ "name": "Total albums",
+ "unit_of_measurement": "albums"
},
"info_total_artists": {
- "name": "Total artists"
+ "name": "Total artists",
+ "unit_of_measurement": "artists"
},
"info_total_duration": {
"name": "Total duration"
},
"info_total_genres": {
- "name": "Total genres"
+ "name": "Total genres",
+ "unit_of_measurement": "genres"
},
"info_total_songs": {
- "name": "Total songs"
+ "name": "Total songs",
+ "unit_of_measurement": "songs"
},
"player_count": {
- "name": "Player count"
+ "name": "Player count",
+ "unit_of_measurement": "players"
},
"other_player_count": {
- "name": "Player count off service"
+ "name": "Player count off service",
+ "unit_of_measurement": "[%key:component::squeezebox::entity::sensor::player_count::unit_of_measurement%]"
}
}
}
diff --git a/homeassistant/components/srp_energy/strings.json b/homeassistant/components/srp_energy/strings.json
index 191d10a70dd..eca4f465435 100644
--- a/homeassistant/components/srp_energy/strings.json
+++ b/homeassistant/components/srp_energy/strings.json
@@ -17,7 +17,8 @@
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"abort": {
- "already_configured": "[%key:common::config_flow::abort::already_configured_service%]"
+ "already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
+ "unknown": "Unexpected error"
}
},
"entity": {
diff --git a/homeassistant/components/ssdp/manifest.json b/homeassistant/components/ssdp/manifest.json
index e9d4f57d5fb..2632e37aa98 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.41.0"]
+ "requirements": ["async-upnp-client==0.42.0"]
}
diff --git a/homeassistant/components/starline/binary_sensor.py b/homeassistant/components/starline/binary_sensor.py
index 0383fc8ade6..69f0ae06d02 100644
--- a/homeassistant/components/starline/binary_sensor.py
+++ b/homeassistant/components/starline/binary_sensor.py
@@ -41,6 +41,11 @@ BINARY_SENSOR_TYPES: tuple[BinarySensorEntityDescription, ...] = (
translation_key="doors",
device_class=BinarySensorDeviceClass.LOCK,
),
+ BinarySensorEntityDescription(
+ key="run",
+ translation_key="is_running",
+ device_class=BinarySensorDeviceClass.RUNNING,
+ ),
BinarySensorEntityDescription(
key="hfree",
translation_key="handsfree",
diff --git a/homeassistant/components/starline/button.py b/homeassistant/components/starline/button.py
index ea1a27adc15..6fb307cda74 100644
--- a/homeassistant/components/starline/button.py
+++ b/homeassistant/components/starline/button.py
@@ -16,6 +16,20 @@ BUTTON_TYPES: tuple[ButtonEntityDescription, ...] = (
key="poke",
translation_key="horn",
),
+ ButtonEntityDescription(
+ key="panic",
+ translation_key="panic",
+ entity_registry_enabled_default=False,
+ ),
+ *[
+ ButtonEntityDescription(
+ key=f"flex_{i}",
+ translation_key="flex",
+ translation_placeholders={"num": str(i)},
+ entity_registry_enabled_default=False,
+ )
+ for i in range(1, 10)
+ ],
)
diff --git a/homeassistant/components/starline/config_flow.py b/homeassistant/components/starline/config_flow.py
index 5235bd5230b..a899b562f36 100644
--- a/homeassistant/components/starline/config_flow.py
+++ b/homeassistant/components/starline/config_flow.py
@@ -34,6 +34,7 @@ class StarlineFlowHandler(ConfigFlow, domain=DOMAIN):
_app_code: str
_app_token: str
_captcha_image: str
+ _phone_number: str
def __init__(self) -> None:
"""Initialize flow."""
@@ -49,7 +50,6 @@ class StarlineFlowHandler(ConfigFlow, domain=DOMAIN):
self._slnet_token_expires = None
self._captcha_sid: str | None = None
self._captcha_code: str | None = None
- self._phone_number = None
self._auth = StarlineAuth()
diff --git a/homeassistant/components/starline/icons.json b/homeassistant/components/starline/icons.json
index 8a4f85a89bf..d7d20ae03bd 100644
--- a/homeassistant/components/starline/icons.json
+++ b/homeassistant/components/starline/icons.json
@@ -12,11 +12,20 @@
},
"moving_ban": {
"default": "mdi:car-off"
+ },
+ "is_running": {
+ "default": "mdi:speedometer"
}
},
"button": {
"horn": {
"default": "mdi:bullhorn-outline"
+ },
+ "flex": {
+ "default": "mdi:star-circle-outline"
+ },
+ "panic": {
+ "default": "mdi:alarm-note"
}
},
"device_tracker": {
@@ -60,9 +69,6 @@
"on": "mdi:access-point-network"
}
},
- "horn": {
- "default": "mdi:bullhorn-outline"
- },
"service_mode": {
"default": "mdi:car-wrench",
"state": {
diff --git a/homeassistant/components/starline/strings.json b/homeassistant/components/starline/strings.json
index 14a8ed5a035..f292a74621c 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"
}
@@ -63,6 +63,9 @@
},
"moving_ban": {
"name": "Moving ban"
+ },
+ "is_running": {
+ "name": "Running"
}
},
"device_tracker": {
@@ -121,17 +124,23 @@
"button": {
"horn": {
"name": "Horn"
+ },
+ "flex": {
+ "name": "Flex logic {num}"
+ },
+ "panic": {
+ "name": "Panic mode"
}
}
},
"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",
@@ -141,7 +150,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 1b48a72c732..05193d98c8a 100644
--- a/homeassistant/components/starline/switch.py
+++ b/homeassistant/components/starline/switch.py
@@ -78,8 +78,6 @@ class StarlineSwitch(StarlineEntity, SwitchEntity):
@property
def is_on(self):
"""Return True if entity is on."""
- if self._key == "poke":
- return False
return self._device.car_state.get(self._key)
def turn_on(self, **kwargs: Any) -> None:
@@ -88,6 +86,4 @@ class StarlineSwitch(StarlineEntity, SwitchEntity):
def turn_off(self, **kwargs: Any) -> None:
"""Turn the entity off."""
- if self._key == "poke":
- return
self._account.api.set_car_state(self._device.device_id, self._key, False)
diff --git a/homeassistant/components/starlingbank/manifest.json b/homeassistant/components/starlingbank/manifest.json
index ef9be6d6da8..f7ab72c4379 100644
--- a/homeassistant/components/starlingbank/manifest.json
+++ b/homeassistant/components/starlingbank/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/starlingbank",
"iot_class": "cloud_polling",
"loggers": ["starlingbank"],
+ "quality_scale": "legacy",
"requirements": ["starlingbank==3.2"]
}
diff --git a/homeassistant/components/starlink/coordinator.py b/homeassistant/components/starlink/coordinator.py
index a891941fb8e..89d03a4fadc 100644
--- a/homeassistant/components/starlink/coordinator.py
+++ b/homeassistant/components/starlink/coordinator.py
@@ -14,8 +14,11 @@ from starlink_grpc import (
GrpcError,
LocationDict,
ObstructionDict,
+ PowerDict,
StatusDict,
+ UsageDict,
get_sleep_config,
+ history_stats,
location_data,
reboot,
set_sleep_config,
@@ -39,6 +42,8 @@ class StarlinkData:
status: StatusDict
obstruction: ObstructionDict
alert: AlertDict
+ usage: UsageDict
+ consumption: PowerDict
class StarlinkUpdateCoordinator(DataUpdateCoordinator[StarlinkData]):
@@ -57,11 +62,15 @@ class StarlinkUpdateCoordinator(DataUpdateCoordinator[StarlinkData]):
def _get_starlink_data(self) -> StarlinkData:
"""Retrieve Starlink data."""
- channel_context = self.channel_context
- status = status_data(channel_context)
- location = location_data(channel_context)
- sleep = get_sleep_config(channel_context)
- return StarlinkData(location, sleep, *status)
+ context = self.channel_context
+ status = status_data(context)
+ 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:]
+ return StarlinkData(
+ location, sleep, status, obstruction, alert, usage, consumption
+ )
async def _async_update_data(self) -> StarlinkData:
async with asyncio.timeout(4):
diff --git a/homeassistant/components/starlink/icons.json b/homeassistant/components/starlink/icons.json
index 65cb273e24b..02de62aeb8a 100644
--- a/homeassistant/components/starlink/icons.json
+++ b/homeassistant/components/starlink/icons.json
@@ -18,6 +18,12 @@
},
"last_boot_time": {
"default": "mdi:clock"
+ },
+ "upload": {
+ "default": "mdi:upload"
+ },
+ "download": {
+ "default": "mdi:download"
}
}
}
diff --git a/homeassistant/components/starlink/manifest.json b/homeassistant/components/starlink/manifest.json
index b8733dd2435..15bad3ebc2e 100644
--- a/homeassistant/components/starlink/manifest.json
+++ b/homeassistant/components/starlink/manifest.json
@@ -5,6 +5,5 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/starlink",
"iot_class": "local_polling",
- "quality_scale": "silver",
- "requirements": ["starlink-grpc-core==1.1.3"]
+ "requirements": ["starlink-grpc-core==1.2.2"]
}
diff --git a/homeassistant/components/starlink/sensor.py b/homeassistant/components/starlink/sensor.py
index 21f2400022c..5481e310fbd 100644
--- a/homeassistant/components/starlink/sensor.py
+++ b/homeassistant/components/starlink/sensor.py
@@ -18,6 +18,9 @@ from homeassistant.const import (
PERCENTAGE,
EntityCategory,
UnitOfDataRate,
+ UnitOfEnergy,
+ UnitOfInformation,
+ UnitOfPower,
UnitOfTime,
)
from homeassistant.core import HomeAssistant
@@ -120,4 +123,36 @@ SENSORS: tuple[StarlinkSensorEntityDescription, ...] = (
native_unit_of_measurement=PERCENTAGE,
value_fn=lambda data: data.status["pop_ping_drop_rate"] * 100,
),
+ StarlinkSensorEntityDescription(
+ key="upload",
+ translation_key="upload",
+ device_class=SensorDeviceClass.DATA_SIZE,
+ state_class=SensorStateClass.TOTAL_INCREASING,
+ native_unit_of_measurement=UnitOfInformation.BYTES,
+ suggested_unit_of_measurement=UnitOfInformation.GIGABYTES,
+ value_fn=lambda data: data.usage["upload_usage"],
+ ),
+ StarlinkSensorEntityDescription(
+ key="download",
+ translation_key="download",
+ device_class=SensorDeviceClass.DATA_SIZE,
+ state_class=SensorStateClass.TOTAL_INCREASING,
+ native_unit_of_measurement=UnitOfInformation.BYTES,
+ suggested_unit_of_measurement=UnitOfInformation.GIGABYTES,
+ value_fn=lambda data: data.usage["download_usage"],
+ ),
+ StarlinkSensorEntityDescription(
+ key="power",
+ device_class=SensorDeviceClass.POWER,
+ state_class=SensorStateClass.MEASUREMENT,
+ native_unit_of_measurement=UnitOfPower.WATT,
+ value_fn=lambda data: data.consumption["latest_power"],
+ ),
+ StarlinkSensorEntityDescription(
+ key="energy",
+ device_class=SensorDeviceClass.ENERGY,
+ state_class=SensorStateClass.TOTAL_INCREASING,
+ native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
+ value_fn=lambda data: data.consumption["total_energy"],
+ ),
)
diff --git a/homeassistant/components/starlink/strings.json b/homeassistant/components/starlink/strings.json
index 36a4f176e70..395b6288c71 100644
--- a/homeassistant/components/starlink/strings.json
+++ b/homeassistant/components/starlink/strings.json
@@ -70,6 +70,12 @@
},
"ping_drop_rate": {
"name": "Ping drop rate"
+ },
+ "upload": {
+ "name": "Upload"
+ },
+ "download": {
+ "name": "Download"
}
},
"switch": {
diff --git a/homeassistant/components/startca/manifest.json b/homeassistant/components/startca/manifest.json
index 8c74a655ce3..958477c193b 100644
--- a/homeassistant/components/startca/manifest.json
+++ b/homeassistant/components/startca/manifest.json
@@ -4,5 +4,6 @@
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/startca",
"iot_class": "cloud_polling",
+ "quality_scale": "legacy",
"requirements": ["xmltodict==0.13.0"]
}
diff --git a/homeassistant/components/statistics/config_flow.py b/homeassistant/components/statistics/config_flow.py
index 4280c92131a..4c78afbde9c 100644
--- a/homeassistant/components/statistics/config_flow.py
+++ b/homeassistant/components/statistics/config_flow.py
@@ -57,9 +57,9 @@ async def get_state_characteristics(handler: SchemaCommonFlowHandler) -> vol.Sch
split_entity_id(handler.options[CONF_ENTITY_ID])[0] == BINARY_SENSOR_DOMAIN
)
if is_binary:
- options = STATS_BINARY_SUPPORT
+ options = list(STATS_BINARY_SUPPORT)
else:
- options = STATS_NUMERIC_SUPPORT
+ options = list(STATS_NUMERIC_SUPPORT)
return vol.Schema(
{
diff --git a/homeassistant/components/statistics/manifest.json b/homeassistant/components/statistics/manifest.json
index 24d4b4914cb..8eaed552edd 100644
--- a/homeassistant/components/statistics/manifest.json
+++ b/homeassistant/components/statistics/manifest.json
@@ -2,7 +2,7 @@
"domain": "statistics",
"name": "Statistics",
"after_dependencies": ["recorder"],
- "codeowners": ["@ThomDietrich"],
+ "codeowners": ["@ThomDietrich", "@gjohansson-ST"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/statistics",
"integration_type": "helper",
diff --git a/homeassistant/components/statistics/sensor.py b/homeassistant/components/statistics/sensor.py
index 50d07d4e466..5252c23fd3d 100644
--- a/homeassistant/components/statistics/sensor.py
+++ b/homeassistant/components/statistics/sensor.py
@@ -9,6 +9,7 @@ from datetime import datetime, timedelta
import logging
import math
import statistics
+import time
from typing import Any, cast
import voluptuous as vol
@@ -53,7 +54,7 @@ from homeassistant.helpers.event import (
async_track_state_report_event,
)
from homeassistant.helpers.reload import async_setup_reload_service
-from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType
+from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import dt as dt_util
from homeassistant.util.enum import try_parse_enum
@@ -97,47 +98,373 @@ STAT_VALUE_MAX = "value_max"
STAT_VALUE_MIN = "value_min"
STAT_VARIANCE = "variance"
+
+def _callable_characteristic_fn(
+ characteristic: str, binary: bool
+) -> Callable[[deque[bool | float], deque[float], int], float | int | datetime | None]:
+ """Return the function callable of one characteristic function."""
+ Callable[[deque[bool | float], deque[datetime], int], datetime | int | float | None]
+ if binary:
+ return STATS_BINARY_SUPPORT[characteristic]
+ return STATS_NUMERIC_SUPPORT[characteristic]
+
+
+# Statistics for numeric sensor
+
+
+def _stat_average_linear(
+ states: deque[bool | float], ages: deque[float], percentile: int
+) -> float | None:
+ if len(states) == 1:
+ return states[0]
+ if len(states) >= 2:
+ area: float = 0
+ for i in range(1, len(states)):
+ area += 0.5 * (states[i] + states[i - 1]) * (ages[i] - ages[i - 1])
+ age_range_seconds = ages[-1] - ages[0]
+ return area / age_range_seconds
+ return None
+
+
+def _stat_average_step(
+ states: deque[bool | float], ages: deque[float], percentile: int
+) -> float | None:
+ if len(states) == 1:
+ return states[0]
+ if len(states) >= 2:
+ area: float = 0
+ for i in range(1, len(states)):
+ area += states[i - 1] * (ages[i] - ages[i - 1])
+ age_range_seconds = ages[-1] - ages[0]
+ return area / age_range_seconds
+ return None
+
+
+def _stat_average_timeless(
+ states: deque[bool | float], ages: deque[float], percentile: int
+) -> float | None:
+ return _stat_mean(states, ages, percentile)
+
+
+def _stat_change(
+ states: deque[bool | float], ages: deque[float], percentile: int
+) -> float | None:
+ if len(states) > 0:
+ return states[-1] - states[0]
+ return None
+
+
+def _stat_change_sample(
+ states: deque[bool | float], ages: deque[float], percentile: int
+) -> float | None:
+ if len(states) > 1:
+ return (states[-1] - states[0]) / (len(states) - 1)
+ return None
+
+
+def _stat_change_second(
+ states: deque[bool | float], ages: deque[float], percentile: int
+) -> float | None:
+ if len(states) > 1:
+ age_range_seconds = ages[-1] - ages[0]
+ if age_range_seconds > 0:
+ return (states[-1] - states[0]) / age_range_seconds
+ return None
+
+
+def _stat_count(
+ states: deque[bool | float], ages: deque[float], percentile: int
+) -> int | None:
+ return len(states)
+
+
+def _stat_datetime_newest(
+ states: deque[bool | float], ages: deque[float], percentile: int
+) -> datetime | None:
+ if len(states) > 0:
+ return dt_util.utc_from_timestamp(ages[-1])
+ return None
+
+
+def _stat_datetime_oldest(
+ states: deque[bool | float], ages: deque[float], percentile: int
+) -> datetime | None:
+ if len(states) > 0:
+ return dt_util.utc_from_timestamp(ages[0])
+ return None
+
+
+def _stat_datetime_value_max(
+ states: deque[bool | float], ages: deque[float], percentile: int
+) -> datetime | None:
+ if len(states) > 0:
+ return dt_util.utc_from_timestamp(ages[states.index(max(states))])
+ return None
+
+
+def _stat_datetime_value_min(
+ states: deque[bool | float], ages: deque[float], percentile: int
+) -> datetime | None:
+ if len(states) > 0:
+ return dt_util.utc_from_timestamp(ages[states.index(min(states))])
+ return None
+
+
+def _stat_distance_95_percent_of_values(
+ states: deque[bool | float], ages: deque[float], percentile: int
+) -> float | None:
+ if len(states) >= 1:
+ return (
+ 2 * 1.96 * cast(float, _stat_standard_deviation(states, ages, percentile))
+ )
+ return None
+
+
+def _stat_distance_99_percent_of_values(
+ states: deque[bool | float], ages: deque[float], percentile: int
+) -> float | None:
+ if len(states) >= 1:
+ return (
+ 2 * 2.58 * cast(float, _stat_standard_deviation(states, ages, percentile))
+ )
+ return None
+
+
+def _stat_distance_absolute(
+ states: deque[bool | float], ages: deque[float], percentile: int
+) -> float | None:
+ if len(states) > 0:
+ return max(states) - min(states)
+ return None
+
+
+def _stat_mean(
+ states: deque[bool | float], ages: deque[float], percentile: int
+) -> float | None:
+ if len(states) > 0:
+ return statistics.mean(states)
+ return None
+
+
+def _stat_mean_circular(
+ states: deque[bool | float], ages: deque[float], percentile: int
+) -> float | None:
+ if len(states) > 0:
+ sin_sum = sum(math.sin(math.radians(x)) for x in states)
+ cos_sum = sum(math.cos(math.radians(x)) for x in states)
+ return (math.degrees(math.atan2(sin_sum, cos_sum)) + 360) % 360
+ return None
+
+
+def _stat_median(
+ states: deque[bool | float], ages: deque[float], percentile: int
+) -> float | None:
+ if len(states) > 0:
+ return statistics.median(states)
+ return None
+
+
+def _stat_noisiness(
+ states: deque[bool | float], ages: deque[float], percentile: int
+) -> float | None:
+ if len(states) == 1:
+ return 0.0
+ if len(states) >= 2:
+ return cast(float, _stat_sum_differences(states, ages, percentile)) / (
+ len(states) - 1
+ )
+ return None
+
+
+def _stat_percentile(
+ states: deque[bool | float], ages: deque[float], percentile: int
+) -> float | None:
+ if len(states) == 1:
+ return states[0]
+ if len(states) >= 2:
+ percentiles = statistics.quantiles(states, n=100, method="exclusive")
+ return percentiles[percentile - 1]
+ return None
+
+
+def _stat_standard_deviation(
+ states: deque[bool | float], ages: deque[float], percentile: int
+) -> float | None:
+ if len(states) == 1:
+ return 0.0
+ if len(states) >= 2:
+ return statistics.stdev(states)
+ return None
+
+
+def _stat_sum(
+ states: deque[bool | float], ages: deque[float], percentile: int
+) -> float | None:
+ if len(states) > 0:
+ return sum(states)
+ return None
+
+
+def _stat_sum_differences(
+ states: deque[bool | float], ages: deque[float], percentile: int
+) -> float | None:
+ if len(states) == 1:
+ return 0.0
+ if len(states) >= 2:
+ return sum(
+ abs(j - i) for i, j in zip(list(states), list(states)[1:], strict=False)
+ )
+ return None
+
+
+def _stat_sum_differences_nonnegative(
+ states: deque[bool | float], ages: deque[float], percentile: int
+) -> float | None:
+ if len(states) == 1:
+ return 0.0
+ if len(states) >= 2:
+ return sum(
+ (j - i if j >= i else j - 0)
+ for i, j in zip(list(states), list(states)[1:], strict=False)
+ )
+ return None
+
+
+def _stat_total(
+ states: deque[bool | float], ages: deque[float], percentile: int
+) -> float | None:
+ return _stat_sum(states, ages, percentile)
+
+
+def _stat_value_max(
+ states: deque[bool | float], ages: deque[float], percentile: int
+) -> float | None:
+ if len(states) > 0:
+ return max(states)
+ return None
+
+
+def _stat_value_min(
+ states: deque[bool | float], ages: deque[float], percentile: int
+) -> float | None:
+ if len(states) > 0:
+ return min(states)
+ return None
+
+
+def _stat_variance(
+ states: deque[bool | float], ages: deque[float], percentile: int
+) -> float | None:
+ if len(states) == 1:
+ return 0.0
+ if len(states) >= 2:
+ return statistics.variance(states)
+ return None
+
+
+# Statistics for binary sensor
+
+
+def _stat_binary_average_step(
+ states: deque[bool | float], ages: deque[float], percentile: int
+) -> float | None:
+ if len(states) == 1:
+ return 100.0 * int(states[0] is True)
+ if len(states) >= 2:
+ on_seconds: float = 0
+ for i in range(1, len(states)):
+ if states[i - 1] is True:
+ on_seconds += ages[i] - ages[i - 1]
+ age_range_seconds = ages[-1] - ages[0]
+ return 100 / age_range_seconds * on_seconds
+ return None
+
+
+def _stat_binary_average_timeless(
+ states: deque[bool | float], ages: deque[float], percentile: int
+) -> float | None:
+ return _stat_binary_mean(states, ages, percentile)
+
+
+def _stat_binary_count(
+ states: deque[bool | float], ages: deque[float], percentile: int
+) -> int | None:
+ return len(states)
+
+
+def _stat_binary_count_on(
+ states: deque[bool | float], ages: deque[float], percentile: int
+) -> int | None:
+ return states.count(True)
+
+
+def _stat_binary_count_off(
+ states: deque[bool | float], ages: deque[float], percentile: int
+) -> int | None:
+ return states.count(False)
+
+
+def _stat_binary_datetime_newest(
+ states: deque[bool | float], ages: deque[float], percentile: int
+) -> datetime | None:
+ return _stat_datetime_newest(states, ages, percentile)
+
+
+def _stat_binary_datetime_oldest(
+ states: deque[bool | float], ages: deque[float], percentile: int
+) -> datetime | None:
+ return _stat_datetime_oldest(states, ages, percentile)
+
+
+def _stat_binary_mean(
+ states: deque[bool | float], ages: deque[float], percentile: int
+) -> float | None:
+ if len(states) > 0:
+ return 100.0 / len(states) * states.count(True)
+ return None
+
+
# Statistics supported by a sensor source (numeric)
STATS_NUMERIC_SUPPORT = {
- STAT_AVERAGE_LINEAR,
- STAT_AVERAGE_STEP,
- STAT_AVERAGE_TIMELESS,
- STAT_CHANGE_SAMPLE,
- STAT_CHANGE_SECOND,
- STAT_CHANGE,
- STAT_COUNT,
- STAT_DATETIME_NEWEST,
- STAT_DATETIME_OLDEST,
- STAT_DATETIME_VALUE_MAX,
- STAT_DATETIME_VALUE_MIN,
- STAT_DISTANCE_95P,
- STAT_DISTANCE_99P,
- STAT_DISTANCE_ABSOLUTE,
- STAT_MEAN,
- STAT_MEAN_CIRCULAR,
- STAT_MEDIAN,
- STAT_NOISINESS,
- STAT_PERCENTILE,
- STAT_STANDARD_DEVIATION,
- STAT_SUM,
- STAT_SUM_DIFFERENCES,
- STAT_SUM_DIFFERENCES_NONNEGATIVE,
- STAT_TOTAL,
- STAT_VALUE_MAX,
- STAT_VALUE_MIN,
- STAT_VARIANCE,
+ STAT_AVERAGE_LINEAR: _stat_average_linear,
+ STAT_AVERAGE_STEP: _stat_average_step,
+ STAT_AVERAGE_TIMELESS: _stat_average_timeless,
+ STAT_CHANGE_SAMPLE: _stat_change_sample,
+ STAT_CHANGE_SECOND: _stat_change_second,
+ STAT_CHANGE: _stat_change,
+ STAT_COUNT: _stat_count,
+ STAT_DATETIME_NEWEST: _stat_datetime_newest,
+ STAT_DATETIME_OLDEST: _stat_datetime_oldest,
+ STAT_DATETIME_VALUE_MAX: _stat_datetime_value_max,
+ STAT_DATETIME_VALUE_MIN: _stat_datetime_value_min,
+ STAT_DISTANCE_95P: _stat_distance_95_percent_of_values,
+ STAT_DISTANCE_99P: _stat_distance_99_percent_of_values,
+ STAT_DISTANCE_ABSOLUTE: _stat_distance_absolute,
+ STAT_MEAN: _stat_mean,
+ STAT_MEAN_CIRCULAR: _stat_mean_circular,
+ STAT_MEDIAN: _stat_median,
+ STAT_NOISINESS: _stat_noisiness,
+ STAT_PERCENTILE: _stat_percentile,
+ STAT_STANDARD_DEVIATION: _stat_standard_deviation,
+ STAT_SUM: _stat_sum,
+ STAT_SUM_DIFFERENCES: _stat_sum_differences,
+ STAT_SUM_DIFFERENCES_NONNEGATIVE: _stat_sum_differences_nonnegative,
+ STAT_TOTAL: _stat_total,
+ STAT_VALUE_MAX: _stat_value_max,
+ STAT_VALUE_MIN: _stat_value_min,
+ STAT_VARIANCE: _stat_variance,
}
# Statistics supported by a binary_sensor source
STATS_BINARY_SUPPORT = {
- STAT_AVERAGE_STEP,
- STAT_AVERAGE_TIMELESS,
- STAT_COUNT,
- STAT_COUNT_BINARY_ON,
- STAT_COUNT_BINARY_OFF,
- STAT_DATETIME_NEWEST,
- STAT_DATETIME_OLDEST,
- STAT_MEAN,
+ STAT_AVERAGE_STEP: _stat_binary_average_step,
+ STAT_AVERAGE_TIMELESS: _stat_binary_average_timeless,
+ STAT_COUNT: _stat_binary_count,
+ STAT_COUNT_BINARY_ON: _stat_binary_count_on,
+ STAT_COUNT_BINARY_OFF: _stat_binary_count_off,
+ STAT_DATETIME_NEWEST: _stat_binary_datetime_newest,
+ STAT_DATETIME_OLDEST: _stat_binary_datetime_oldest,
+ STAT_MEAN: _stat_binary_mean,
}
STATS_NOT_A_NUMBER = {
@@ -298,12 +625,8 @@ async def async_setup_entry(
sampling_size = int(sampling_size)
max_age = None
- if max_age_input := entry.options.get(CONF_MAX_AGE):
- max_age = timedelta(
- hours=max_age_input["hours"],
- minutes=max_age_input["minutes"],
- seconds=max_age_input["seconds"],
- )
+ if max_age := entry.options.get(CONF_MAX_AGE):
+ max_age = timedelta(**max_age)
async_add_entities(
[
@@ -356,19 +679,22 @@ class StatisticsSensor(SensorEntity):
)
self._state_characteristic: str = state_characteristic
self._samples_max_buffer_size: int | None = samples_max_buffer_size
- self._samples_max_age: timedelta | None = samples_max_age
+ self._samples_max_age: float | None = (
+ samples_max_age.total_seconds() if samples_max_age else None
+ )
self.samples_keep_last: bool = samples_keep_last
self._precision: int = precision
self._percentile: int = percentile
self._attr_available: bool = False
- self.states: deque[float | bool] = deque(maxlen=self._samples_max_buffer_size)
- self.ages: deque[datetime] = deque(maxlen=self._samples_max_buffer_size)
- self.attributes: dict[str, StateType] = {}
+ self.states: deque[float | bool] = deque(maxlen=samples_max_buffer_size)
+ self.ages: deque[float] = deque(maxlen=samples_max_buffer_size)
+ self._attr_extra_state_attributes = {}
- self._state_characteristic_fn: Callable[[], float | int | datetime | None] = (
- self._callable_characteristic_fn(self._state_characteristic)
- )
+ self._state_characteristic_fn: Callable[
+ [deque[bool | float], deque[float], int],
+ float | int | datetime | None,
+ ] = _callable_characteristic_fn(state_characteristic, self.is_binary)
self._update_listener: CALLBACK_TYPE | None = None
self._preview_callback: Callable[[str, Mapping[str, Any]], None] | None = None
@@ -462,10 +788,10 @@ class StatisticsSensor(SensorEntity):
# Here we make a copy the current value, which is okay.
self._attr_available = new_state.state != STATE_UNAVAILABLE
if new_state.state == STATE_UNAVAILABLE:
- self.attributes[STAT_SOURCE_VALUE_VALID] = None
+ self._attr_extra_state_attributes[STAT_SOURCE_VALUE_VALID] = None
return
if new_state.state in (STATE_UNKNOWN, None, ""):
- self.attributes[STAT_SOURCE_VALUE_VALID] = False
+ self._attr_extra_state_attributes[STAT_SOURCE_VALUE_VALID] = False
return
try:
@@ -474,10 +800,10 @@ class StatisticsSensor(SensorEntity):
self.states.append(new_state.state == "on")
else:
self.states.append(float(new_state.state))
- self.ages.append(new_state.last_reported)
- self.attributes[STAT_SOURCE_VALUE_VALID] = True
+ self.ages.append(new_state.last_reported_timestamp)
+ self._attr_extra_state_attributes[STAT_SOURCE_VALUE_VALID] = True
except ValueError:
- self.attributes[STAT_SOURCE_VALUE_VALID] = False
+ self._attr_extra_state_attributes[STAT_SOURCE_VALUE_VALID] = False
_LOGGER.error(
"%s: parsing error. Expected number or binary state, but received '%s'",
self.entity_id,
@@ -507,27 +833,24 @@ class StatisticsSensor(SensorEntity):
base_unit: str | None = new_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
unit: str | None = None
- if self.is_binary and self._state_characteristic in STATS_BINARY_PERCENTAGE:
+ stat_type = self._state_characteristic
+ if self.is_binary and stat_type in STATS_BINARY_PERCENTAGE:
unit = PERCENTAGE
elif not base_unit:
unit = None
- elif self._state_characteristic in STATS_NUMERIC_RETAIN_UNIT:
+ elif stat_type in STATS_NUMERIC_RETAIN_UNIT:
unit = base_unit
- elif (
- self._state_characteristic in STATS_NOT_A_NUMBER
- or self._state_characteristic
- in (
- STAT_COUNT,
- STAT_COUNT_BINARY_ON,
- STAT_COUNT_BINARY_OFF,
- )
+ elif stat_type in STATS_NOT_A_NUMBER or stat_type in (
+ STAT_COUNT,
+ STAT_COUNT_BINARY_ON,
+ STAT_COUNT_BINARY_OFF,
):
unit = None
- elif self._state_characteristic == STAT_VARIANCE:
+ elif stat_type == STAT_VARIANCE:
unit = base_unit + "²"
- elif self._state_characteristic == STAT_CHANGE_SAMPLE:
+ elif stat_type == STAT_CHANGE_SAMPLE:
unit = base_unit + "/sample"
- elif self._state_characteristic == STAT_CHANGE_SECOND:
+ elif stat_type == STAT_CHANGE_SECOND:
unit = base_unit + "/s"
return unit
@@ -543,9 +866,10 @@ class StatisticsSensor(SensorEntity):
"""
device_class: SensorDeviceClass | None = None
- if self._state_characteristic in STATS_DATETIME:
+ stat_type = self._state_characteristic
+ if stat_type in STATS_DATETIME:
return SensorDeviceClass.TIMESTAMP
- if self._state_characteristic in STATS_NUMERIC_RETAIN_UNIT:
+ if stat_type in STATS_NUMERIC_RETAIN_UNIT:
device_class = new_state.attributes.get(ATTR_DEVICE_CLASS)
if device_class is None:
return None
@@ -584,62 +908,60 @@ class StatisticsSensor(SensorEntity):
return None
return SensorStateClass.MEASUREMENT
- @property
- def extra_state_attributes(self) -> dict[str, StateType] | None:
- """Return the state attributes of the sensor."""
- return {
- key: value for key, value in self.attributes.items() if value is not None
- }
-
- def _purge_old_states(self, max_age: timedelta) -> None:
+ def _purge_old_states(self, max_age: float) -> None:
"""Remove states which are older than a given age."""
- now = dt_util.utcnow()
+ now_timestamp = time.time()
+ debug = _LOGGER.isEnabledFor(logging.DEBUG)
- _LOGGER.debug(
- "%s: purging records older then %s(%s)(keep_last_sample: %s)",
- self.entity_id,
- dt_util.as_local(now - max_age),
- self._samples_max_age,
- self.samples_keep_last,
- )
+ if debug:
+ _LOGGER.debug(
+ "%s: purging records older then %s(%s)(keep_last_sample: %s)",
+ self.entity_id,
+ dt_util.as_local(dt_util.utc_from_timestamp(now_timestamp - max_age)),
+ self._samples_max_age,
+ self.samples_keep_last,
+ )
- while self.ages and (now - self.ages[0]) > max_age:
+ while self.ages and (now_timestamp - self.ages[0]) > max_age:
if self.samples_keep_last and len(self.ages) == 1:
# Under normal circumstance this will not be executed, as a purge will not
# be scheduled for the last value if samples_keep_last is enabled.
# If this happens to be called outside normal scheduling logic or a
# source sensor update, this ensures the last value is preserved.
- _LOGGER.debug(
- "%s: preserving expired record with datetime %s(%s)",
- self.entity_id,
- dt_util.as_local(self.ages[0]),
- (now - self.ages[0]),
- )
+ if debug:
+ _LOGGER.debug(
+ "%s: preserving expired record with datetime %s(%s)",
+ self.entity_id,
+ dt_util.as_local(dt_util.utc_from_timestamp(self.ages[0])),
+ dt_util.utc_from_timestamp(now_timestamp - self.ages[0]),
+ )
break
- _LOGGER.debug(
- "%s: purging record with datetime %s(%s)",
- self.entity_id,
- dt_util.as_local(self.ages[0]),
- (now - self.ages[0]),
- )
+ if debug:
+ _LOGGER.debug(
+ "%s: purging record with datetime %s(%s)",
+ self.entity_id,
+ dt_util.as_local(dt_util.utc_from_timestamp(self.ages[0])),
+ dt_util.utc_from_timestamp(now_timestamp - self.ages[0]),
+ )
self.ages.popleft()
self.states.popleft()
@callback
- def _async_next_to_purge_timestamp(self) -> datetime | None:
+ def _async_next_to_purge_timestamp(self) -> float | None:
"""Find the timestamp when the next purge would occur."""
if self.ages and self._samples_max_age:
if self.samples_keep_last and len(self.ages) == 1:
# Preserve the most recent entry if it is the only value.
# Do not schedule another purge. When a new source
# value is inserted it will restart purge cycle.
- _LOGGER.debug(
- "%s: skipping purge cycle for last record with datetime %s(%s)",
- self.entity_id,
- dt_util.as_local(self.ages[0]),
- (dt_util.utcnow() - self.ages[0]),
- )
+ if _LOGGER.isEnabledFor(logging.DEBUG):
+ _LOGGER.debug(
+ "%s: skipping purge cycle for last record with datetime %s(%s)",
+ self.entity_id,
+ dt_util.as_local(dt_util.utc_from_timestamp(self.ages[0])),
+ (dt_util.utcnow() - dt_util.utc_from_timestamp(self.ages[0])),
+ )
return None
# Take the oldest entry from the ages list and add the configured max_age.
# If executed after purging old states, the result is the next timestamp
@@ -657,17 +979,24 @@ class StatisticsSensor(SensorEntity):
if self._samples_max_age is not None:
self._purge_old_states(self._samples_max_age)
- self._update_attributes()
+ self._update_extra_state_attributes()
self._update_value()
# If max_age is set, ensure to update again after the defined interval.
# By basing updates off the timestamps of sampled data we avoid updating
# when none of the observed entities change.
if timestamp := self._async_next_to_purge_timestamp():
- _LOGGER.debug("%s: scheduling update at %s", self.entity_id, timestamp)
+ if _LOGGER.isEnabledFor(logging.DEBUG):
+ _LOGGER.debug(
+ "%s: scheduling update at %s",
+ self.entity_id,
+ dt_util.utc_from_timestamp(timestamp),
+ )
self._async_cancel_update_listener()
self._update_listener = async_track_point_in_utc_time(
- self.hass, self._async_scheduled_update, timestamp
+ self.hass,
+ self._async_scheduled_update,
+ dt_util.utc_from_timestamp(timestamp),
)
@callback
@@ -691,9 +1020,11 @@ class StatisticsSensor(SensorEntity):
"""Fetch the states from the database."""
_LOGGER.debug("%s: initializing values from the database", self.entity_id)
lower_entity_id = self._source_entity_id.lower()
- if self._samples_max_age is not None:
+ if (max_age := self._samples_max_age) is not None:
start_date = (
- dt_util.utcnow() - self._samples_max_age - timedelta(microseconds=1)
+ dt_util.utcnow()
+ - timedelta(seconds=max_age)
+ - timedelta(microseconds=1)
)
_LOGGER.debug(
"%s: retrieve records not older then %s",
@@ -738,22 +1069,21 @@ class StatisticsSensor(SensorEntity):
self.async_write_ha_state()
_LOGGER.debug("%s: initializing from database completed", self.entity_id)
- def _update_attributes(self) -> None:
+ def _update_extra_state_attributes(self) -> None:
"""Calculate and update the various attributes."""
if self._samples_max_buffer_size is not None:
- self.attributes[STAT_BUFFER_USAGE_RATIO] = round(
+ self._attr_extra_state_attributes[STAT_BUFFER_USAGE_RATIO] = round(
len(self.states) / self._samples_max_buffer_size, 2
)
- if self._samples_max_age is not None:
+ if (max_age := self._samples_max_age) is not None:
if len(self.states) >= 1:
- self.attributes[STAT_AGE_COVERAGE_RATIO] = round(
- (self.ages[-1] - self.ages[0]).total_seconds()
- / self._samples_max_age.total_seconds(),
+ self._attr_extra_state_attributes[STAT_AGE_COVERAGE_RATIO] = round(
+ (self.ages[-1] - self.ages[0]) / max_age,
2,
)
else:
- self.attributes[STAT_AGE_COVERAGE_RATIO] = None
+ self._attr_extra_state_attributes[STAT_AGE_COVERAGE_RATIO] = 0
def _update_value(self) -> None:
"""Front to call the right statistical characteristics functions.
@@ -761,7 +1091,7 @@ class StatisticsSensor(SensorEntity):
One of the _stat_*() functions is represented by self._state_characteristic_fn().
"""
- value = self._state_characteristic_fn()
+ value = self._state_characteristic_fn(self.states, self.ages, self._percentile)
_LOGGER.debug(
"Updating value: states: %s, ages: %s => %s", self.states, self.ages, value
)
@@ -771,225 +1101,3 @@ class StatisticsSensor(SensorEntity):
if self._precision == 0:
value = int(value)
self._attr_native_value = value
-
- def _callable_characteristic_fn(
- self, characteristic: str
- ) -> Callable[[], float | int | datetime | None]:
- """Return the function callable of one characteristic function."""
- function: Callable[[], float | int | datetime | None] = getattr(
- self,
- f"_stat_binary_{characteristic}"
- if self.is_binary
- else f"_stat_{characteristic}",
- )
- return function
-
- # Statistics for numeric sensor
-
- def _stat_average_linear(self) -> StateType:
- if len(self.states) == 1:
- return self.states[0]
- if len(self.states) >= 2:
- area: float = 0
- for i in range(1, len(self.states)):
- area += (
- 0.5
- * (self.states[i] + self.states[i - 1])
- * (self.ages[i] - self.ages[i - 1]).total_seconds()
- )
- age_range_seconds = (self.ages[-1] - self.ages[0]).total_seconds()
- return area / age_range_seconds
- return None
-
- def _stat_average_step(self) -> StateType:
- if len(self.states) == 1:
- return self.states[0]
- if len(self.states) >= 2:
- area: float = 0
- for i in range(1, len(self.states)):
- area += (
- self.states[i - 1]
- * (self.ages[i] - self.ages[i - 1]).total_seconds()
- )
- age_range_seconds = (self.ages[-1] - self.ages[0]).total_seconds()
- return area / age_range_seconds
- return None
-
- def _stat_average_timeless(self) -> StateType:
- return self._stat_mean()
-
- def _stat_change(self) -> StateType:
- if len(self.states) > 0:
- return self.states[-1] - self.states[0]
- return None
-
- def _stat_change_sample(self) -> StateType:
- if len(self.states) > 1:
- return (self.states[-1] - self.states[0]) / (len(self.states) - 1)
- return None
-
- def _stat_change_second(self) -> StateType:
- if len(self.states) > 1:
- age_range_seconds = (self.ages[-1] - self.ages[0]).total_seconds()
- if age_range_seconds > 0:
- return (self.states[-1] - self.states[0]) / age_range_seconds
- return None
-
- def _stat_count(self) -> StateType:
- return len(self.states)
-
- def _stat_datetime_newest(self) -> datetime | None:
- if len(self.states) > 0:
- return self.ages[-1]
- return None
-
- def _stat_datetime_oldest(self) -> datetime | None:
- if len(self.states) > 0:
- return self.ages[0]
- return None
-
- def _stat_datetime_value_max(self) -> datetime | None:
- if len(self.states) > 0:
- return self.ages[self.states.index(max(self.states))]
- return None
-
- def _stat_datetime_value_min(self) -> datetime | None:
- if len(self.states) > 0:
- return self.ages[self.states.index(min(self.states))]
- return None
-
- def _stat_distance_95_percent_of_values(self) -> StateType:
- if len(self.states) >= 1:
- return 2 * 1.96 * cast(float, self._stat_standard_deviation())
- return None
-
- def _stat_distance_99_percent_of_values(self) -> StateType:
- if len(self.states) >= 1:
- return 2 * 2.58 * cast(float, self._stat_standard_deviation())
- return None
-
- def _stat_distance_absolute(self) -> StateType:
- if len(self.states) > 0:
- return max(self.states) - min(self.states)
- return None
-
- def _stat_mean(self) -> StateType:
- if len(self.states) > 0:
- return statistics.mean(self.states)
- return None
-
- def _stat_mean_circular(self) -> StateType:
- if len(self.states) > 0:
- sin_sum = sum(math.sin(math.radians(x)) for x in self.states)
- cos_sum = sum(math.cos(math.radians(x)) for x in self.states)
- return (math.degrees(math.atan2(sin_sum, cos_sum)) + 360) % 360
- return None
-
- def _stat_median(self) -> StateType:
- if len(self.states) > 0:
- return statistics.median(self.states)
- return None
-
- def _stat_noisiness(self) -> StateType:
- if len(self.states) == 1:
- return 0.0
- if len(self.states) >= 2:
- return cast(float, self._stat_sum_differences()) / (len(self.states) - 1)
- return None
-
- def _stat_percentile(self) -> StateType:
- if len(self.states) == 1:
- return self.states[0]
- if len(self.states) >= 2:
- percentiles = statistics.quantiles(self.states, n=100, method="exclusive")
- return percentiles[self._percentile - 1]
- return None
-
- def _stat_standard_deviation(self) -> StateType:
- if len(self.states) == 1:
- return 0.0
- if len(self.states) >= 2:
- return statistics.stdev(self.states)
- return None
-
- def _stat_sum(self) -> StateType:
- if len(self.states) > 0:
- return sum(self.states)
- return None
-
- def _stat_sum_differences(self) -> StateType:
- if len(self.states) == 1:
- return 0.0
- if len(self.states) >= 2:
- return sum(
- abs(j - i)
- for i, j in zip(list(self.states), list(self.states)[1:], strict=False)
- )
- return None
-
- def _stat_sum_differences_nonnegative(self) -> StateType:
- if len(self.states) == 1:
- return 0.0
- if len(self.states) >= 2:
- return sum(
- (j - i if j >= i else j - 0)
- for i, j in zip(list(self.states), list(self.states)[1:], strict=False)
- )
- return None
-
- def _stat_total(self) -> StateType:
- return self._stat_sum()
-
- def _stat_value_max(self) -> StateType:
- if len(self.states) > 0:
- return max(self.states)
- return None
-
- def _stat_value_min(self) -> StateType:
- if len(self.states) > 0:
- return min(self.states)
- return None
-
- def _stat_variance(self) -> StateType:
- if len(self.states) == 1:
- return 0.0
- if len(self.states) >= 2:
- return statistics.variance(self.states)
- return None
-
- # Statistics for binary sensor
-
- def _stat_binary_average_step(self) -> StateType:
- if len(self.states) == 1:
- return 100.0 * int(self.states[0] is True)
- if len(self.states) >= 2:
- on_seconds: float = 0
- for i in range(1, len(self.states)):
- if self.states[i - 1] is True:
- on_seconds += (self.ages[i] - self.ages[i - 1]).total_seconds()
- age_range_seconds = (self.ages[-1] - self.ages[0]).total_seconds()
- return 100 / age_range_seconds * on_seconds
- return None
-
- def _stat_binary_average_timeless(self) -> StateType:
- return self._stat_binary_mean()
-
- def _stat_binary_count(self) -> StateType:
- return len(self.states)
-
- def _stat_binary_count_on(self) -> StateType:
- return self.states.count(True)
-
- def _stat_binary_count_off(self) -> StateType:
- return self.states.count(False)
-
- def _stat_binary_datetime_newest(self) -> datetime | None:
- return self._stat_datetime_newest()
-
- def _stat_binary_datetime_oldest(self) -> datetime | None:
- return self._stat_datetime_oldest()
-
- def _stat_binary_mean(self) -> StateType:
- if len(self.states) > 0:
- return 100.0 / len(self.states) * self.states.count(True)
- return None
diff --git a/homeassistant/components/statistics/strings.json b/homeassistant/components/statistics/strings.json
index a060c88da24..91aead261ff 100644
--- a/homeassistant/components/statistics/strings.json
+++ b/homeassistant/components/statistics/strings.json
@@ -10,7 +10,7 @@
},
"step": {
"user": {
- "description": "Add a statistics sensor",
+ "description": "Create a statistics sensor",
"data": {
"name": "[%key:common::config_flow::data::name%]",
"entity_id": "Entity"
@@ -23,10 +23,10 @@
"state_characteristic": {
"description": "Read the documention for further details on available options and how to use them.",
"data": {
- "state_characteristic": "State_characteristic"
+ "state_characteristic": "Statistic characteristic"
},
"data_description": {
- "state_characteristic": "The characteristic that should be used as the state of the statistics sensor."
+ "state_characteristic": "The statistic characteristic that should be used as the state of the sensor."
}
},
"options": {
diff --git a/homeassistant/components/statsd/manifest.json b/homeassistant/components/statsd/manifest.json
index 73296a23dd9..4f0ea93eb98 100644
--- a/homeassistant/components/statsd/manifest.json
+++ b/homeassistant/components/statsd/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/statsd",
"iot_class": "local_push",
"loggers": ["statsd"],
+ "quality_scale": "legacy",
"requirements": ["statsd==3.2.1"]
}
diff --git a/homeassistant/components/steam_online/coordinator.py b/homeassistant/components/steam_online/coordinator.py
index 6e7bdf4b91c..81a3bb0d898 100644
--- a/homeassistant/components/steam_online/coordinator.py
+++ b/homeassistant/components/steam_online/coordinator.py
@@ -60,9 +60,9 @@ class SteamDataUpdateCoordinator(
for player in response["response"]["players"]["player"]
if player["steamid"] in _ids
}
- for k in players:
- data = self.player_interface.GetSteamLevel(steamid=players[k]["steamid"])
- players[k]["level"] = data["response"].get("player_level")
+ for value in players.values():
+ data = self.player_interface.GetSteamLevel(steamid=value["steamid"])
+ value["level"] = data["response"].get("player_level")
return players
async def _async_update_data(self) -> dict[str, dict[str, str | int]]:
diff --git a/homeassistant/components/stiebel_eltron/climate.py b/homeassistant/components/stiebel_eltron/climate.py
index 41015ac16a4..676f613f382 100644
--- a/homeassistant/components/stiebel_eltron/climate.py
+++ b/homeassistant/components/stiebel_eltron/climate.py
@@ -80,7 +80,6 @@ class StiebelEltron(ClimateEntity):
| ClimateEntityFeature.TURN_ON
)
_attr_temperature_unit = UnitOfTemperature.CELSIUS
- _enable_turn_on_off_backwards_compatibility = False
def __init__(self, name, ste_data):
"""Initialize the unit."""
diff --git a/homeassistant/components/stiebel_eltron/manifest.json b/homeassistant/components/stiebel_eltron/manifest.json
index 6592851d641..9580cd4d4ca 100644
--- a/homeassistant/components/stiebel_eltron/manifest.json
+++ b/homeassistant/components/stiebel_eltron/manifest.json
@@ -6,5 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/stiebel_eltron",
"iot_class": "local_polling",
"loggers": ["pymodbus", "pystiebeleltron"],
+ "quality_scale": "legacy",
"requirements": ["pystiebeleltron==0.0.1.dev2"]
}
diff --git a/homeassistant/components/stookalert/__init__.py b/homeassistant/components/stookalert/__init__.py
deleted file mode 100644
index 0ef9c7fa845..00000000000
--- a/homeassistant/components/stookalert/__init__.py
+++ /dev/null
@@ -1,29 +0,0 @@
-"""The Stookalert integration."""
-
-from __future__ import annotations
-
-import stookalert
-
-from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import Platform
-from homeassistant.core import HomeAssistant
-
-from .const import CONF_PROVINCE, DOMAIN
-
-PLATFORMS = [Platform.BINARY_SENSOR]
-
-
-async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
- """Set up Stookalert from a config entry."""
- hass.data.setdefault(DOMAIN, {})
- hass.data[DOMAIN][entry.entry_id] = stookalert.stookalert(entry.data[CONF_PROVINCE])
- await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
- return True
-
-
-async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
- """Unload Stookalert 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
diff --git a/homeassistant/components/stookalert/binary_sensor.py b/homeassistant/components/stookalert/binary_sensor.py
deleted file mode 100644
index a2fff52f2a3..00000000000
--- a/homeassistant/components/stookalert/binary_sensor.py
+++ /dev/null
@@ -1,57 +0,0 @@
-"""Support for Stookalert Binary Sensor."""
-
-from __future__ import annotations
-
-from datetime import timedelta
-
-import stookalert
-
-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 DeviceEntryType, DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
-
-from .const import CONF_PROVINCE, DOMAIN
-
-SCAN_INTERVAL = timedelta(minutes=60)
-
-
-async def async_setup_entry(
- hass: HomeAssistant,
- entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
-) -> None:
- """Set up Stookalert binary sensor from a config entry."""
- client = hass.data[DOMAIN][entry.entry_id]
- async_add_entities([StookalertBinarySensor(client, entry)], update_before_add=True)
-
-
-class StookalertBinarySensor(BinarySensorEntity):
- """Defines a Stookalert binary sensor."""
-
- _attr_attribution = "Data provided by rivm.nl"
- _attr_device_class = BinarySensorDeviceClass.SAFETY
- _attr_has_entity_name = True
- _attr_name = None
-
- def __init__(self, client: stookalert.stookalert, entry: ConfigEntry) -> None:
- """Initialize a Stookalert device."""
- self._client = client
- self._attr_unique_id = entry.unique_id
- self._attr_device_info = DeviceInfo(
- identifiers={(DOMAIN, f"{entry.entry_id}")},
- name=f"Stookalert {entry.data[CONF_PROVINCE]}",
- manufacturer="RIVM",
- model="Stookalert",
- entry_type=DeviceEntryType.SERVICE,
- configuration_url="https://www.rivm.nl/stookalert",
- )
-
- def update(self) -> None:
- """Update the data from the Stookalert handler."""
- self._client.get_alerts()
- self._attr_is_on = self._client.state == 1
diff --git a/homeassistant/components/stookalert/config_flow.py b/homeassistant/components/stookalert/config_flow.py
deleted file mode 100644
index 0d3bc0c1761..00000000000
--- a/homeassistant/components/stookalert/config_flow.py
+++ /dev/null
@@ -1,33 +0,0 @@
-"""Config flow to configure the Stookalert integration."""
-
-from __future__ import annotations
-
-from typing import Any
-
-import voluptuous as vol
-
-from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
-
-from .const import CONF_PROVINCE, DOMAIN, PROVINCES
-
-
-class StookalertFlowHandler(ConfigFlow, domain=DOMAIN):
- """Config flow for Stookalert."""
-
- VERSION = 1
-
- async def async_step_user(
- self, user_input: dict[str, Any] | None = None
- ) -> ConfigFlowResult:
- """Handle a flow initialized by the user."""
- if user_input is not None:
- await self.async_set_unique_id(user_input[CONF_PROVINCE])
- self._abort_if_unique_id_configured()
- return self.async_create_entry(
- title=user_input[CONF_PROVINCE], data=user_input
- )
-
- return self.async_show_form(
- step_id="user",
- data_schema=vol.Schema({vol.Required(CONF_PROVINCE): vol.In(PROVINCES)}),
- )
diff --git a/homeassistant/components/stookalert/const.py b/homeassistant/components/stookalert/const.py
deleted file mode 100644
index 9896eea212a..00000000000
--- a/homeassistant/components/stookalert/const.py
+++ /dev/null
@@ -1,24 +0,0 @@
-"""Constants for the Stookalert integration."""
-
-import logging
-from typing import Final
-
-DOMAIN: Final = "stookalert"
-LOGGER = logging.getLogger(__package__)
-
-CONF_PROVINCE: Final = "province"
-
-PROVINCES: Final = (
- "Drenthe",
- "Flevoland",
- "Friesland",
- "Gelderland",
- "Groningen",
- "Limburg",
- "Noord-Brabant",
- "Noord-Holland",
- "Overijssel",
- "Utrecht",
- "Zeeland",
- "Zuid-Holland",
-)
diff --git a/homeassistant/components/stookalert/diagnostics.py b/homeassistant/components/stookalert/diagnostics.py
deleted file mode 100644
index c15e808ae19..00000000000
--- a/homeassistant/components/stookalert/diagnostics.py
+++ /dev/null
@@ -1,20 +0,0 @@
-"""Diagnostics support for Stookalert."""
-
-from __future__ import annotations
-
-from typing import Any
-
-import stookalert
-
-from homeassistant.config_entries import ConfigEntry
-from homeassistant.core import HomeAssistant
-
-from .const import DOMAIN
-
-
-async def async_get_config_entry_diagnostics(
- hass: HomeAssistant, entry: ConfigEntry
-) -> dict[str, Any]:
- """Return diagnostics for a config entry."""
- client: stookalert.stookalert = hass.data[DOMAIN][entry.entry_id]
- return {"state": client.state}
diff --git a/homeassistant/components/stookalert/manifest.json b/homeassistant/components/stookalert/manifest.json
deleted file mode 100644
index 2bebc639720..00000000000
--- a/homeassistant/components/stookalert/manifest.json
+++ /dev/null
@@ -1,10 +0,0 @@
-{
- "domain": "stookalert",
- "name": "RIVM Stookalert",
- "codeowners": ["@fwestenberg", "@frenck"],
- "config_flow": true,
- "documentation": "https://www.home-assistant.io/integrations/stookalert",
- "integration_type": "service",
- "iot_class": "cloud_polling",
- "requirements": ["stookalert==0.1.4"]
-}
diff --git a/homeassistant/components/stookalert/strings.json b/homeassistant/components/stookalert/strings.json
deleted file mode 100644
index a05ae4e61e7..00000000000
--- a/homeassistant/components/stookalert/strings.json
+++ /dev/null
@@ -1,14 +0,0 @@
-{
- "config": {
- "step": {
- "user": {
- "data": {
- "province": "Province"
- }
- }
- },
- "abort": {
- "already_configured": "[%key:common::config_flow::abort::already_configured_service%]"
- }
- }
-}
diff --git a/homeassistant/components/stookwijzer/__init__.py b/homeassistant/components/stookwijzer/__init__.py
index a714e3bd368..d8b9561bde9 100644
--- a/homeassistant/components/stookwijzer/__init__.py
+++ b/homeassistant/components/stookwijzer/__init__.py
@@ -2,29 +2,89 @@
from __future__ import annotations
+from typing import Any
+
from stookwijzer import Stookwijzer
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE, Platform
-from homeassistant.core import HomeAssistant
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers import entity_registry as er, issue_registry as ir
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
-from .const import DOMAIN
+from .const import DOMAIN, LOGGER
+from .coordinator import StookwijzerConfigEntry, StookwijzerCoordinator
PLATFORMS = [Platform.SENSOR]
-async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_setup_entry(hass: HomeAssistant, entry: StookwijzerConfigEntry) -> bool:
"""Set up Stookwijzer from a config entry."""
- hass.data.setdefault(DOMAIN, {})[entry.entry_id] = Stookwijzer(
- entry.data[CONF_LOCATION][CONF_LATITUDE],
- entry.data[CONF_LOCATION][CONF_LONGITUDE],
- )
+ await er.async_migrate_entries(hass, entry.entry_id, async_migrate_entity_entry)
+
+ coordinator = StookwijzerCoordinator(hass, entry)
+ await coordinator.async_config_entry_first_refresh()
+
+ entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
-async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_unload_entry(
+ hass: HomeAssistant, entry: StookwijzerConfigEntry
+) -> bool:
"""Unload Stookwijzer 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)
+
+
+async def async_migrate_entry(
+ hass: HomeAssistant, entry: StookwijzerConfigEntry
+) -> bool:
+ """Migrate old entry."""
+ LOGGER.debug("Migrating from version %s", entry.version)
+
+ if entry.version == 1:
+ latitude, longitude = await Stookwijzer.async_transform_coordinates(
+ async_get_clientsession(hass),
+ entry.data[CONF_LOCATION][CONF_LATITUDE],
+ entry.data[CONF_LOCATION][CONF_LONGITUDE],
+ )
+
+ if not latitude or not longitude:
+ ir.async_create_issue(
+ hass,
+ DOMAIN,
+ "location_migration_failed",
+ is_fixable=False,
+ severity=ir.IssueSeverity.ERROR,
+ translation_key="location_migration_failed",
+ translation_placeholders={
+ "entry_title": entry.title,
+ },
+ )
+ return False
+
+ hass.config_entries.async_update_entry(
+ entry,
+ version=2,
+ data={
+ CONF_LATITUDE: latitude,
+ CONF_LONGITUDE: longitude,
+ },
+ )
+
+ LOGGER.debug("Migration to version %s successful", entry.version)
+
+ return True
+
+
+@callback
+def async_migrate_entity_entry(entity_entry: er.RegistryEntry) -> dict[str, Any] | None:
+ """Migrate Stookwijzer entity entries.
+
+ - Migrates unique ID for the old Stookwijzer sensors to the new unique ID.
+ """
+ if entity_entry.unique_id == entity_entry.config_entry_id:
+ return {"new_unique_id": f"{entity_entry.config_entry_id}_advice"}
+
+ # No migration needed
+ return None
diff --git a/homeassistant/components/stookwijzer/config_flow.py b/homeassistant/components/stookwijzer/config_flow.py
index be53ce56390..32b4836763f 100644
--- a/homeassistant/components/stookwijzer/config_flow.py
+++ b/homeassistant/components/stookwijzer/config_flow.py
@@ -4,10 +4,12 @@ from __future__ import annotations
from typing import Any
+from stookwijzer import Stookwijzer
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.selector import LocationSelector
from .const import DOMAIN
@@ -16,21 +18,29 @@ from .const import DOMAIN
class StookwijzerFlowHandler(ConfigFlow, domain=DOMAIN):
"""Config flow for Stookwijzer."""
- VERSION = 1
+ VERSION = 2
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle a flow initialized by the user."""
-
+ errors = {}
if user_input is not None:
- return self.async_create_entry(
- title="Stookwijzer",
- data=user_input,
+ latitude, longitude = await Stookwijzer.async_transform_coordinates(
+ async_get_clientsession(self.hass),
+ user_input[CONF_LOCATION][CONF_LATITUDE],
+ user_input[CONF_LOCATION][CONF_LONGITUDE],
)
+ if latitude and longitude:
+ return self.async_create_entry(
+ title="Stookwijzer",
+ data={CONF_LATITUDE: latitude, CONF_LONGITUDE: longitude},
+ )
+ errors["base"] = "unknown"
return self.async_show_form(
step_id="user",
+ errors=errors,
data_schema=vol.Schema(
{
vol.Required(
diff --git a/homeassistant/components/stookwijzer/const.py b/homeassistant/components/stookwijzer/const.py
index e8cb3d818e6..1b0be86d375 100644
--- a/homeassistant/components/stookwijzer/const.py
+++ b/homeassistant/components/stookwijzer/const.py
@@ -1,16 +1,7 @@
"""Constants for the Stookwijzer integration."""
-from enum import StrEnum
import logging
from typing import Final
DOMAIN: Final = "stookwijzer"
LOGGER = logging.getLogger(__package__)
-
-
-class StookwijzerState(StrEnum):
- """Stookwijzer states for sensor entity."""
-
- BLUE = "blauw"
- ORANGE = "oranje"
- RED = "rood"
diff --git a/homeassistant/components/stookwijzer/coordinator.py b/homeassistant/components/stookwijzer/coordinator.py
new file mode 100644
index 00000000000..23092bed66e
--- /dev/null
+++ b/homeassistant/components/stookwijzer/coordinator.py
@@ -0,0 +1,44 @@
+"""Class representing a Stookwijzer update coordinator."""
+
+from datetime import timedelta
+
+from stookwijzer import Stookwijzer
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE
+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 = timedelta(minutes=60)
+
+type StookwijzerConfigEntry = ConfigEntry[StookwijzerCoordinator]
+
+
+class StookwijzerCoordinator(DataUpdateCoordinator[None]):
+ """Stookwijzer update coordinator."""
+
+ def __init__(self, hass: HomeAssistant, entry: StookwijzerConfigEntry) -> None:
+ """Initialize the coordinator."""
+ super().__init__(
+ hass,
+ LOGGER,
+ name=DOMAIN,
+ update_interval=SCAN_INTERVAL,
+ )
+ self.client = Stookwijzer(
+ async_get_clientsession(hass),
+ entry.data[CONF_LATITUDE],
+ entry.data[CONF_LONGITUDE],
+ )
+
+ async def _async_update_data(self) -> None:
+ """Fetch data from API endpoint."""
+ await self.client.async_update()
+ if self.client.advice is None:
+ raise UpdateFailed(
+ translation_domain=DOMAIN,
+ translation_key="no_data_received",
+ )
diff --git a/homeassistant/components/stookwijzer/diagnostics.py b/homeassistant/components/stookwijzer/diagnostics.py
index c7bf4fad14d..2849e0e976a 100644
--- a/homeassistant/components/stookwijzer/diagnostics.py
+++ b/homeassistant/components/stookwijzer/diagnostics.py
@@ -4,29 +4,18 @@ from __future__ import annotations
from typing import Any
-from stookwijzer import Stookwijzer
-
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from .const import DOMAIN
+from .coordinator import StookwijzerConfigEntry
async def async_get_config_entry_diagnostics(
- hass: HomeAssistant, entry: ConfigEntry
+ hass: HomeAssistant, entry: StookwijzerConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
- client: Stookwijzer = hass.data[DOMAIN][entry.entry_id]
-
- last_updated = None
- if client.last_updated:
- last_updated = client.last_updated.isoformat()
-
+ client = entry.runtime_data.client
return {
- "state": client.state,
- "last_updated": last_updated,
- "lqi": client.lqi,
- "windspeed": client.windspeed,
- "weather": client.weather,
- "concentrations": client.concentrations,
+ "advice": client.advice,
+ "air_quality_index": client.lki,
+ "windspeed_ms": client.windspeed_ms,
}
diff --git a/homeassistant/components/stookwijzer/manifest.json b/homeassistant/components/stookwijzer/manifest.json
index dbf902b1e1e..3fe16fb3d33 100644
--- a/homeassistant/components/stookwijzer/manifest.json
+++ b/homeassistant/components/stookwijzer/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/stookwijzer",
"integration_type": "service",
"iot_class": "cloud_polling",
- "requirements": ["stookwijzer==1.3.0"]
+ "requirements": ["stookwijzer==1.5.1"]
}
diff --git a/homeassistant/components/stookwijzer/quality_scale.yaml b/homeassistant/components/stookwijzer/quality_scale.yaml
new file mode 100644
index 00000000000..20e64efaa92
--- /dev/null
+++ b/homeassistant/components/stookwijzer/quality_scale.yaml
@@ -0,0 +1,92 @@
+rules:
+ # Bronze
+ action-setup:
+ status: exempt
+ comment: |
+ The integration doesn't provide any additional service 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: |
+ The integration doesn't provide any additional service actions.
+ docs-high-level-description: done
+ docs-installation-instructions: done
+ docs-removal-instructions: todo
+ entity-event-setup:
+ status: exempt
+ comment: |
+ The integration doesn't subscribe to any events.
+ entity-unique-id: done
+ has-entity-name: done
+ runtime-data: done
+ test-before-configure: todo
+ test-before-setup: done
+ unique-config-entry: todo
+
+ # Silver
+ action-exceptions:
+ status: exempt
+ comment: |
+ This integration is read-only and doesn't provide any actions.
+ config-entry-unloading: done
+ docs-configuration-parameters: todo
+ docs-installation-parameters: todo
+ entity-unavailable: done
+ integration-owner: done
+ log-when-unavailable: done
+ parallel-updates:
+ status: exempt
+ comment: |
+ This integration is read-only and doesn't provide any actions. Querying
+ the service for data is handled centrally using a data update coordinator.
+ reauthentication-flow:
+ status: exempt
+ comment: |
+ This integration doesn't require re-authentication.
+ test-coverage: done
+ # Gold
+ devices: done
+ diagnostics: done
+ discovery-update-info:
+ status: exempt
+ comment: |
+ The integration cannot be discovered, as it is an external service.
+ discovery:
+ status: exempt
+ comment: |
+ The integration cannot be discovered, as it is an external service.
+ 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 provides a single device entry for the service.
+ entity-category: done
+ entity-device-class: done
+ entity-disabled-by-default: done
+ entity-translations: done
+ exception-translations: done
+ icon-translations: todo
+ reconfiguration-flow: todo
+ repair-issues: done
+ stale-devices:
+ status: exempt
+ comment: |
+ This integration provides a single device entry for the service.
+ # Platinum
+ async-dependency: done
+ inject-websession: done
+ strict-typing:
+ status: todo
+ comment: |
+ Requirement 'stookwijzer==1.5.1' appears untyped
diff --git a/homeassistant/components/stookwijzer/sensor.py b/homeassistant/components/stookwijzer/sensor.py
index b8f9a660598..2660ff2ddb2 100644
--- a/homeassistant/components/stookwijzer/sensor.py
+++ b/homeassistant/components/stookwijzer/sensor.py
@@ -2,65 +2,95 @@
from __future__ import annotations
-from datetime import timedelta
+from collections.abc import Callable
+from dataclasses import dataclass
from stookwijzer import Stookwijzer
-from homeassistant.components.sensor import SensorDeviceClass, SensorEntity
-from homeassistant.config_entries import ConfigEntry
+from homeassistant.components.sensor import (
+ SensorDeviceClass,
+ SensorEntity,
+ SensorEntityDescription,
+ SensorStateClass,
+)
+from homeassistant.const import UnitOfSpeed
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, StookwijzerState
+from .const import DOMAIN
+from .coordinator import StookwijzerConfigEntry, StookwijzerCoordinator
-SCAN_INTERVAL = timedelta(minutes=60)
+
+@dataclass(kw_only=True, frozen=True)
+class StookwijzerSensorDescription(SensorEntityDescription):
+ """Class describing Stookwijzer sensor entities."""
+
+ value_fn: Callable[[Stookwijzer], int | float | str | None]
+
+
+STOOKWIJZER_SENSORS = [
+ StookwijzerSensorDescription(
+ key="windspeed",
+ native_unit_of_measurement=UnitOfSpeed.METERS_PER_SECOND,
+ suggested_unit_of_measurement=UnitOfSpeed.BEAUFORT,
+ device_class=SensorDeviceClass.WIND_SPEED,
+ suggested_display_precision=0,
+ state_class=SensorStateClass.MEASUREMENT,
+ value_fn=lambda client: client.windspeed_ms,
+ ),
+ StookwijzerSensorDescription(
+ key="air_quality_index",
+ device_class=SensorDeviceClass.AQI,
+ state_class=SensorStateClass.MEASUREMENT,
+ value_fn=lambda client: client.lki,
+ ),
+ StookwijzerSensorDescription(
+ key="advice",
+ translation_key="advice",
+ device_class=SensorDeviceClass.ENUM,
+ value_fn=lambda client: client.advice,
+ options=["code_yellow", "code_orange", "code_red"],
+ ),
+]
async def async_setup_entry(
hass: HomeAssistant,
- entry: ConfigEntry,
+ entry: StookwijzerConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Stookwijzer sensor from a config entry."""
- client = hass.data[DOMAIN][entry.entry_id]
- async_add_entities([StookwijzerSensor(client, entry)], update_before_add=True)
+ async_add_entities(
+ StookwijzerSensor(description, entry) for description in STOOKWIJZER_SENSORS
+ )
-class StookwijzerSensor(SensorEntity):
+class StookwijzerSensor(CoordinatorEntity[StookwijzerCoordinator], SensorEntity):
"""Defines a Stookwijzer binary sensor."""
- _attr_attribution = "Data provided by stookwijzer.nu"
- _attr_device_class = SensorDeviceClass.ENUM
+ entity_description: StookwijzerSensorDescription
+ _attr_attribution = "Data provided by atlasleefomgeving.nl"
_attr_has_entity_name = True
- _attr_name = None
- _attr_translation_key = "stookwijzer"
- def __init__(self, client: Stookwijzer, entry: ConfigEntry) -> None:
+ def __init__(
+ self,
+ description: StookwijzerSensorDescription,
+ entry: StookwijzerConfigEntry,
+ ) -> None:
"""Initialize a Stookwijzer device."""
- self._client = client
- self._attr_options = [cls.value for cls in StookwijzerState]
- self._attr_unique_id = entry.entry_id
+ super().__init__(entry.runtime_data)
+ self.entity_description = description
+ self._attr_unique_id = f"{entry.entry_id}_{description.key}"
self._attr_device_info = DeviceInfo(
- identifiers={(DOMAIN, f"{entry.entry_id}")},
- name="Stookwijzer",
- manufacturer="stookwijzer.nu",
+ identifiers={(DOMAIN, entry.entry_id)},
+ manufacturer="Atlas Leefomgeving",
entry_type=DeviceEntryType.SERVICE,
- configuration_url="https://www.stookwijzer.nu",
+ configuration_url="https://www.atlasleefomgeving.nl/stookwijzer",
)
- def update(self) -> None:
- """Update the data from the Stookwijzer handler."""
- self._client.update()
-
@property
- def available(self) -> bool:
- """Return if entity is available."""
- return self._client.state is not None
-
- @property
- def native_value(self) -> str | None:
+ def native_value(self) -> int | float | str | None:
"""Return the state of the device."""
- if self._client.state is None:
- return None
- return StookwijzerState(self._client.state).value
+ return self.entity_description.value_fn(self.coordinator.client)
diff --git a/homeassistant/components/stookwijzer/strings.json b/homeassistant/components/stookwijzer/strings.json
index 549673165ec..189af89b282 100644
--- a/homeassistant/components/stookwijzer/strings.json
+++ b/homeassistant/components/stookwijzer/strings.json
@@ -5,19 +5,37 @@
"description": "Select the location you want to recieve the Stookwijzer information for.",
"data": {
"location": "[%key:common::config_flow::data::location%]"
+ },
+ "data_description": {
+ "location": "Use the map to set the location for Stookwijzer."
}
}
+ },
+ "error": {
+ "unknown": "[%key:common::config_flow::error::unknown%]"
}
},
"entity": {
"sensor": {
- "stookwijzer": {
+ "advice": {
+ "name": "Advice code",
"state": {
- "blauw": "Blue",
- "oranje": "Orange",
- "rood": "Red"
+ "code_yellow": "Yellow",
+ "code_orange": "Orange",
+ "code_red": "Red"
}
}
}
+ },
+ "issues": {
+ "location_migration_failed": {
+ "description": "The Stookwijzer integration was unable to automatically migrate your location to a new format the updated integrations uses.\n\nMake sure you are connected to the internet and restart Home Assistant to try again.\n\nIf this doesn't resolve the error, remove and re-add the integration.",
+ "title": "Migration of your location failed"
+ }
+ },
+ "exceptions": {
+ "no_data_received": {
+ "message": "No data received from Stookwijzer."
+ }
}
}
diff --git a/homeassistant/components/stream/__init__.py b/homeassistant/components/stream/__init__.py
index 64c520150c2..8692a2acaad 100644
--- a/homeassistant/components/stream/__init__.py
+++ b/homeassistant/components/stream/__init__.py
@@ -40,6 +40,7 @@ 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,
@@ -60,6 +61,7 @@ from .const import (
SOURCE_TIMEOUT,
STREAM_RESTART_INCREMENT,
STREAM_RESTART_RESET_TIME,
+ StreamClientError,
)
from .core import (
PROVIDERS,
@@ -71,6 +73,7 @@ from .core import (
StreamSettings,
)
from .diagnostics import Diagnostics
+from .exceptions import StreamOpenClientError, StreamWorkerError
from .hls import HlsStreamOutput, async_setup_hls
if TYPE_CHECKING:
@@ -88,6 +91,8 @@ __all__ = [
"RTSP_TRANSPORTS",
"SOURCE_TIMEOUT",
"Stream",
+ "StreamClientError",
+ "StreamOpenClientError",
"create_stream",
"Orientation",
]
@@ -95,6 +100,34 @@ __all__ = [
_LOGGER = logging.getLogger(__name__)
+async def async_check_stream_client_error(
+ hass: HomeAssistant, source: str, pyav_options: dict[str, str] | None = None
+) -> None:
+ """Check if a stream can be successfully opened.
+
+ Raise StreamOpenClientError if an http client error is encountered.
+ """
+ await hass.loop.run_in_executor(
+ None, _check_stream_client_error, hass, source, pyav_options
+ )
+
+
+def _check_stream_client_error(
+ 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.
+ """
+ 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:
"""Redact credentials from string data."""
yurl = URL(url)
@@ -108,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,
@@ -123,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,
@@ -420,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/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/manifest.json b/homeassistant/components/stream/manifest.json
index 304ef5bbf62..e0321e306e3 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.1.2"]
+ "requirements": ["PyTurboJPEG==1.7.5", "av==13.1.0", "numpy==2.2.1"]
}
diff --git a/homeassistant/components/stream/worker.py b/homeassistant/components/stream/worker.py
index 8c9bb1b8e9e..0c1f38938eb 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.
@@ -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/subaru/__init__.py b/homeassistant/components/subaru/__init__.py
index 3762b16e58b..4068507ed14 100644
--- a/homeassistant/components/subaru/__init__.py
+++ b/homeassistant/components/subaru/__init__.py
@@ -49,7 +49,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Subaru from a config entry."""
config = entry.data
- websession = aiohttp_client.async_get_clientsession(hass)
+ websession = aiohttp_client.async_create_clientsession(hass)
try:
controller = SubaruAPI(
websession,
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/subaru/strings.json b/homeassistant/components/subaru/strings.json
index 78625192e4a..00da729dccd 100644
--- a/homeassistant/components/subaru/strings.json
+++ b/homeassistant/components/subaru/strings.json
@@ -37,13 +37,13 @@
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"incorrect_pin": "Incorrect PIN",
"bad_pin_format": "PIN should be 4 digits",
- "two_factor_request_failed": "Request for 2FA code failed, please try again",
"bad_validation_code_format": "Validation code should be 6 digits",
"incorrect_validation_code": "Incorrect validation code"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
- "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
+ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
+ "two_factor_request_failed": "Request for 2FA code failed, please try again"
}
},
"options": {
diff --git a/homeassistant/components/suez_water/__init__.py b/homeassistant/components/suez_water/__init__.py
index 06f503b85c2..30f8c030c26 100644
--- a/homeassistant/components/suez_water/__init__.py
+++ b/homeassistant/components/suez_water/__init__.py
@@ -2,32 +2,65 @@
from __future__ import annotations
-from homeassistant.config_entries import ConfigEntry
+import logging
+
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
-from .const import DOMAIN
-from .coordinator import SuezWaterCoordinator
+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: ConfigEntry) -> bool:
+
+async def async_setup_entry(hass: HomeAssistant, entry: SuezWaterConfigEntry) -> bool:
"""Set up Suez Water from a config entry."""
coordinator = SuezWaterCoordinator(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: SuezWaterConfigEntry) -> 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 await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
- return unload_ok
+
+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 ac09cf4a1d3..fb8bc2988d6 100644
--- a/homeassistant/components/suez_water/config_flow.py
+++ b/homeassistant/components/suez_water/config_flow.py
@@ -37,31 +37,33 @@ async def validate_input(data: dict[str, Any]) -> None:
data[CONF_PASSWORD],
counter_id,
)
- if not await client.check_credentials():
- raise InvalidAuth
- except PySuezError as ex:
- raise CannotConnect from ex
-
- if counter_id is None:
try:
- data[CONF_COUNTER_ID] = await client.find_counter()
+ if not await client.check_credentials():
+ raise InvalidAuth
except PySuezError as ex:
- raise CounterNotFound from ex
+ raise CannotConnect from ex
+
+ if counter_id is None:
+ try:
+ data[CONF_COUNTER_ID] = await client.find_counter()
+ except PySuezError as ex:
+ raise CounterNotFound from ex
+ finally:
+ await client.close_session()
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:
@@ -74,12 +76,16 @@ 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", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
+ step_id="user",
+ data_schema=STEP_USER_DATA_SCHEMA,
+ errors=errors,
+ description_placeholders={"tout_sur_mon_eau": "Tout sur mon Eau"},
)
@@ -92,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 55f3ba348d4..38f94b8937e 100644
--- a/homeassistant/components/suez_water/coordinator.py
+++ b/homeassistant/components/suez_water/coordinator.py
@@ -1,6 +1,9 @@
"""Suez water update coordinator."""
-from pysuez import AggregatedData, PySuezError, SuezClient
+from dataclasses import dataclass
+from datetime import date
+
+from pysuez import PySuezError, SuezClient
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
@@ -11,13 +14,37 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda
from .const import CONF_COUNTER_ID, DATA_REFRESH_INTERVAL, DOMAIN
-class SuezWaterCoordinator(DataUpdateCoordinator[AggregatedData]):
+@dataclass
+class SuezWaterAggregatedAttributes:
+ """Class containing aggregated sensor extra attributes."""
+
+ this_month_consumption: dict[str, float]
+ previous_month_consumption: dict[str, float]
+ last_year_overall: dict[str, float]
+ this_year_overall: dict[str, float]
+ history: dict[str, float]
+ highest_monthly_consumption: float
+
+
+@dataclass
+class SuezWaterData:
+ """Class used to hold all fetch data from suez api."""
+
+ aggregated_value: float
+ aggregated_attr: SuezWaterAggregatedAttributes
+ price: float
+
+
+type SuezWaterConfigEntry = ConfigEntry[SuezWaterCoordinator]
+
+
+class SuezWaterCoordinator(DataUpdateCoordinator[SuezWaterData]):
"""Suez water coordinator."""
_suez_client: SuezClient
- config_entry: ConfigEntry
+ config_entry: SuezWaterConfigEntry
- def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None:
+ def __init__(self, hass: HomeAssistant, config_entry: SuezWaterConfigEntry) -> None:
"""Initialize suez water coordinator."""
super().__init__(
hass,
@@ -37,14 +64,27 @@ class SuezWaterCoordinator(DataUpdateCoordinator[AggregatedData]):
if not await self._suez_client.check_credentials():
raise ConfigEntryError("Invalid credentials for suez water")
- async def _async_update_data(self) -> AggregatedData:
+ async def _async_update_data(self) -> SuezWaterData:
"""Fetch data from API endpoint."""
+
+ def map_dict(param: dict[date, float]) -> dict[str, float]:
+ return {str(key): value for key, value in param.items()}
+
try:
- data = await self._suez_client.fetch_aggregated_data()
+ aggregated = await self._suez_client.fetch_aggregated_data()
+ data = SuezWaterData(
+ aggregated_value=aggregated.value,
+ aggregated_attr=SuezWaterAggregatedAttributes(
+ this_month_consumption=map_dict(aggregated.current_month),
+ previous_month_consumption=map_dict(aggregated.previous_month),
+ highest_monthly_consumption=aggregated.highest_monthly_consumption,
+ last_year_overall=aggregated.previous_year,
+ this_year_overall=aggregated.current_year,
+ history=map_dict(aggregated.history),
+ ),
+ 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/manifest.json b/homeassistant/components/suez_water/manifest.json
index 5eb05b9acb7..5d317ea5ba3 100644
--- a/homeassistant/components/suez_water/manifest.json
+++ b/homeassistant/components/suez_water/manifest.json
@@ -6,5 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/suez_water",
"iot_class": "cloud_polling",
"loggers": ["pysuez", "regex"],
- "requirements": ["pysuezV2==1.3.1"]
+ "quality_scale": "bronze",
+ "requirements": ["pysuezV2==2.0.3"]
}
diff --git a/homeassistant/components/suez_water/quality_scale.yaml b/homeassistant/components/suez_water/quality_scale.yaml
new file mode 100644
index 00000000000..dae9002b7dd
--- /dev/null
+++ b/homeassistant/components/suez_water/quality_scale.yaml
@@ -0,0 +1,98 @@
+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: no subscription to api
+ dependency-transparency: done
+ action-setup:
+ status: exempt
+ comment: no service action
+ common-modules: done
+ docs-high-level-description: done
+ docs-installation-instructions: done
+ docs-removal-instructions: done
+ docs-actions:
+ status: exempt
+ comment: no service action
+ brands: done
+
+ # Silver
+ config-entry-unloading:
+ status: todo
+ comment: cleanly close session
+ log-when-unavailable: done
+ entity-unavailable: done
+ action-exceptions:
+ status: exempt
+ comment: no service action
+ reauthentication-flow: todo
+ parallel-updates:
+ status: exempt
+ comment: no service action and coordinator updates
+ test-coverage: done
+ integration-owner: done
+ docs-installation-parameters:
+ status: todo
+ comment: missing user/password
+ docs-configuration-parameters:
+ status: exempt
+ comment: no configuration option
+
+ # Gold
+ entity-translations: done
+ entity-device-class: done
+ 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: todo
+ comment: see devices
+ diagnostics: todo
+ exception-translations: todo
+ icon-translations:
+ status: exempt
+ 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: devices are not network dependent and will not be updated during their lives
+ repair-issues:
+ status: exempt
+ comment: No repair issues to be raised
+ docs-use-cases: done
+ docs-supported-devices: todo
+ docs-supported-functions: done
+ docs-data-update:
+ status: todo
+ comment: make it clearer
+ docs-known-limitations: todo
+ docs-troubleshooting: todo
+ docs-examples: todo
+
+ # Platinum
+ async-dependency: done
+ inject-websession: todo
+ strict-typing: done
diff --git a/homeassistant/components/suez_water/sensor.py b/homeassistant/components/suez_water/sensor.py
index 22a61c835e1..1152ebd551b 100644
--- a/homeassistant/components/suez_water/sensor.py
+++ b/homeassistant/components/suez_water/sensor.py
@@ -2,68 +2,97 @@
from __future__ import annotations
-from collections.abc import Mapping
+from collections.abc import Callable
+from dataclasses import asdict, dataclass
from typing import Any
-from homeassistant.components.sensor import SensorDeviceClass, SensorEntity
-from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import UnitOfVolume
+from pysuez.const import ATTRIBUTION
+
+from homeassistant.components.sensor import (
+ SensorDeviceClass,
+ SensorEntity,
+ SensorEntityDescription,
+)
+from homeassistant.const import CURRENCY_EURO, UnitOfVolume
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 CONF_COUNTER_ID, DOMAIN
-from .coordinator import SuezWaterCoordinator
+from .coordinator import SuezWaterConfigEntry, SuezWaterCoordinator, SuezWaterData
+
+
+@dataclass(frozen=True, kw_only=True)
+class SuezWaterSensorEntityDescription(SensorEntityDescription):
+ """Describes Suez water sensor entity."""
+
+ value_fn: Callable[[SuezWaterData], float | str | None]
+ attr_fn: Callable[[SuezWaterData], dict[str, Any] | None] = lambda _: None
+
+
+SENSORS: tuple[SuezWaterSensorEntityDescription, ...] = (
+ SuezWaterSensorEntityDescription(
+ key="water_usage_yesterday",
+ translation_key="water_usage_yesterday",
+ native_unit_of_measurement=UnitOfVolume.LITERS,
+ device_class=SensorDeviceClass.WATER,
+ value_fn=lambda suez_data: suez_data.aggregated_value,
+ attr_fn=lambda suez_data: asdict(suez_data.aggregated_attr),
+ ),
+ SuezWaterSensorEntityDescription(
+ key="water_price",
+ translation_key="water_price",
+ native_unit_of_measurement=CURRENCY_EURO,
+ device_class=SensorDeviceClass.MONETARY,
+ value_fn=lambda suez_data: suez_data.price,
+ ),
+)
async def async_setup_entry(
hass: HomeAssistant,
- entry: ConfigEntry,
+ entry: SuezWaterConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Suez Water sensor from a config entry."""
- coordinator = hass.data[DOMAIN][entry.entry_id]
- async_add_entities([SuezAggregatedSensor(coordinator, entry.data[CONF_COUNTER_ID])])
+ coordinator = entry.runtime_data
+ counter_id = entry.data[CONF_COUNTER_ID]
+
+ async_add_entities(
+ SuezWaterSensor(coordinator, counter_id, description) for description in SENSORS
+ )
-class SuezAggregatedSensor(CoordinatorEntity[SuezWaterCoordinator], SensorEntity):
- """Representation of a Sensor."""
+class SuezWaterSensor(CoordinatorEntity[SuezWaterCoordinator], SensorEntity):
+ """Representation of a Suez water sensor."""
_attr_has_entity_name = True
- _attr_translation_key = "water_usage_yesterday"
- _attr_native_unit_of_measurement = UnitOfVolume.LITERS
- _attr_device_class = SensorDeviceClass.WATER
+ _attr_attribution = ATTRIBUTION
+ entity_description: SuezWaterSensorEntityDescription
- def __init__(self, coordinator: SuezWaterCoordinator, counter_id: int) -> None:
- """Initialize the data object."""
+ def __init__(
+ self,
+ coordinator: SuezWaterCoordinator,
+ counter_id: int,
+ entity_description: SuezWaterSensorEntityDescription,
+ ) -> None:
+ """Initialize the suez water sensor entity."""
super().__init__(coordinator)
- self._attr_extra_state_attributes = {}
- self._attr_unique_id = f"{counter_id}_water_usage_yesterday"
+ self._attr_unique_id = f"{counter_id}_{entity_description.key}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, str(counter_id))},
entry_type=DeviceEntryType.SERVICE,
manufacturer="Suez",
)
+ self.entity_description = entity_description
@property
- def native_value(self) -> float:
- """Return the current daily usage."""
- return self.coordinator.data.value
+ def native_value(self) -> float | str | None:
+ """Return the state of the sensor."""
+ return self.entity_description.value_fn(self.coordinator.data)
@property
- def attribution(self) -> str:
- """Return data attribution message."""
- return self.coordinator.data.attribution
-
- @property
- def extra_state_attributes(self) -> Mapping[str, Any]:
- """Return aggregated data."""
- return {
- "this_month_consumption": self.coordinator.data.current_month,
- "previous_month_consumption": self.coordinator.data.previous_month,
- "highest_monthly_consumption": self.coordinator.data.highest_monthly_consumption,
- "last_year_overall": self.coordinator.data.previous_year,
- "this_year_overall": self.coordinator.data.current_year,
- "history": self.coordinator.data.history,
- }
+ def extra_state_attributes(self) -> dict[str, Any] | None:
+ """Return extra state of the sensor."""
+ return self.entity_description.attr_fn(self.coordinator.data)
diff --git a/homeassistant/components/suez_water/strings.json b/homeassistant/components/suez_water/strings.json
index a1af12abd55..be2d4849e76 100644
--- a/homeassistant/components/suez_water/strings.json
+++ b/homeassistant/components/suez_water/strings.json
@@ -5,15 +5,21 @@
"data": {
"username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]",
- "counter_id": "Counter id"
- }
+ "counter_id": "Meter id"
+ },
+ "data_description": {
+ "username": "Enter your login associated with your {tout_sur_mon_eau} account",
+ "password": "Enter your password associated with your {tout_sur_mon_eau} account",
+ "counter_id": "Enter your meter id (ex: 12345678). Should be found automatically during setup, if not see integration documentation for more information"
+ },
+ "description": "Connect your suez water {tout_sur_mon_eau} account to retrieve your water consumption"
}
},
"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%]",
- "counter_not_found": "Could not find counter id automatically"
+ "counter_not_found": "Could not find meter id automatically"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
@@ -23,6 +29,9 @@
"sensor": {
"water_usage_yesterday": {
"name": "Water usage yesterday"
+ },
+ "water_price": {
+ "name": "Water price"
}
}
}
diff --git a/homeassistant/components/sun/__init__.py b/homeassistant/components/sun/__init__.py
index 8f6f3098ee8..f42f5450462 100644
--- a/homeassistant/components/sun/__init__.py
+++ b/homeassistant/components/sun/__init__.py
@@ -2,10 +2,13 @@
from __future__ import annotations
+import logging
+
from homeassistant.config_entries import SOURCE_IMPORT
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.typing import ConfigType
# The sensor platform is pre-imported here to ensure
@@ -23,6 +26,8 @@ from .entity import Sun, SunConfigEntry
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
+_LOGGER = logging.getLogger(__name__)
+
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Track the state of the sun."""
@@ -42,7 +47,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: SunConfigEntry) -> bool:
"""Set up from a config entry."""
- entry.runtime_data = sun = Sun(hass)
+ sun = Sun(hass)
+ component = EntityComponent[Sun](_LOGGER, DOMAIN, hass)
+ await component.async_add_entities([sun])
+ entry.runtime_data = sun
entry.async_on_unload(sun.remove_listeners)
await hass.config_entries.async_forward_entry_setups(entry, [Platform.SENSOR])
return True
@@ -53,6 +61,5 @@ async def async_unload_entry(hass: HomeAssistant, entry: SunConfigEntry) -> bool
if unload_ok := await hass.config_entries.async_unload_platforms(
entry, [Platform.SENSOR]
):
- sun = entry.runtime_data
- hass.states.async_remove(sun.entity_id)
+ await entry.runtime_data.async_remove()
return unload_ok
diff --git a/homeassistant/components/sun/entity.py b/homeassistant/components/sun/entity.py
index 10d328afde7..925845c8b4d 100644
--- a/homeassistant/components/sun/entity.py
+++ b/homeassistant/components/sun/entity.py
@@ -100,9 +100,6 @@ class Sun(Entity):
_attr_name = "Sun"
entity_id = ENTITY_ID
- # This entity is legacy and does not have a platform.
- # We can't fix this easily without breaking changes.
- _no_platform_reported = True
location: Location
elevation: Elevation
@@ -122,18 +119,16 @@ class Sun(Entity):
self.hass = hass
self.phase: str | None = None
- # This is normally done by async_internal_added_to_hass which is not called
- # for sun because sun has no platform
- self._state_info = {
- "unrecorded_attributes": self._Entity__combined_unrecorded_attributes # type: ignore[attr-defined]
- }
-
self._config_listener: CALLBACK_TYPE | None = None
self._update_events_listener: CALLBACK_TYPE | None = None
self._update_sun_position_listener: CALLBACK_TYPE | None = None
self._config_listener = self.hass.bus.async_listen(
EVENT_CORE_CONFIG_UPDATE, self.update_location
)
+
+ async def async_added_to_hass(self) -> None:
+ """Update after entity has been added."""
+ await super().async_added_to_hass()
self.update_location(initial=True)
@callback
diff --git a/homeassistant/components/supervisord/manifest.json b/homeassistant/components/supervisord/manifest.json
index 7586a435ed7..3cdbdd230aa 100644
--- a/homeassistant/components/supervisord/manifest.json
+++ b/homeassistant/components/supervisord/manifest.json
@@ -3,5 +3,6 @@
"name": "Supervisord",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/supervisord",
- "iot_class": "local_polling"
+ "iot_class": "local_polling",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/supla/manifest.json b/homeassistant/components/supla/manifest.json
index 6927c92c6e1..803a321c0d6 100644
--- a/homeassistant/components/supla/manifest.json
+++ b/homeassistant/components/supla/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/supla",
"iot_class": "cloud_polling",
"loggers": ["asyncpysupla"],
+ "quality_scale": "legacy",
"requirements": ["asyncpysupla==0.0.5"]
}
diff --git a/homeassistant/components/swiss_hydrological_data/manifest.json b/homeassistant/components/swiss_hydrological_data/manifest.json
index 14e2882804e..11b49a42e3f 100644
--- a/homeassistant/components/swiss_hydrological_data/manifest.json
+++ b/homeassistant/components/swiss_hydrological_data/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/swiss_hydrological_data",
"iot_class": "cloud_polling",
"loggers": ["swisshydrodata"],
+ "quality_scale": "legacy",
"requirements": ["swisshydrodata==0.1.0"]
}
diff --git a/homeassistant/components/swiss_public_transport/__init__.py b/homeassistant/components/swiss_public_transport/__init__.py
index bceac6007a2..628f6e95c2a 100644
--- a/homeassistant/components/swiss_public_transport/__init__.py
+++ b/homeassistant/components/swiss_public_transport/__init__.py
@@ -19,12 +19,22 @@ from homeassistant.helpers import (
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.typing import ConfigType
-from .const import CONF_DESTINATION, CONF_START, CONF_VIA, DOMAIN, PLACEHOLDERS
+from .const import (
+ CONF_DESTINATION,
+ CONF_START,
+ CONF_TIME_FIXED,
+ CONF_TIME_OFFSET,
+ CONF_TIME_STATION,
+ CONF_VIA,
+ DEFAULT_TIME_STATION,
+ DOMAIN,
+ PLACEHOLDERS,
+)
from .coordinator import (
SwissPublicTransportConfigEntry,
SwissPublicTransportDataUpdateCoordinator,
)
-from .helper import unique_id_from_config
+from .helper import offset_opendata, unique_id_from_config
from .services import setup_services
_LOGGER = logging.getLogger(__name__)
@@ -50,8 +60,19 @@ async def async_setup_entry(
start = config[CONF_START]
destination = config[CONF_DESTINATION]
+ time_offset: dict[str, int] | None = config.get(CONF_TIME_OFFSET)
+
session = async_get_clientsession(hass)
- opendata = OpendataTransport(start, destination, session, via=config.get(CONF_VIA))
+ opendata = OpendataTransport(
+ start,
+ destination,
+ session,
+ via=config.get(CONF_VIA),
+ time=config.get(CONF_TIME_FIXED),
+ isArrivalTime=config.get(CONF_TIME_STATION, DEFAULT_TIME_STATION) == "arrival",
+ )
+ if time_offset:
+ offset_opendata(opendata, time_offset)
try:
await opendata.async_get_data()
@@ -75,7 +96,7 @@ async def async_setup_entry(
},
) from e
- coordinator = SwissPublicTransportDataUpdateCoordinator(hass, opendata)
+ coordinator = SwissPublicTransportDataUpdateCoordinator(hass, opendata, time_offset)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
@@ -96,7 +117,7 @@ async def async_migrate_entry(
"""Migrate config entry."""
_LOGGER.debug("Migrating from version %s", config_entry.version)
- if config_entry.version > 2:
+ if config_entry.version > 3:
# This means the user has downgraded from a future version
return False
@@ -131,9 +152,9 @@ async def async_migrate_entry(
config_entry, unique_id=new_unique_id, minor_version=2
)
- if config_entry.version < 2:
- # Via stations now available, which are not backwards compatible if used, changes unique id
- hass.config_entries.async_update_entry(config_entry, version=2, minor_version=1)
+ if config_entry.version < 3:
+ # Via stations and time/offset settings now available, which are not backwards compatible if used, changes unique id
+ hass.config_entries.async_update_entry(config_entry, version=3, minor_version=1)
_LOGGER.debug(
"Migration to version %s.%s successful",
diff --git a/homeassistant/components/swiss_public_transport/config_flow.py b/homeassistant/components/swiss_public_transport/config_flow.py
index 74c6223f1d9..58d674f0c26 100644
--- a/homeassistant/components/swiss_public_transport/config_flow.py
+++ b/homeassistant/components/swiss_public_transport/config_flow.py
@@ -14,15 +14,35 @@ from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.helpers.aiohttp_client import async_get_clientsession
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.selector import (
+ DurationSelector,
+ SelectSelector,
+ SelectSelectorConfig,
+ SelectSelectorMode,
TextSelector,
TextSelectorConfig,
TextSelectorType,
+ TimeSelector,
)
-from .const import CONF_DESTINATION, CONF_START, CONF_VIA, DOMAIN, MAX_VIA, PLACEHOLDERS
-from .helper import unique_id_from_config
+from .const import (
+ CONF_DESTINATION,
+ CONF_START,
+ CONF_TIME_FIXED,
+ CONF_TIME_MODE,
+ CONF_TIME_OFFSET,
+ CONF_TIME_STATION,
+ CONF_VIA,
+ DEFAULT_TIME_MODE,
+ DEFAULT_TIME_STATION,
+ DOMAIN,
+ IS_ARRIVAL_OPTIONS,
+ MAX_VIA,
+ PLACEHOLDERS,
+ TIME_MODE_OPTIONS,
+)
+from .helper import offset_opendata, unique_id_from_config
-DATA_SCHEMA = vol.Schema(
+USER_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_START): cv.string,
vol.Optional(CONF_VIA): TextSelector(
@@ -32,8 +52,25 @@ DATA_SCHEMA = vol.Schema(
),
),
vol.Required(CONF_DESTINATION): cv.string,
+ vol.Optional(CONF_TIME_MODE, default=DEFAULT_TIME_MODE): SelectSelector(
+ SelectSelectorConfig(
+ options=TIME_MODE_OPTIONS,
+ mode=SelectSelectorMode.DROPDOWN,
+ translation_key="time_mode",
+ ),
+ ),
+ vol.Optional(CONF_TIME_STATION, default=DEFAULT_TIME_STATION): SelectSelector(
+ SelectSelectorConfig(
+ options=IS_ARRIVAL_OPTIONS,
+ mode=SelectSelectorMode.DROPDOWN,
+ translation_key="time_station",
+ ),
+ ),
}
)
+ADVANCED_TIME_DATA_SCHEMA = {vol.Optional(CONF_TIME_FIXED): TimeSelector()}
+ADVANCED_TIME_OFFSET_DATA_SCHEMA = {vol.Optional(CONF_TIME_OFFSET): DurationSelector()}
+
_LOGGER = logging.getLogger(__name__)
@@ -41,39 +78,33 @@ _LOGGER = logging.getLogger(__name__)
class SwissPublicTransportConfigFlow(ConfigFlow, domain=DOMAIN):
"""Swiss public transport config flow."""
- VERSION = 2
+ VERSION = 3
MINOR_VERSION = 1
+ user_input: dict[str, Any]
+
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Async user step to set up the connection."""
errors: dict[str, str] = {}
if user_input is not None:
- unique_id = unique_id_from_config(user_input)
- await self.async_set_unique_id(unique_id)
- self._abort_if_unique_id_configured()
-
if CONF_VIA in user_input and len(user_input[CONF_VIA]) > MAX_VIA:
errors["base"] = "too_many_via_stations"
else:
- session = async_get_clientsession(self.hass)
- opendata = OpendataTransport(
- user_input[CONF_START],
- user_input[CONF_DESTINATION],
- session,
- via=user_input.get(CONF_VIA),
- )
- try:
- await opendata.async_get_data()
- except OpendataTransportConnectionError:
- errors["base"] = "cannot_connect"
- except OpendataTransportError:
- errors["base"] = "bad_config"
- except Exception: # pylint: disable=broad-except
- _LOGGER.exception("Unknown error")
- errors["base"] = "unknown"
+ err = await self.fetch_connections(user_input)
+ if err:
+ errors["base"] = err
else:
+ self.user_input = user_input
+ if user_input[CONF_TIME_MODE] == "fixed":
+ return await self.async_step_time_fixed()
+ if user_input[CONF_TIME_MODE] == "offset":
+ return await self.async_step_time_offset()
+
+ unique_id = unique_id_from_config(user_input)
+ await self.async_set_unique_id(unique_id)
+ self._abort_if_unique_id_configured()
return self.async_create_entry(
title=unique_id,
data=user_input,
@@ -81,7 +112,85 @@ class SwissPublicTransportConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_show_form(
step_id="user",
- data_schema=DATA_SCHEMA,
+ data_schema=self.add_suggested_values_to_schema(
+ data_schema=USER_DATA_SCHEMA,
+ suggested_values=user_input,
+ ),
errors=errors,
description_placeholders=PLACEHOLDERS,
)
+
+ async def async_step_time_fixed(
+ self, time_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Async time step to set up the connection."""
+ return await self._async_step_time_mode(
+ CONF_TIME_FIXED, vol.Schema(ADVANCED_TIME_DATA_SCHEMA), time_input
+ )
+
+ async def async_step_time_offset(
+ self, time_offset_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Async time offset step to set up the connection."""
+ return await self._async_step_time_mode(
+ CONF_TIME_OFFSET,
+ vol.Schema(ADVANCED_TIME_OFFSET_DATA_SCHEMA),
+ time_offset_input,
+ )
+
+ async def _async_step_time_mode(
+ self,
+ step_id: str,
+ time_mode_schema: vol.Schema,
+ time_mode_input: dict[str, Any] | None = None,
+ ) -> ConfigFlowResult:
+ """Async time mode step to set up the connection."""
+ errors: dict[str, str] = {}
+ if time_mode_input is not None:
+ unique_id = unique_id_from_config({**self.user_input, **time_mode_input})
+ await self.async_set_unique_id(unique_id)
+ self._abort_if_unique_id_configured()
+
+ err = await self.fetch_connections(
+ {**self.user_input, **time_mode_input},
+ time_mode_input.get(CONF_TIME_OFFSET),
+ )
+ if err:
+ errors["base"] = err
+ else:
+ return self.async_create_entry(
+ title=unique_id,
+ data={**self.user_input, **time_mode_input},
+ )
+
+ return self.async_show_form(
+ step_id=step_id,
+ data_schema=time_mode_schema,
+ errors=errors,
+ description_placeholders=PLACEHOLDERS,
+ )
+
+ async def fetch_connections(
+ self, input: dict[str, Any], time_offset: dict[str, int] | None = None
+ ) -> str | None:
+ """Fetch the connections and advancedly return an error."""
+ try:
+ session = async_get_clientsession(self.hass)
+ opendata = OpendataTransport(
+ input[CONF_START],
+ input[CONF_DESTINATION],
+ session,
+ via=input.get(CONF_VIA),
+ time=input.get(CONF_TIME_FIXED),
+ )
+ if time_offset:
+ offset_opendata(opendata, time_offset)
+ await opendata.async_get_data()
+ except OpendataTransportConnectionError:
+ return "cannot_connect"
+ except OpendataTransportError:
+ return "bad_config"
+ except Exception: # pylint: disable=broad-except
+ _LOGGER.exception("Unknown error")
+ return "unknown"
+ return None
diff --git a/homeassistant/components/swiss_public_transport/const.py b/homeassistant/components/swiss_public_transport/const.py
index c02f36f2f25..10bfc0d0355 100644
--- a/homeassistant/components/swiss_public_transport/const.py
+++ b/homeassistant/components/swiss_public_transport/const.py
@@ -7,13 +7,21 @@ DOMAIN = "swiss_public_transport"
CONF_DESTINATION: Final = "to"
CONF_START: Final = "from"
CONF_VIA: Final = "via"
+CONF_TIME_STATION: Final = "time_station"
+CONF_TIME_MODE: Final = "time_mode"
+CONF_TIME_FIXED: Final = "time_fixed"
+CONF_TIME_OFFSET: Final = "time_offset"
DEFAULT_NAME = "Next Destination"
DEFAULT_UPDATE_TIME = 90
+DEFAULT_TIME_STATION = "departure"
+DEFAULT_TIME_MODE = "now"
MAX_VIA = 5
CONNECTIONS_COUNT = 3
CONNECTIONS_MAX = 15
+IS_ARRIVAL_OPTIONS = ["departure", "arrival"]
+TIME_MODE_OPTIONS = ["now", "fixed", "offset"]
PLACEHOLDERS = {
diff --git a/homeassistant/components/swiss_public_transport/coordinator.py b/homeassistant/components/swiss_public_transport/coordinator.py
index e6413e6f772..c4cf2390dd0 100644
--- a/homeassistant/components/swiss_public_transport/coordinator.py
+++ b/homeassistant/components/swiss_public_transport/coordinator.py
@@ -19,6 +19,7 @@ import homeassistant.util.dt as dt_util
from homeassistant.util.json import JsonValueType
from .const import CONNECTIONS_COUNT, DEFAULT_UPDATE_TIME, DOMAIN
+from .helper import offset_opendata
_LOGGER = logging.getLogger(__name__)
@@ -57,7 +58,12 @@ class SwissPublicTransportDataUpdateCoordinator(
config_entry: SwissPublicTransportConfigEntry
- def __init__(self, hass: HomeAssistant, opendata: OpendataTransport) -> None:
+ def __init__(
+ self,
+ hass: HomeAssistant,
+ opendata: OpendataTransport,
+ time_offset: dict[str, int] | None,
+ ) -> None:
"""Initialize the SwissPublicTransport data coordinator."""
super().__init__(
hass,
@@ -66,6 +72,7 @@ class SwissPublicTransportDataUpdateCoordinator(
update_interval=timedelta(seconds=DEFAULT_UPDATE_TIME),
)
self._opendata = opendata
+ self._time_offset = time_offset
def remaining_time(self, departure) -> timedelta | None:
"""Calculate the remaining time for the departure."""
@@ -81,6 +88,9 @@ class SwissPublicTransportDataUpdateCoordinator(
async def fetch_connections(self, limit: int) -> list[DataConnection]:
"""Fetch connections using the opendata api."""
self._opendata.limit = limit
+ if self._time_offset:
+ offset_opendata(self._opendata, self._time_offset)
+
try:
await self._opendata.async_get_data()
except OpendataTransportConnectionError as e:
@@ -103,7 +113,7 @@ class SwissPublicTransportDataUpdateCoordinator(
destination=self._opendata.to_name,
remaining_time=str(self.remaining_time(connections[i]["departure"])),
delay=connections[i]["delay"],
- line=connections[i]["line"],
+ line=connections[i].get("line"),
)
for i in range(limit)
if len(connections) > i and connections[i] is not None
@@ -124,7 +134,7 @@ class SwissPublicTransportDataUpdateCoordinator(
"train_number": connection["train_number"],
"transfers": connection["transfers"],
"delay": connection["delay"],
- "line": connection["line"],
+ "line": connection.get("line"),
}
for connection in await self.fetch_connections(limit)
]
diff --git a/homeassistant/components/swiss_public_transport/helper.py b/homeassistant/components/swiss_public_transport/helper.py
index af03f7ad193..704479b77d6 100644
--- a/homeassistant/components/swiss_public_transport/helper.py
+++ b/homeassistant/components/swiss_public_transport/helper.py
@@ -1,15 +1,59 @@
"""Helper functions for swiss_public_transport."""
+from datetime import timedelta
from types import MappingProxyType
from typing import Any
-from .const import CONF_DESTINATION, CONF_START, CONF_VIA
+from opendata_transport import OpendataTransport
+
+import homeassistant.util.dt as dt_util
+
+from .const import (
+ CONF_DESTINATION,
+ CONF_START,
+ CONF_TIME_FIXED,
+ CONF_TIME_OFFSET,
+ CONF_TIME_STATION,
+ CONF_VIA,
+ DEFAULT_TIME_STATION,
+)
+
+
+def offset_opendata(opendata: OpendataTransport, offset: dict[str, int]) -> None:
+ """In place offset the opendata connector."""
+
+ duration = timedelta(**offset)
+ if duration:
+ now_offset = dt_util.as_local(dt_util.now() + duration)
+ opendata.date = now_offset.date()
+ opendata.time = now_offset.time()
+
+
+def dict_duration_to_str_duration(
+ d: dict[str, int],
+) -> str:
+ """Build a string from a dict duration."""
+ return f"{d['hours']:02d}:{d['minutes']:02d}:{d['seconds']:02d}"
def unique_id_from_config(config: MappingProxyType[str, Any] | dict[str, Any]) -> str:
"""Build a unique id from a config entry."""
- return f"{config[CONF_START]} {config[CONF_DESTINATION]}" + (
- " via " + ", ".join(config[CONF_VIA])
- if CONF_VIA in config and len(config[CONF_VIA]) > 0
- else ""
+ return (
+ f"{config[CONF_START]} {config[CONF_DESTINATION]}"
+ + (
+ " via " + ", ".join(config[CONF_VIA])
+ if CONF_VIA in config and len(config[CONF_VIA]) > 0
+ else ""
+ )
+ + (
+ " arrival"
+ if config.get(CONF_TIME_STATION, DEFAULT_TIME_STATION) == "arrival"
+ else ""
+ )
+ + (" at " + config[CONF_TIME_FIXED] if CONF_TIME_FIXED in config else "")
+ + (
+ " in " + dict_duration_to_str_duration(config[CONF_TIME_OFFSET])
+ if CONF_TIME_OFFSET in config
+ else ""
+ )
)
diff --git a/homeassistant/components/swiss_public_transport/quality_scale.yaml b/homeassistant/components/swiss_public_transport/quality_scale.yaml
new file mode 100644
index 00000000000..75dc642d77f
--- /dev/null
+++ b/homeassistant/components/swiss_public_transport/quality_scale.yaml
@@ -0,0 +1,86 @@
+rules:
+ # Bronze
+ action-setup: done
+ appropriate-polling:
+ status: done
+ comment: >
+ Polling interval is set to support one connection.
+ There is a rate limit at 10000 calls per day.
+ brands: done
+ common-modules: done
+ config-flow-test-coverage: done
+ config-flow: done
+ dependency-transparency: todo
+ docs-actions: done
+ docs-high-level-description: done
+ docs-installation-instructions: done
+ docs-removal-instructions: done
+ entity-event-setup:
+ status: exempt
+ comment: No events implemented
+ 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
+ log-when-unavailable:
+ status: done
+ comment: Offloaded to coordinator
+ entity-unavailable:
+ status: done
+ comment: Offloaded to coordinator
+ action-exceptions: done
+ reauthentication-flow:
+ status: exempt
+ comment: No authentication needed
+ parallel-updates: done
+ test-coverage: todo
+ integration-owner: done
+ docs-installation-parameters: done
+ docs-configuration-parameters:
+ status: exempt
+ comment: no options flow
+
+ # Gold
+ entity-translations: done
+ entity-device-class: done
+ devices: done
+ entity-category: done
+ entity-disabled-by-default:
+ status: done
+ comment: No disabled entities implemented
+ discovery:
+ status: exempt
+ comment: Nothing to discover
+ stale-devices:
+ status: exempt
+ comment: Stale not possible
+ diagnostics: todo
+ exception-translations: done
+ icon-translations: done
+ reconfiguration-flow: todo
+ dynamic-devices:
+ status: exempt
+ comment: No dynamic devices
+ discovery-update-info:
+ status: exempt
+ comment: Nothing to discover
+ repair-issues:
+ status: exempt
+ comment: Nothing to repair
+ docs-use-cases: todo
+ docs-supported-devices: done
+ docs-supported-functions: done
+ docs-data-update: done
+ docs-known-limitations: todo
+ docs-troubleshooting: todo
+ docs-examples: todo
+
+ # Platinum
+ async-dependency: done
+ inject-websession: done
+ strict-typing: todo
diff --git a/homeassistant/components/swiss_public_transport/sensor.py b/homeassistant/components/swiss_public_transport/sensor.py
index 452ec31972f..a0131938a37 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)
diff --git a/homeassistant/components/swiss_public_transport/strings.json b/homeassistant/components/swiss_public_transport/strings.json
index b3bfd9aea8f..ef8cc5595e3 100644
--- a/homeassistant/components/swiss_public_transport/strings.json
+++ b/homeassistant/components/swiss_public_transport/strings.json
@@ -17,10 +17,39 @@
"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.",
"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"
+ },
+ "time_offset": {
+ "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"
}
}
},
@@ -84,5 +113,20 @@
"config_entry_not_found": {
"message": "Swiss public transport integration instance \"{target}\" not found."
}
+ },
+ "selector": {
+ "time_station": {
+ "options": {
+ "departure": "Show departure time from start station",
+ "arrival": "Show arrival time at end station"
+ }
+ },
+ "time_mode": {
+ "options": {
+ "now": "Now",
+ "fixed": "At a fixed time of day",
+ "offset": "At an offset from now"
+ }
+ }
}
}
diff --git a/homeassistant/components/swisscom/manifest.json b/homeassistant/components/swisscom/manifest.json
index cb0e674570e..cf1ea01ea9c 100644
--- a/homeassistant/components/swisscom/manifest.json
+++ b/homeassistant/components/swisscom/manifest.json
@@ -3,5 +3,6 @@
"name": "Swisscom Internet-Box",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/swisscom",
- "iot_class": "local_polling"
+ "iot_class": "local_polling",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/switch/__init__.py b/homeassistant/components/switch/__init__.py
index 9838d9501f7..61ee2908009 100644
--- a/homeassistant/components/switch/__init__.py
+++ b/homeassistant/components/switch/__init__.py
@@ -4,7 +4,6 @@ from __future__ import annotations
from datetime import timedelta
from enum import StrEnum
-from functools import partial
import logging
from propcache import cached_property
@@ -19,12 +18,6 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
-from homeassistant.helpers.deprecation import (
- DeprecatedConstantEnum,
- all_with_deprecated_constants,
- check_if_deprecated_constant,
- dir_with_deprecated_constants,
-)
from homeassistant.helpers.entity import ToggleEntity, ToggleEntityDescription
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.typing import ConfigType
@@ -52,16 +45,8 @@ class SwitchDeviceClass(StrEnum):
DEVICE_CLASSES_SCHEMA = vol.All(vol.Lower, vol.Coerce(SwitchDeviceClass))
-
-# DEVICE_CLASS* below are deprecated as of 2021.12
-# use the SwitchDeviceClass enum instead.
DEVICE_CLASSES = [cls.value for cls in SwitchDeviceClass]
-_DEPRECATED_DEVICE_CLASS_OUTLET = DeprecatedConstantEnum(
- SwitchDeviceClass.OUTLET, "2025.1"
-)
-_DEPRECATED_DEVICE_CLASS_SWITCH = DeprecatedConstantEnum(
- SwitchDeviceClass.SWITCH, "2025.1"
-)
+
# mypy: disallow-any-generics
@@ -124,11 +109,3 @@ class SwitchEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_)
if hasattr(self, "entity_description"):
return self.entity_description.device_class
return None
-
-
-# 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/switch_as_x/config_flow.py b/homeassistant/components/switch_as_x/config_flow.py
index 37df3affbad..aa9f1d411ce 100644
--- a/homeassistant/components/switch_as_x/config_flow.py
+++ b/homeassistant/components/switch_as_x/config_flow.py
@@ -18,12 +18,12 @@ from homeassistant.helpers.schema_config_entry_flow import (
from .const import CONF_INVERT, CONF_TARGET_DOMAIN, DOMAIN
TARGET_DOMAIN_OPTIONS = [
- selector.SelectOptionDict(value=Platform.COVER, label="Cover"),
- selector.SelectOptionDict(value=Platform.FAN, label="Fan"),
- selector.SelectOptionDict(value=Platform.LIGHT, label="Light"),
- selector.SelectOptionDict(value=Platform.LOCK, label="Lock"),
- selector.SelectOptionDict(value=Platform.SIREN, label="Siren"),
- selector.SelectOptionDict(value=Platform.VALVE, label="Valve"),
+ Platform.COVER,
+ Platform.FAN,
+ Platform.LIGHT,
+ Platform.LOCK,
+ Platform.SIREN,
+ Platform.VALVE,
]
CONFIG_FLOW = {
@@ -35,7 +35,9 @@ CONFIG_FLOW = {
),
vol.Optional(CONF_INVERT, default=False): selector.BooleanSelector(),
vol.Required(CONF_TARGET_DOMAIN): selector.SelectSelector(
- selector.SelectSelectorConfig(options=TARGET_DOMAIN_OPTIONS),
+ selector.SelectSelectorConfig(
+ options=TARGET_DOMAIN_OPTIONS, translation_key="target_domain"
+ ),
),
}
)
diff --git a/homeassistant/components/switch_as_x/fan.py b/homeassistant/components/switch_as_x/fan.py
index 91d3a4d119a..858379e71df 100644
--- a/homeassistant/components/switch_as_x/fan.py
+++ b/homeassistant/components/switch_as_x/fan.py
@@ -46,7 +46,6 @@ class FanSwitch(BaseToggleEntity, FanEntity):
"""Represents a Switch as a Fan."""
_attr_supported_features = FanEntityFeature.TURN_OFF | FanEntityFeature.TURN_ON
- _enable_turn_on_off_backwards_compatibility = False
@property
def is_on(self) -> bool | None:
diff --git a/homeassistant/components/switch_as_x/strings.json b/homeassistant/components/switch_as_x/strings.json
index 81567ef9e40..9c3db05231b 100644
--- a/homeassistant/components/switch_as_x/strings.json
+++ b/homeassistant/components/switch_as_x/strings.json
@@ -26,5 +26,17 @@
}
}
}
+ },
+ "selector": {
+ "target_domain": {
+ "options": {
+ "cover": "[%key:component::cover::title%]",
+ "fan": "[%key:component::fan::title%]",
+ "light": "[%key:component::light::title%]",
+ "lock": "[%key:component::lock::title%]",
+ "siren": "[%key:component::siren::title%]",
+ "valve": "[%key:component::valve::title%]"
+ }
+ }
}
}
diff --git a/homeassistant/components/switchbee/climate.py b/homeassistant/components/switchbee/climate.py
index 7ec0ad4d88b..d946ed1761b 100644
--- a/homeassistant/components/switchbee/climate.py
+++ b/homeassistant/components/switchbee/climate.py
@@ -90,7 +90,6 @@ class SwitchBeeClimateEntity(SwitchBeeDeviceEntity[SwitchBeeThermostat], Climate
_attr_fan_modes = SUPPORTED_FAN_MODES
_attr_target_temperature_step = 1
- _enable_turn_on_off_backwards_compatibility = False
def __init__(
self,
diff --git a/homeassistant/components/switchbot/__init__.py b/homeassistant/components/switchbot/__init__.py
index c2b4b2ad736..499a5073872 100644
--- a/homeassistant/components/switchbot/__init__.py
+++ b/homeassistant/components/switchbot/__init__.py
@@ -24,6 +24,7 @@ from .const import (
CONF_RETRY_COUNT,
CONNECTABLE_SUPPORTED_MODEL_TYPES,
DEFAULT_RETRY_COUNT,
+ ENCRYPTED_MODELS,
HASS_SENSOR_TYPE_TO_SWITCHBOT_MODEL,
SupportedModels,
)
@@ -61,6 +62,9 @@ PLATFORMS_BY_TYPE = {
Platform.SENSOR,
],
SupportedModels.HUB2.value: [Platform.SENSOR],
+ SupportedModels.RELAY_SWITCH_1PM.value: [Platform.SWITCH, Platform.SENSOR],
+ SupportedModels.RELAY_SWITCH_1.value: [Platform.SWITCH],
+ SupportedModels.LEAK.value: [Platform.BINARY_SENSOR, Platform.SENSOR],
}
CLASS_BY_DEVICE = {
SupportedModels.CEILING_LIGHT.value: switchbot.SwitchbotCeilingLight,
@@ -73,6 +77,8 @@ CLASS_BY_DEVICE = {
SupportedModels.LOCK.value: switchbot.SwitchbotLock,
SupportedModels.LOCK_PRO.value: switchbot.SwitchbotLock,
SupportedModels.BLIND_TILT.value: switchbot.SwitchbotBlindTilt,
+ SupportedModels.RELAY_SWITCH_1PM.value: switchbot.SwitchbotRelaySwitch,
+ SupportedModels.RELAY_SWITCH_1.value: switchbot.SwitchbotRelaySwitch,
}
@@ -116,9 +122,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: SwitchbotConfigEntry) ->
)
cls = CLASS_BY_DEVICE.get(sensor_type, switchbot.SwitchbotDevice)
- if cls is switchbot.SwitchbotLock:
+ if switchbot_model in ENCRYPTED_MODELS:
try:
- device = switchbot.SwitchbotLock(
+ device = cls(
device=ble_device,
key_id=entry.data.get(CONF_KEY_ID),
encryption_key=entry.data.get(CONF_ENCRYPTION_KEY),
diff --git a/homeassistant/components/switchbot/binary_sensor.py b/homeassistant/components/switchbot/binary_sensor.py
index a545ffd01ce..144872ff315 100644
--- a/homeassistant/components/switchbot/binary_sensor.py
+++ b/homeassistant/components/switchbot/binary_sensor.py
@@ -64,6 +64,11 @@ BINARY_SENSOR_TYPES: dict[str, BinarySensorEntityDescription] = {
translation_key="door_auto_lock_paused",
entity_category=EntityCategory.DIAGNOSTIC,
),
+ "leak": BinarySensorEntityDescription(
+ key="leak",
+ name=None,
+ device_class=BinarySensorDeviceClass.MOISTURE,
+ ),
}
diff --git a/homeassistant/components/switchbot/config_flow.py b/homeassistant/components/switchbot/config_flow.py
index a0e45169770..fc2d9f491ac 100644
--- a/homeassistant/components/switchbot/config_flow.py
+++ b/homeassistant/components/switchbot/config_flow.py
@@ -10,7 +10,7 @@ from switchbot import (
SwitchBotAdvertisement,
SwitchbotApiError,
SwitchbotAuthenticationError,
- SwitchbotLock,
+ SwitchbotModel,
parse_advertisement_data,
)
import voluptuous as vol
@@ -44,8 +44,9 @@ from .const import (
DEFAULT_LOCK_NIGHTLATCH,
DEFAULT_RETRY_COUNT,
DOMAIN,
+ ENCRYPTED_MODELS,
+ ENCRYPTED_SWITCHBOT_MODEL_TO_CLASS,
NON_CONNECTABLE_SUPPORTED_MODEL_TYPES,
- SUPPORTED_LOCK_MODELS,
SUPPORTED_MODEL_TYPES,
SupportedModels,
)
@@ -112,8 +113,8 @@ class SwitchbotConfigFlow(ConfigFlow, domain=DOMAIN):
"name": data["modelFriendlyName"],
"address": short_address(discovery_info.address),
}
- if model_name in SUPPORTED_LOCK_MODELS:
- return await self.async_step_lock_choose_method()
+ if model_name in ENCRYPTED_MODELS:
+ return await self.async_step_encrypted_choose_method()
if self._discovered_adv.data["isEncrypted"]:
return await self.async_step_password()
return await self.async_step_confirm()
@@ -171,7 +172,7 @@ class SwitchbotConfigFlow(ConfigFlow, domain=DOMAIN):
},
)
- async def async_step_lock_auth(
+ async def async_step_encrypted_auth(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the SwitchBot API auth step."""
@@ -179,8 +180,10 @@ class SwitchbotConfigFlow(ConfigFlow, domain=DOMAIN):
assert self._discovered_adv is not None
description_placeholders = {}
if user_input is not None:
+ model: SwitchbotModel = self._discovered_adv.data["modelName"]
+ cls = ENCRYPTED_SWITCHBOT_MODEL_TO_CLASS[model]
try:
- key_details = await SwitchbotLock.async_retrieve_encryption_key(
+ key_details = await cls.async_retrieve_encryption_key(
async_get_clientsession(self.hass),
self._discovered_adv.address,
user_input[CONF_USERNAME],
@@ -198,11 +201,11 @@ class SwitchbotConfigFlow(ConfigFlow, domain=DOMAIN):
errors = {"base": "auth_failed"}
description_placeholders = {"error_detail": str(ex)}
else:
- return await self.async_step_lock_key(key_details)
+ return await self.async_step_encrypted_key(key_details)
user_input = user_input or {}
return self.async_show_form(
- step_id="lock_auth",
+ step_id="encrypted_auth",
errors=errors,
data_schema=vol.Schema(
{
@@ -218,32 +221,34 @@ class SwitchbotConfigFlow(ConfigFlow, domain=DOMAIN):
},
)
- async def async_step_lock_choose_method(
+ async def async_step_encrypted_choose_method(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the SwitchBot API chose method step."""
assert self._discovered_adv is not None
return self.async_show_menu(
- step_id="lock_choose_method",
- menu_options=["lock_auth", "lock_key"],
+ step_id="encrypted_choose_method",
+ menu_options=["encrypted_auth", "encrypted_key"],
description_placeholders={
"name": name_from_discovery(self._discovered_adv),
},
)
- async def async_step_lock_key(
+ async def async_step_encrypted_key(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the encryption key step."""
errors = {}
assert self._discovered_adv is not None
if user_input is not None:
- if not await SwitchbotLock.verify_encryption_key(
+ model: SwitchbotModel = self._discovered_adv.data["modelName"]
+ cls = ENCRYPTED_SWITCHBOT_MODEL_TO_CLASS[model]
+ if not await cls.verify_encryption_key(
self._discovered_adv.device,
user_input[CONF_KEY_ID],
user_input[CONF_ENCRYPTION_KEY],
- model=self._discovered_adv.data["modelName"],
+ model=model,
):
errors = {
"base": "encryption_key_invalid",
@@ -252,7 +257,7 @@ class SwitchbotConfigFlow(ConfigFlow, domain=DOMAIN):
return await self._async_create_entry_from_discovery(user_input)
return self.async_show_form(
- step_id="lock_key",
+ step_id="encrypted_key",
errors=errors,
data_schema=vol.Schema(
{
@@ -309,8 +314,8 @@ class SwitchbotConfigFlow(ConfigFlow, domain=DOMAIN):
if user_input is not None:
device_adv = self._discovered_advs[user_input[CONF_ADDRESS]]
await self._async_set_device(device_adv)
- if device_adv.data.get("modelName") in SUPPORTED_LOCK_MODELS:
- return await self.async_step_lock_choose_method()
+ if device_adv.data.get("modelName") in ENCRYPTED_MODELS:
+ return await self.async_step_encrypted_choose_method()
if device_adv.data["isEncrypted"]:
return await self.async_step_password()
return await self._async_create_entry_from_discovery(user_input)
@@ -321,8 +326,8 @@ class SwitchbotConfigFlow(ConfigFlow, domain=DOMAIN):
# or simply confirm it
device_adv = list(self._discovered_advs.values())[0]
await self._async_set_device(device_adv)
- if device_adv.data.get("modelName") in SUPPORTED_LOCK_MODELS:
- return await self.async_step_lock_choose_method()
+ if device_adv.data.get("modelName") in ENCRYPTED_MODELS:
+ return await self.async_step_encrypted_choose_method()
if device_adv.data["isEncrypted"]:
return await self.async_step_password()
return await self.async_step_confirm()
diff --git a/homeassistant/components/switchbot/const.py b/homeassistant/components/switchbot/const.py
index 19b264bd46f..854ab32b657 100644
--- a/homeassistant/components/switchbot/const.py
+++ b/homeassistant/components/switchbot/const.py
@@ -2,6 +2,7 @@
from enum import StrEnum
+import switchbot
from switchbot import SwitchbotModel
DOMAIN = "switchbot"
@@ -30,6 +31,9 @@ class SupportedModels(StrEnum):
LOCK_PRO = "lock_pro"
BLIND_TILT = "blind_tilt"
HUB2 = "hub2"
+ RELAY_SWITCH_1PM = "relay_switch_1pm"
+ RELAY_SWITCH_1 = "relay_switch_1"
+ LEAK = "leak"
CONNECTABLE_SUPPORTED_MODEL_TYPES = {
@@ -44,6 +48,8 @@ CONNECTABLE_SUPPORTED_MODEL_TYPES = {
SwitchbotModel.LOCK_PRO: SupportedModels.LOCK_PRO,
SwitchbotModel.BLIND_TILT: SupportedModels.BLIND_TILT,
SwitchbotModel.HUB2: SupportedModels.HUB2,
+ SwitchbotModel.RELAY_SWITCH_1PM: SupportedModels.RELAY_SWITCH_1PM,
+ SwitchbotModel.RELAY_SWITCH_1: SupportedModels.RELAY_SWITCH_1,
}
NON_CONNECTABLE_SUPPORTED_MODEL_TYPES = {
@@ -53,13 +59,28 @@ NON_CONNECTABLE_SUPPORTED_MODEL_TYPES = {
SwitchbotModel.METER_PRO_C: SupportedModels.HYGROMETER_CO2,
SwitchbotModel.CONTACT_SENSOR: SupportedModels.CONTACT,
SwitchbotModel.MOTION_SENSOR: SupportedModels.MOTION,
+ SwitchbotModel.LEAK: SupportedModels.LEAK,
}
SUPPORTED_MODEL_TYPES = (
CONNECTABLE_SUPPORTED_MODEL_TYPES | NON_CONNECTABLE_SUPPORTED_MODEL_TYPES
)
-SUPPORTED_LOCK_MODELS = {SwitchbotModel.LOCK, SwitchbotModel.LOCK_PRO}
+ENCRYPTED_MODELS = {
+ SwitchbotModel.RELAY_SWITCH_1,
+ SwitchbotModel.RELAY_SWITCH_1PM,
+ SwitchbotModel.LOCK,
+ SwitchbotModel.LOCK_PRO,
+}
+
+ENCRYPTED_SWITCHBOT_MODEL_TO_CLASS: dict[
+ SwitchbotModel, switchbot.SwitchbotEncryptedDevice
+] = {
+ SwitchbotModel.LOCK: switchbot.SwitchbotLock,
+ SwitchbotModel.LOCK_PRO: switchbot.SwitchbotLock,
+ SwitchbotModel.RELAY_SWITCH_1PM: switchbot.SwitchbotRelaySwitch,
+ SwitchbotModel.RELAY_SWITCH_1: switchbot.SwitchbotRelaySwitch,
+}
HASS_SENSOR_TYPE_TO_SWITCHBOT_MODEL = {
str(v): k for k, v in SUPPORTED_MODEL_TYPES.items()
@@ -74,8 +95,3 @@ CONF_RETRY_COUNT = "retry_count"
CONF_KEY_ID = "key_id"
CONF_ENCRYPTION_KEY = "encryption_key"
CONF_LOCK_NIGHTLATCH = "lock_force_nightlatch"
-
-# Deprecated config Entry Options to be removed in 2023.4
-CONF_TIME_BETWEEN_UPDATE_COMMAND = "update_time"
-CONF_RETRY_TIMEOUT = "retry_timeout"
-CONF_SCAN_TIMEOUT = "scan_timeout"
diff --git a/homeassistant/components/switchbot/light.py b/homeassistant/components/switchbot/light.py
index 836ba1bd4f3..927ad5120c7 100644
--- a/homeassistant/components/switchbot/light.py
+++ b/homeassistant/components/switchbot/light.py
@@ -8,17 +8,13 @@ from switchbot import ColorMode as SwitchBotColorMode, SwitchbotBaseLight
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
- ATTR_COLOR_TEMP,
+ ATTR_COLOR_TEMP_KELVIN,
ATTR_RGB_COLOR,
ColorMode,
LightEntity,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from homeassistant.util.color import (
- color_temperature_kelvin_to_mired,
- color_temperature_mired_to_kelvin,
-)
from .coordinator import SwitchbotConfigEntry, SwitchbotDataUpdateCoordinator
from .entity import SwitchbotEntity
@@ -50,8 +46,8 @@ class SwitchbotLightEntity(SwitchbotEntity, LightEntity):
"""Initialize the Switchbot light."""
super().__init__(coordinator)
device = self._device
- self._attr_min_mireds = color_temperature_kelvin_to_mired(device.max_temp)
- self._attr_max_mireds = color_temperature_kelvin_to_mired(device.min_temp)
+ self._attr_max_color_temp_kelvin = device.max_temp
+ self._attr_min_color_temp_kelvin = device.min_temp
self._attr_supported_color_modes = {
SWITCHBOT_COLOR_MODE_TO_HASS[mode] for mode in device.color_modes
}
@@ -64,7 +60,7 @@ class SwitchbotLightEntity(SwitchbotEntity, LightEntity):
self._attr_is_on = self._device.on
self._attr_brightness = max(0, min(255, round(device.brightness * 2.55)))
if device.color_mode == SwitchBotColorMode.COLOR_TEMP:
- self._attr_color_temp = color_temperature_kelvin_to_mired(device.color_temp)
+ self._attr_color_temp_kelvin = device.color_temp
self._attr_color_mode = ColorMode.COLOR_TEMP
return
self._attr_rgb_color = device.rgb
@@ -77,10 +73,9 @@ class SwitchbotLightEntity(SwitchbotEntity, LightEntity):
if (
self.supported_color_modes
and ColorMode.COLOR_TEMP in self.supported_color_modes
- and ATTR_COLOR_TEMP in kwargs
+ and ATTR_COLOR_TEMP_KELVIN in kwargs
):
- color_temp = kwargs[ATTR_COLOR_TEMP]
- kelvin = max(2700, min(6500, color_temperature_mired_to_kelvin(color_temp)))
+ kelvin = max(2700, min(6500, kwargs[ATTR_COLOR_TEMP_KELVIN]))
await self._device.set_color_temp(brightness, kelvin)
return
if ATTR_RGB_COLOR in kwargs:
diff --git a/homeassistant/components/switchbot/manifest.json b/homeassistant/components/switchbot/manifest.json
index 0e369f8ad2d..1b80da43e16 100644
--- a/homeassistant/components/switchbot/manifest.json
+++ b/homeassistant/components/switchbot/manifest.json
@@ -39,5 +39,5 @@
"documentation": "https://www.home-assistant.io/integrations/switchbot",
"iot_class": "local_push",
"loggers": ["switchbot"],
- "requirements": ["PySwitchbot==0.51.0"]
+ "requirements": ["PySwitchbot==0.55.4"]
}
diff --git a/homeassistant/components/switchbot/sensor.py b/homeassistant/components/switchbot/sensor.py
index fd3de3e31e9..9787521a5e9 100644
--- a/homeassistant/components/switchbot/sensor.py
+++ b/homeassistant/components/switchbot/sensor.py
@@ -14,6 +14,8 @@ from homeassistant.const import (
PERCENTAGE,
SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
EntityCategory,
+ UnitOfElectricCurrent,
+ UnitOfElectricPotential,
UnitOfPower,
UnitOfTemperature,
)
@@ -82,6 +84,18 @@ SENSOR_TYPES: dict[str, SensorEntityDescription] = {
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.POWER,
),
+ "current": SensorEntityDescription(
+ key="current",
+ native_unit_of_measurement=UnitOfElectricCurrent.MILLIAMPERE,
+ state_class=SensorStateClass.MEASUREMENT,
+ device_class=SensorDeviceClass.CURRENT,
+ ),
+ "voltage": SensorEntityDescription(
+ key="voltage",
+ native_unit_of_measurement=UnitOfElectricPotential.VOLT,
+ state_class=SensorStateClass.MEASUREMENT,
+ device_class=SensorDeviceClass.VOLTAGE,
+ ),
}
diff --git a/homeassistant/components/switchbot/strings.json b/homeassistant/components/switchbot/strings.json
index 80ca32d4826..2a5ddaa0cba 100644
--- a/homeassistant/components/switchbot/strings.json
+++ b/homeassistant/components/switchbot/strings.json
@@ -16,25 +16,25 @@
"password": "[%key:common::config_flow::data::password%]"
}
},
- "lock_key": {
+ "encrypted_key": {
"description": "The {name} device requires encryption key, details on how to obtain it can be found in the documentation.",
"data": {
"key_id": "Key ID",
"encryption_key": "Encryption key"
}
},
- "lock_auth": {
- "description": "Please provide your SwitchBot app username and password. This data won't be saved and only used to retrieve your locks encryption key. Usernames and passwords are case sensitive.",
+ "encrypted_auth": {
+ "description": "Please provide your SwitchBot app username and password. This data won't be saved and only used to retrieve your device's encryption key. Usernames and passwords are case sensitive.",
"data": {
"username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]"
}
},
- "lock_choose_method": {
- "description": "A SwitchBot lock can be set up in Home Assistant in two different ways.\n\nYou can enter the key id and encryption key yourself, or Home Assistant can import them from your SwitchBot account.",
+ "encrypted_choose_method": {
+ "description": "An encrypted SwitchBot device can be set up in Home Assistant in two different ways.\n\nYou can enter the key id and encryption key yourself, or Home Assistant can import them from your SwitchBot account.",
"menu_options": {
- "lock_auth": "SwitchBot account (recommended)",
- "lock_key": "Enter lock encryption key manually"
+ "encrypted_auth": "SwitchBot account (recommended)",
+ "encrypted_key": "Enter encryption key manually"
}
}
},
diff --git a/homeassistant/components/switchbot_cloud/__init__.py b/homeassistant/components/switchbot_cloud/__init__.py
index a2738ed446f..827dce550ef 100644
--- a/homeassistant/components/switchbot_cloud/__init__.py
+++ b/homeassistant/components/switchbot_cloud/__init__.py
@@ -75,9 +75,11 @@ def make_device_data(
)
if (
isinstance(device, Device)
- and device.device_type.startswith("Plug")
- or isinstance(device, Remote)
- ):
+ 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)
)
@@ -85,6 +87,10 @@ def make_device_data(
"Meter",
"MeterPlus",
"WoIOSensor",
+ "Hub 2",
+ "MeterPro",
+ "MeterPro(CO2)",
+ "Relay Switch 1PM",
]:
devices_data.sensors.append(
prepare_device(hass, api, device, coordinators_by_id)
diff --git a/homeassistant/components/switchbot_cloud/climate.py b/homeassistant/components/switchbot_cloud/climate.py
index cd60313f37a..4e05e9e9a1e 100644
--- a/homeassistant/components/switchbot_cloud/climate.py
+++ b/homeassistant/components/switchbot_cloud/climate.py
@@ -79,8 +79,9 @@ class SwitchBotCloudAirConditioner(SwitchBotCloudEntity, ClimateEntity):
_attr_hvac_mode = HVACMode.FAN_ONLY
_attr_temperature_unit = UnitOfTemperature.CELSIUS
_attr_target_temperature = 21
+ _attr_target_temperature_step = 1
+ _attr_precision = 1
_attr_name = None
- _enable_turn_on_off_backwards_compatibility = False
async def _do_send_command(
self,
@@ -97,7 +98,7 @@ class SwitchBotCloudAirConditioner(SwitchBotCloudEntity, ClimateEntity):
)
await self.send_api_command(
AirConditionerCommands.SET_ALL,
- parameters=f"{new_temperature},{new_mode},{new_fan_speed},on",
+ parameters=f"{int(new_temperature)},{new_mode},{new_fan_speed},on",
)
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
diff --git a/homeassistant/components/switchbot_cloud/sensor.py b/homeassistant/components/switchbot_cloud/sensor.py
index ac612aea119..ae912e914ba 100644
--- a/homeassistant/components/switchbot_cloud/sensor.py
+++ b/homeassistant/components/switchbot_cloud/sensor.py
@@ -9,7 +9,14 @@ from homeassistant.components.sensor import (
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import PERCENTAGE, UnitOfTemperature
+from homeassistant.const import (
+ CONCENTRATION_PARTS_PER_MILLION,
+ PERCENTAGE,
+ UnitOfElectricCurrent,
+ UnitOfElectricPotential,
+ UnitOfPower,
+ UnitOfTemperature,
+)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
@@ -21,28 +28,98 @@ from .entity import SwitchBotCloudEntity
SENSOR_TYPE_TEMPERATURE = "temperature"
SENSOR_TYPE_HUMIDITY = "humidity"
SENSOR_TYPE_BATTERY = "battery"
+SENSOR_TYPE_CO2 = "CO2"
+SENSOR_TYPE_POWER = "power"
+SENSOR_TYPE_VOLTAGE = "voltage"
+SENSOR_TYPE_CURRENT = "electricCurrent"
-METER_PLUS_SENSOR_DESCRIPTIONS = (
- SensorEntityDescription(
- key=SENSOR_TYPE_TEMPERATURE,
- device_class=SensorDeviceClass.TEMPERATURE,
- state_class=SensorStateClass.MEASUREMENT,
- native_unit_of_measurement=UnitOfTemperature.CELSIUS,
- ),
- SensorEntityDescription(
- key=SENSOR_TYPE_HUMIDITY,
- device_class=SensorDeviceClass.HUMIDITY,
- state_class=SensorStateClass.MEASUREMENT,
- native_unit_of_measurement=PERCENTAGE,
- ),
- SensorEntityDescription(
- key=SENSOR_TYPE_BATTERY,
- device_class=SensorDeviceClass.BATTERY,
- state_class=SensorStateClass.MEASUREMENT,
- native_unit_of_measurement=PERCENTAGE,
- ),
+TEMPERATURE_DESCRIPTION = SensorEntityDescription(
+ key=SENSOR_TYPE_TEMPERATURE,
+ device_class=SensorDeviceClass.TEMPERATURE,
+ state_class=SensorStateClass.MEASUREMENT,
+ native_unit_of_measurement=UnitOfTemperature.CELSIUS,
)
+HUMIDITY_DESCRIPTION = SensorEntityDescription(
+ key=SENSOR_TYPE_HUMIDITY,
+ device_class=SensorDeviceClass.HUMIDITY,
+ state_class=SensorStateClass.MEASUREMENT,
+ native_unit_of_measurement=PERCENTAGE,
+)
+
+BATTERY_DESCRIPTION = SensorEntityDescription(
+ key=SENSOR_TYPE_BATTERY,
+ device_class=SensorDeviceClass.BATTERY,
+ state_class=SensorStateClass.MEASUREMENT,
+ native_unit_of_measurement=PERCENTAGE,
+)
+
+POWER_DESCRIPTION = SensorEntityDescription(
+ key=SENSOR_TYPE_POWER,
+ device_class=SensorDeviceClass.POWER,
+ state_class=SensorStateClass.MEASUREMENT,
+ native_unit_of_measurement=UnitOfPower.WATT,
+)
+
+VOLATGE_DESCRIPTION = SensorEntityDescription(
+ key=SENSOR_TYPE_VOLTAGE,
+ device_class=SensorDeviceClass.VOLTAGE,
+ state_class=SensorStateClass.MEASUREMENT,
+ native_unit_of_measurement=UnitOfElectricPotential.VOLT,
+)
+
+CURRENT_DESCRIPTION = SensorEntityDescription(
+ key=SENSOR_TYPE_CURRENT,
+ device_class=SensorDeviceClass.CURRENT,
+ state_class=SensorStateClass.MEASUREMENT,
+ native_unit_of_measurement=UnitOfElectricCurrent.MILLIAMPERE,
+)
+
+CO2_DESCRIPTION = SensorEntityDescription(
+ key=SENSOR_TYPE_CO2,
+ device_class=SensorDeviceClass.CO2,
+ state_class=SensorStateClass.MEASUREMENT,
+ native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION,
+)
+
+SENSOR_DESCRIPTIONS_BY_DEVICE_TYPES = {
+ "Meter": (
+ TEMPERATURE_DESCRIPTION,
+ HUMIDITY_DESCRIPTION,
+ BATTERY_DESCRIPTION,
+ ),
+ "MeterPlus": (
+ TEMPERATURE_DESCRIPTION,
+ HUMIDITY_DESCRIPTION,
+ BATTERY_DESCRIPTION,
+ ),
+ "WoIOSensor": (
+ TEMPERATURE_DESCRIPTION,
+ HUMIDITY_DESCRIPTION,
+ BATTERY_DESCRIPTION,
+ ),
+ "Relay Switch 1PM": (
+ POWER_DESCRIPTION,
+ VOLATGE_DESCRIPTION,
+ CURRENT_DESCRIPTION,
+ ),
+ "Hub 2": (
+ TEMPERATURE_DESCRIPTION,
+ HUMIDITY_DESCRIPTION,
+ ),
+ "MeterPro": (
+ TEMPERATURE_DESCRIPTION,
+ HUMIDITY_DESCRIPTION,
+ BATTERY_DESCRIPTION,
+ ),
+ "MeterPro(CO2)": (
+ TEMPERATURE_DESCRIPTION,
+ HUMIDITY_DESCRIPTION,
+ BATTERY_DESCRIPTION,
+ CO2_DESCRIPTION,
+ ),
+}
+
async def async_setup_entry(
hass: HomeAssistant,
@@ -55,7 +132,7 @@ async def async_setup_entry(
async_add_entities(
SwitchBotCloudSensor(data.api, device, coordinator, description)
for device, coordinator in data.devices.sensors
- for description in METER_PLUS_SENSOR_DESCRIPTIONS
+ for description in SENSOR_DESCRIPTIONS_BY_DEVICE_TYPES[device.device_type]
)
diff --git a/homeassistant/components/switchbot_cloud/switch.py b/homeassistant/components/switchbot_cloud/switch.py
index c30e60086fa..281ebb9322e 100644
--- a/homeassistant/components/switchbot_cloud/switch.py
+++ b/homeassistant/components/switchbot_cloud/switch.py
@@ -69,6 +69,18 @@ class SwitchBotCloudPlugSwitch(SwitchBotCloudSwitch):
_attr_device_class = SwitchDeviceClass.OUTLET
+class SwitchBotCloudRelaySwitchSwitch(SwitchBotCloudSwitch):
+ """Representation of a SwitchBot relay switch."""
+
+ @callback
+ def _handle_coordinator_update(self) -> None:
+ """Handle updated data from the coordinator."""
+ if not self.coordinator.data:
+ return
+ self._attr_is_on = self.coordinator.data.get("switchStatus") == 1
+ self.async_write_ha_state()
+
+
@callback
def _async_make_entity(
api: SwitchBotAPI, device: Device | Remote, coordinator: SwitchBotCoordinator
@@ -78,4 +90,9 @@ def _async_make_entity(
return SwitchBotCloudRemoteSwitch(api, device, coordinator)
if "Plug" in device.device_type:
return SwitchBotCloudPlugSwitch(api, device, coordinator)
+ if device.device_type in [
+ "Relay Switch 1PM",
+ "Relay Switch 1",
+ ]:
+ return SwitchBotCloudRelaySwitchSwitch(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 f9236507037..2d2a1783d73 100644
--- a/homeassistant/components/switchbot_cloud/vacuum.py
+++ b/homeassistant/components/switchbot_cloud/vacuum.py
@@ -5,13 +5,8 @@ from typing import Any
from switchbot_api import Device, Remote, SwitchBotAPI, VacuumCommands
from homeassistant.components.vacuum import (
- STATE_CLEANING,
- STATE_DOCKED,
- STATE_ERROR,
- STATE_IDLE,
- STATE_PAUSED,
- STATE_RETURNING,
StateVacuumEntity,
+ VacuumActivity,
VacuumEntityFeature,
)
from homeassistant.config_entries import ConfigEntry
@@ -43,17 +38,17 @@ async def async_setup_entry(
)
-VACUUM_SWITCHBOT_STATE_TO_HA_STATE: dict[str, str] = {
- "StandBy": STATE_IDLE,
- "Clearing": STATE_CLEANING,
- "Paused": STATE_PAUSED,
- "GotoChargeBase": STATE_RETURNING,
- "Charging": STATE_DOCKED,
- "ChargeDone": STATE_DOCKED,
- "Dormant": STATE_IDLE,
- "InTrouble": STATE_ERROR,
- "InRemoteControl": STATE_CLEANING,
- "InDustCollecting": STATE_DOCKED,
+VACUUM_SWITCHBOT_STATE_TO_HA_STATE: dict[str, VacuumActivity] = {
+ "StandBy": VacuumActivity.IDLE,
+ "Clearing": VacuumActivity.CLEANING,
+ "Paused": VacuumActivity.PAUSED,
+ "GotoChargeBase": VacuumActivity.RETURNING,
+ "Charging": VacuumActivity.DOCKED,
+ "ChargeDone": VacuumActivity.DOCKED,
+ "Dormant": VacuumActivity.IDLE,
+ "InTrouble": VacuumActivity.ERROR,
+ "InRemoteControl": VacuumActivity.CLEANING,
+ "InDustCollecting": VacuumActivity.DOCKED,
}
VACUUM_FAN_SPEED_TO_SWITCHBOT_FAN_SPEED: dict[str, str] = {
@@ -114,7 +109,7 @@ class SwitchBotCloudVacuum(SwitchBotCloudEntity, StateVacuumEntity):
self._attr_available = self.coordinator.data.get("onlineStatus") == "online"
switchbot_state = str(self.coordinator.data.get("workingStatus"))
- self._attr_state = VACUUM_SWITCHBOT_STATE_TO_HA_STATE.get(switchbot_state)
+ self._attr_activity = VACUUM_SWITCHBOT_STATE_TO_HA_STATE.get(switchbot_state)
self.async_write_ha_state()
diff --git a/homeassistant/components/switcher_kis/button.py b/homeassistant/components/switcher_kis/button.py
index 5564fac830d..d2686e2e550 100644
--- a/homeassistant/components/switcher_kis/button.py
+++ b/homeassistant/components/switcher_kis/button.py
@@ -10,7 +10,6 @@ from aioswitcher.api import (
DeviceState,
SwitcherApi,
SwitcherBaseResponse,
- SwitcherType2Api,
ThermostatSwing,
)
from aioswitcher.api.remotes import SwitcherBreezeRemote
@@ -128,7 +127,7 @@ class SwitcherThermostatButtonEntity(SwitcherEntity, ButtonEntity):
error = None
try:
- async with SwitcherType2Api(
+ async with SwitcherApi(
self.coordinator.data.device_type,
self.coordinator.data.ip_address,
self.coordinator.data.device_id,
diff --git a/homeassistant/components/switcher_kis/climate.py b/homeassistant/components/switcher_kis/climate.py
index eeff603bc8a..2fc4a331676 100644
--- a/homeassistant/components/switcher_kis/climate.py
+++ b/homeassistant/components/switcher_kis/climate.py
@@ -4,7 +4,6 @@ from __future__ import annotations
from typing import Any, cast
-from aioswitcher.api import SwitcherBaseResponse, SwitcherType2Api
from aioswitcher.api.remotes import SwitcherBreezeRemote
from aioswitcher.device import (
DeviceCategory,
@@ -38,6 +37,8 @@ from .coordinator import SwitcherDataUpdateCoordinator
from .entity import SwitcherEntity
from .utils import get_breeze_remote_manager
+API_CONTROL_BREEZE_DEVICE = "control_breeze_device"
+
DEVICE_MODE_TO_HA = {
ThermostatMode.COOL: HVACMode.COOL,
ThermostatMode.HEAT: HVACMode.HEAT,
@@ -83,7 +84,6 @@ class SwitcherClimateEntity(SwitcherEntity, ClimateEntity):
"""Representation of a Switcher climate entity."""
_attr_name = None
- _enable_turn_on_off_backwards_compatibility = False
def __init__(
self, coordinator: SwitcherDataUpdateCoordinator, remote: SwitcherBreezeRemote
@@ -156,27 +156,7 @@ class SwitcherClimateEntity(SwitcherEntity, ClimateEntity):
async def _async_control_breeze_device(self, **kwargs: Any) -> None:
"""Call Switcher Control Breeze API."""
- response: SwitcherBaseResponse | None = None
- error = None
-
- try:
- async with SwitcherType2Api(
- self.coordinator.data.device_type,
- self.coordinator.data.ip_address,
- self.coordinator.data.device_id,
- self.coordinator.data.device_key,
- ) as swapi:
- response = await swapi.control_breeze_device(self._remote, **kwargs)
- except (TimeoutError, OSError, RuntimeError) as err:
- error = repr(err)
-
- if error or not response or not response.successful:
- self.coordinator.last_update_success = False
- self.async_write_ha_state()
- raise HomeAssistantError(
- f"Call Breeze control for {self.name} failed, "
- f"response/error: {response or error}"
- )
+ await self._async_call_api(API_CONTROL_BREEZE_DEVICE, self._remote, **kwargs)
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperature."""
diff --git a/homeassistant/components/switcher_kis/cover.py b/homeassistant/components/switcher_kis/cover.py
index dc3b6d96aed..513b786a033 100644
--- a/homeassistant/components/switcher_kis/cover.py
+++ b/homeassistant/components/switcher_kis/cover.py
@@ -2,10 +2,8 @@
from __future__ import annotations
-import logging
from typing import Any, cast
-from aioswitcher.api import SwitcherBaseResponse, SwitcherType2Api
from aioswitcher.device import DeviceCategory, ShutterDirection, SwitcherShutter
from homeassistant.components.cover import (
@@ -16,7 +14,6 @@ from homeassistant.components.cover import (
)
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
@@ -24,8 +21,6 @@ from .const import SIGNAL_DEVICE_ADD
from .coordinator import SwitcherDataUpdateCoordinator
from .entity import SwitcherEntity
-_LOGGER = logging.getLogger(__name__)
-
API_SET_POSITON = "set_position"
API_STOP = "stop_shutter"
@@ -92,32 +87,6 @@ class SwitcherBaseCoverEntity(SwitcherEntity, CoverEntity):
data.direction[self._cover_id] == ShutterDirection.SHUTTER_UP
)
- async def _async_call_api(self, api: str, *args: Any) -> None:
- """Call Switcher API."""
- _LOGGER.debug("Calling api for %s, api: '%s', args: %s", self.name, api, args)
- response: SwitcherBaseResponse | None = None
- error = None
-
- try:
- async with SwitcherType2Api(
- self.coordinator.data.device_type,
- self.coordinator.data.ip_address,
- self.coordinator.data.device_id,
- self.coordinator.data.device_key,
- self.coordinator.token,
- ) as swapi:
- response = await getattr(swapi, api)(*args)
- except (TimeoutError, OSError, RuntimeError) as err:
- error = repr(err)
-
- if error or not response or not response.successful:
- self.coordinator.last_update_success = False
- self.async_write_ha_state()
- raise HomeAssistantError(
- f"Call api for {self.name} failed, api: '{api}', "
- f"args: {args}, response/error: {response or error}"
- )
-
async def async_close_cover(self, **kwargs: Any) -> None:
"""Close cover."""
await self._async_call_api(API_SET_POSITON, 0, self._cover_id)
diff --git a/homeassistant/components/switcher_kis/entity.py b/homeassistant/components/switcher_kis/entity.py
index 12bde521377..82b892d548d 100644
--- a/homeassistant/components/switcher_kis/entity.py
+++ b/homeassistant/components/switcher_kis/entity.py
@@ -1,11 +1,20 @@
"""Base class for Switcher entities."""
+import logging
+from typing import Any
+
+from aioswitcher.api import SwitcherApi
+from aioswitcher.api.messages import SwitcherBaseResponse
+
+from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .coordinator import SwitcherDataUpdateCoordinator
+_LOGGER = logging.getLogger(__name__)
+
class SwitcherEntity(CoordinatorEntity[SwitcherDataUpdateCoordinator]):
"""Base class for Switcher entities."""
@@ -18,3 +27,29 @@ class SwitcherEntity(CoordinatorEntity[SwitcherDataUpdateCoordinator]):
self._attr_device_info = DeviceInfo(
connections={(dr.CONNECTION_NETWORK_MAC, coordinator.mac_address)}
)
+
+ async def _async_call_api(self, api: str, *args: Any, **kwargs: Any) -> None:
+ """Call Switcher API."""
+ _LOGGER.debug("Calling api for %s, api: '%s', args: %s", self.name, api, args)
+ response: SwitcherBaseResponse | None = None
+ error = None
+
+ try:
+ async with SwitcherApi(
+ self.coordinator.data.device_type,
+ self.coordinator.data.ip_address,
+ self.coordinator.data.device_id,
+ self.coordinator.data.device_key,
+ self.coordinator.token,
+ ) as swapi:
+ response = await getattr(swapi, api)(*args, **kwargs)
+ except (TimeoutError, OSError, RuntimeError) as err:
+ error = repr(err)
+
+ if error or not response or not response.successful:
+ self.coordinator.last_update_success = False
+ self.async_write_ha_state()
+ raise HomeAssistantError(
+ f"Call api for {self.name} failed, api: '{api}', "
+ f"args: {args}, response/error: {response or error}"
+ )
diff --git a/homeassistant/components/switcher_kis/icons.json b/homeassistant/components/switcher_kis/icons.json
index 6ca8e0e8351..bd770d3e656 100644
--- a/homeassistant/components/switcher_kis/icons.json
+++ b/homeassistant/components/switcher_kis/icons.json
@@ -20,6 +20,9 @@
},
"auto_shutdown": {
"default": "mdi:progress-clock"
+ },
+ "temperature": {
+ "default": "mdi:thermometer"
}
}
},
diff --git a/homeassistant/components/switcher_kis/light.py b/homeassistant/components/switcher_kis/light.py
index bd87176bcf0..75156044efa 100644
--- a/homeassistant/components/switcher_kis/light.py
+++ b/homeassistant/components/switcher_kis/light.py
@@ -2,16 +2,13 @@
from __future__ import annotations
-import logging
from typing import Any, cast
-from aioswitcher.api import SwitcherBaseResponse, SwitcherType2Api
from aioswitcher.device import DeviceCategory, DeviceState, SwitcherLight
from homeassistant.components.light import ColorMode, LightEntity
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
@@ -19,8 +16,6 @@ from .const import SIGNAL_DEVICE_ADD
from .coordinator import SwitcherDataUpdateCoordinator
from .entity import SwitcherEntity
-_LOGGER = logging.getLogger(__name__)
-
API_SET_LIGHT = "set_light"
@@ -79,32 +74,6 @@ class SwitcherBaseLightEntity(SwitcherEntity, LightEntity):
data = cast(SwitcherLight, self.coordinator.data)
return bool(data.light[self._light_id] == DeviceState.ON)
- async def _async_call_api(self, api: str, *args: Any) -> None:
- """Call Switcher API."""
- _LOGGER.debug("Calling api for %s, api: '%s', args: %s", self.name, api, args)
- response: SwitcherBaseResponse | None = None
- error = None
-
- try:
- async with SwitcherType2Api(
- self.coordinator.data.device_type,
- self.coordinator.data.ip_address,
- self.coordinator.data.device_id,
- self.coordinator.data.device_key,
- self.coordinator.token,
- ) as swapi:
- response = await getattr(swapi, api)(*args)
- except (TimeoutError, OSError, RuntimeError) as err:
- error = repr(err)
-
- if error or not response or not response.successful:
- self.coordinator.last_update_success = False
- self.async_write_ha_state()
- raise HomeAssistantError(
- f"Call api for {self.name} failed, api: '{api}', "
- f"args: {args}, response/error: {response or error}"
- )
-
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the light on."""
await self._async_call_api(API_SET_LIGHT, DeviceState.ON, self._light_id)
diff --git a/homeassistant/components/switcher_kis/manifest.json b/homeassistant/components/switcher_kis/manifest.json
index 4a50d992d6d..f96b10b4b6f 100644
--- a/homeassistant/components/switcher_kis/manifest.json
+++ b/homeassistant/components/switcher_kis/manifest.json
@@ -6,7 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/switcher_kis",
"iot_class": "local_push",
"loggers": ["aioswitcher"],
- "quality_scale": "platinum",
- "requirements": ["aioswitcher==4.4.0"],
+ "requirements": ["aioswitcher==6.0.0"],
"single_config_entry": true
}
diff --git a/homeassistant/components/switcher_kis/sensor.py b/homeassistant/components/switcher_kis/sensor.py
index 9ff3d6dfaae..0ed60e5a721 100644
--- a/homeassistant/components/switcher_kis/sensor.py
+++ b/homeassistant/components/switcher_kis/sensor.py
@@ -46,9 +46,16 @@ TIME_SENSORS: list[SensorEntityDescription] = [
entity_registry_enabled_default=False,
),
]
+TEMPERATURE_SENSORS: list[SensorEntityDescription] = [
+ SensorEntityDescription(
+ key="temperature",
+ translation_key="temperature",
+ ),
+]
POWER_PLUG_SENSORS = POWER_SENSORS
WATER_HEATER_SENSORS = [*POWER_SENSORS, *TIME_SENSORS]
+THERMOSTAT_SENSORS = TEMPERATURE_SENSORS
async def async_setup_entry(
@@ -71,6 +78,11 @@ async def async_setup_entry(
SwitcherSensorEntity(coordinator, description)
for description in WATER_HEATER_SENSORS
)
+ elif coordinator.data.device_type.category == DeviceCategory.THERMOSTAT:
+ async_add_entities(
+ SwitcherSensorEntity(coordinator, description)
+ for description in THERMOSTAT_SENSORS
+ )
config_entry.async_on_unload(
async_dispatcher_connect(hass, SIGNAL_DEVICE_ADD, async_add_sensors)
diff --git a/homeassistant/components/switcher_kis/strings.json b/homeassistant/components/switcher_kis/strings.json
index 798a43c981c..844cbb4ca98 100644
--- a/homeassistant/components/switcher_kis/strings.json
+++ b/homeassistant/components/switcher_kis/strings.json
@@ -59,6 +59,9 @@
},
"auto_shutdown": {
"name": "Auto shutdown"
+ },
+ "temperature": {
+ "name": "Current temperature"
}
}
},
diff --git a/homeassistant/components/switcher_kis/switch.py b/homeassistant/components/switcher_kis/switch.py
index 6a679680263..ba0a99b4089 100644
--- a/homeassistant/components/switcher_kis/switch.py
+++ b/homeassistant/components/switcher_kis/switch.py
@@ -6,7 +6,7 @@ from datetime import timedelta
import logging
from typing import Any
-from aioswitcher.api import Command, SwitcherBaseResponse, SwitcherType1Api
+from aioswitcher.api import Command
from aioswitcher.device import DeviceCategory, DeviceState
import voluptuous as vol
@@ -96,35 +96,6 @@ class SwitcherBaseSwitchEntity(SwitcherEntity, SwitchEntity):
self.control_result = None
self.async_write_ha_state()
- async def _async_call_api(self, api: str, *args: Any) -> None:
- """Call Switcher API."""
- _LOGGER.debug(
- "Calling api for %s, api: '%s', args: %s", self.coordinator.name, api, args
- )
- response: SwitcherBaseResponse | None = None
- error = None
-
- try:
- async with SwitcherType1Api(
- self.coordinator.data.device_type,
- self.coordinator.data.ip_address,
- self.coordinator.data.device_id,
- self.coordinator.data.device_key,
- ) as swapi:
- response = await getattr(swapi, api)(*args)
- except (TimeoutError, OSError, RuntimeError) as err:
- error = repr(err)
-
- if error or not response or not response.successful:
- _LOGGER.error(
- "Call api for %s failed, api: '%s', args: %s, response/error: %s",
- self.coordinator.name,
- api,
- args,
- response or error,
- )
- self.coordinator.last_update_success = False
-
@property
def is_on(self) -> bool:
"""Return True if entity is on."""
diff --git a/homeassistant/components/switchmate/manifest.json b/homeassistant/components/switchmate/manifest.json
index 5467dc512c3..f21819e1bc0 100644
--- a/homeassistant/components/switchmate/manifest.json
+++ b/homeassistant/components/switchmate/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/switchmate",
"iot_class": "local_polling",
"loggers": ["switchmate"],
+ "quality_scale": "legacy",
"requirements": ["PySwitchmate==0.5.1"]
}
diff --git a/homeassistant/components/syncthing/manifest.json b/homeassistant/components/syncthing/manifest.json
index f7fd2b7ece6..612665913d0 100644
--- a/homeassistant/components/syncthing/manifest.json
+++ b/homeassistant/components/syncthing/manifest.json
@@ -6,6 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/syncthing",
"iot_class": "local_polling",
"loggers": ["aiosyncthing"],
- "quality_scale": "silver",
"requirements": ["aiosyncthing==0.5.1"]
}
diff --git a/homeassistant/components/syncthru/manifest.json b/homeassistant/components/syncthru/manifest.json
index a93e02a51c7..461ce9bfd3a 100644
--- a/homeassistant/components/syncthru/manifest.json
+++ b/homeassistant/components/syncthru/manifest.json
@@ -6,7 +6,7 @@
"documentation": "https://www.home-assistant.io/integrations/syncthru",
"iot_class": "local_polling",
"loggers": ["pysyncthru"],
- "requirements": ["PySyncThru==0.7.10", "url-normalize==1.4.3"],
+ "requirements": ["PySyncThru==0.8.0", "url-normalize==1.4.3"],
"ssdp": [
{
"deviceType": "urn:schemas-upnp-org:device:Printer:1",
diff --git a/homeassistant/components/synology_chat/manifest.json b/homeassistant/components/synology_chat/manifest.json
index 3ac663ff91e..c9bd3396097 100644
--- a/homeassistant/components/synology_chat/manifest.json
+++ b/homeassistant/components/synology_chat/manifest.json
@@ -3,5 +3,6 @@
"name": "Synology Chat",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/synology_chat",
- "iot_class": "cloud_push"
+ "iot_class": "cloud_push",
+ "quality_scale": "legacy"
}
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_srm/manifest.json b/homeassistant/components/synology_srm/manifest.json
index 9980f37969e..0d712b6742b 100644
--- a/homeassistant/components/synology_srm/manifest.json
+++ b/homeassistant/components/synology_srm/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/synology_srm",
"iot_class": "local_polling",
"loggers": ["synology_srm"],
+ "quality_scale": "legacy",
"requirements": ["synology-srm==0.2.0"]
}
diff --git a/homeassistant/components/syslog/manifest.json b/homeassistant/components/syslog/manifest.json
index 380628ffa66..bf327baec10 100644
--- a/homeassistant/components/syslog/manifest.json
+++ b/homeassistant/components/syslog/manifest.json
@@ -3,5 +3,6 @@
"name": "Syslog",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/syslog",
- "iot_class": "local_push"
+ "iot_class": "local_push",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/system_bridge/config_flow.py b/homeassistant/components/system_bridge/config_flow.py
index dc1736ea337..98396e52545 100644
--- a/homeassistant/components/system_bridge/config_flow.py
+++ b/homeassistant/components/system_bridge/config_flow.py
@@ -17,7 +17,7 @@ from systembridgemodels.modules import GetData, Module
import voluptuous as vol
from homeassistant.components import zeroconf
-from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
+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
@@ -120,11 +120,11 @@ class SystemBridgeConfigFlow(
VERSION = 1
MINOR_VERSION = 2
+ _name: str
+
def __init__(self) -> None:
"""Initialize flow."""
- self._name: str | None = None
self._input: dict[str, Any] = {}
- self._reauth = False
async def async_step_user(
self, user_input: dict[str, Any] | None = None
@@ -157,15 +157,13 @@ class SystemBridgeConfigFlow(
user_input = {**self._input, **user_input}
errors, info = await _async_get_info(self.hass, user_input)
if not errors and info is not None:
- # Check if already configured
- existing_entry = await self.async_set_unique_id(info["uuid"])
+ await self.async_set_unique_id(info["uuid"])
- if self._reauth and existing_entry:
- self.hass.config_entries.async_update_entry(
- existing_entry, data=user_input
+ if self.source == SOURCE_REAUTH:
+ self._abort_if_unique_id_mismatch()
+ return self.async_update_reload_and_abort(
+ self._get_reauth_entry(), data=user_input
)
- await self.hass.config_entries.async_reload(existing_entry.entry_id)
- return self.async_abort(reason="reauth_successful")
self._abort_if_unique_id_configured(
updates={CONF_HOST: info["hostname"]}
@@ -212,7 +210,6 @@ class SystemBridgeConfigFlow(
CONF_HOST: entry_data[CONF_HOST],
CONF_PORT: entry_data[CONF_PORT],
}
- self._reauth = True
return await self.async_step_authenticate()
diff --git a/homeassistant/components/system_bridge/manifest.json b/homeassistant/components/system_bridge/manifest.json
index e886bcad150..2799cf31fdd 100644
--- a/homeassistant/components/system_bridge/manifest.json
+++ b/homeassistant/components/system_bridge/manifest.json
@@ -9,7 +9,6 @@
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["systembridgeconnector"],
- "quality_scale": "silver",
"requirements": ["systembridgeconnector==4.1.5", "systembridgemodels==4.2.4"],
"zeroconf": ["_system-bridge._tcp.local."]
}
diff --git a/homeassistant/components/system_bridge/strings.json b/homeassistant/components/system_bridge/strings.json
index b5ceba9bd84..ef7495ef74f 100644
--- a/homeassistant/components/system_bridge/strings.json
+++ b/homeassistant/components/system_bridge/strings.json
@@ -3,6 +3,7 @@
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
+ "unique_id_mismatch": "The identifier does not match the previous identifier",
"unsupported_version": "Your version of System Bridge is not supported. Please upgrade to the latest version.",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
diff --git a/homeassistant/components/system_log/strings.json b/homeassistant/components/system_log/strings.json
index ed1ca79fe07..db71cd6ace4 100644
--- a/homeassistant/components/system_log/strings.json
+++ b/homeassistant/components/system_log/strings.json
@@ -1,8 +1,8 @@
{
"services": {
"clear": {
- "name": "Clear all",
- "description": "Clears all log entries."
+ "name": "Clear",
+ "description": "Deletes all log entries."
},
"write": {
"name": "Write",
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 236f25bb1ed..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.0.0"]
+ "requirements": ["psutil-home-assistant==0.0.1", "psutil==6.1.1"],
+ "single_config_entry": true
}
diff --git a/homeassistant/components/systemmonitor/sensor.py b/homeassistant/components/systemmonitor/sensor.py
index ef1153f09e8..048d7cefd6c 100644
--- a/homeassistant/components/systemmonitor/sensor.py
+++ b/homeassistant/components/systemmonitor/sensor.py
@@ -429,16 +429,17 @@ async def async_setup_entry(
is_enabled = check_legacy_resource(
f"{_type}_{argument}", legacy_resources
)
- loaded_resources.add(slugify(f"{_type}_{argument}"))
- entities.append(
- SystemMonitorSensor(
- coordinator,
- sensor_description,
- entry.entry_id,
- argument,
- is_enabled,
+ if (_add := slugify(f"{_type}_{argument}")) not in loaded_resources:
+ loaded_resources.add(_add)
+ entities.append(
+ SystemMonitorSensor(
+ coordinator,
+ sensor_description,
+ entry.entry_id,
+ argument,
+ is_enabled,
+ )
)
- )
continue
if _type.startswith("ipv"):
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/climate.py b/homeassistant/components/tado/climate.py
index 21a09086d46..5a81e951293 100644
--- a/homeassistant/components/tado/climate.py
+++ b/homeassistant/components/tado/climate.py
@@ -269,7 +269,6 @@ class TadoClimate(TadoZoneEntity, ClimateEntity):
_attr_name = None
_attr_translation_key = DOMAIN
_available = False
- _enable_turn_on_off_backwards_compatibility = False
def __init__(
self,
diff --git a/homeassistant/components/tado/manifest.json b/homeassistant/components/tado/manifest.json
index 652d51f0261..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.7"]
+ "requirements": ["python-tado==0.18.5"]
}
diff --git a/homeassistant/components/tag/__init__.py b/homeassistant/components/tag/__init__.py
index 95efae3d386..47c1d14ce60 100644
--- a/homeassistant/components/tag/__init__.py
+++ b/homeassistant/components/tag/__init__.py
@@ -106,7 +106,6 @@ class TagStore(Store[collection.SerializedStorageCollection]):
for tag in data["items"]:
# Copy name in tag store to the entity registry
_create_entry(entity_registry, tag[CONF_ID], tag.get(CONF_NAME))
- tag["migrated"] = True
if old_major_version == 1 and old_minor_version < 3:
# Version 1.3 removes tag_id from the store
for tag in data["items"]:
@@ -178,10 +177,7 @@ class TagStorageCollection(collection.DictStorageCollection):
We don't store the name, it's stored in the entity registry.
"""
- # Preserve the name of migrated entries to allow downgrading to 2024.5
- # without losing tag names. This can be removed in HA Core 2025.1.
- migrated = item_id in self.data and "migrated" in self.data[item_id]
- return {k: v for k, v in item.items() if k != CONF_NAME or migrated}
+ return {k: v for k, v in item.items() if k != CONF_NAME}
class TagDictStorageCollectionWebsocket(
diff --git a/homeassistant/components/tailscale/manifest.json b/homeassistant/components/tailscale/manifest.json
index 24f485fcdbd..7d571fe0675 100644
--- a/homeassistant/components/tailscale/manifest.json
+++ b/homeassistant/components/tailscale/manifest.json
@@ -6,6 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/tailscale",
"integration_type": "hub",
"iot_class": "cloud_polling",
- "quality_scale": "platinum",
"requirements": ["tailscale==0.6.1"]
}
diff --git a/homeassistant/components/tailwind/__init__.py b/homeassistant/components/tailwind/__init__.py
index 6f1a234e94a..b191d78f2a6 100644
--- a/homeassistant/components/tailwind/__init__.py
+++ b/homeassistant/components/tailwind/__init__.py
@@ -2,14 +2,12 @@
from __future__ import annotations
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from .const import DOMAIN
-from .coordinator import TailwindDataUpdateCoordinator
-from .typing import TailwindConfigEntry
+from .coordinator import TailwindConfigEntry, TailwindDataUpdateCoordinator
PLATFORMS = [Platform.BINARY_SENSOR, Platform.BUTTON, Platform.COVER, Platform.NUMBER]
@@ -39,6 +37,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: TailwindConfigEntry) ->
return True
-async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_unload_entry(hass: HomeAssistant, entry: TailwindConfigEntry) -> bool:
"""Unload Tailwind config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
diff --git a/homeassistant/components/tailwind/binary_sensor.py b/homeassistant/components/tailwind/binary_sensor.py
index 0ce0b4bd964..d2f8e1e2ced 100644
--- a/homeassistant/components/tailwind/binary_sensor.py
+++ b/homeassistant/components/tailwind/binary_sensor.py
@@ -16,8 +16,8 @@ from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from .coordinator import TailwindConfigEntry
from .entity import TailwindDoorEntity
-from .typing import TailwindConfigEntry
@dataclass(kw_only=True, frozen=True)
diff --git a/homeassistant/components/tailwind/button.py b/homeassistant/components/tailwind/button.py
index 2a675bbfdf7..edff3434866 100644
--- a/homeassistant/components/tailwind/button.py
+++ b/homeassistant/components/tailwind/button.py
@@ -19,8 +19,8 @@ from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN
+from .coordinator import TailwindConfigEntry
from .entity import TailwindEntity
-from .typing import TailwindConfigEntry
@dataclass(frozen=True, kw_only=True)
diff --git a/homeassistant/components/tailwind/coordinator.py b/homeassistant/components/tailwind/coordinator.py
index 4d1b4af74c9..770751ccc3b 100644
--- a/homeassistant/components/tailwind/coordinator.py
+++ b/homeassistant/components/tailwind/coordinator.py
@@ -18,11 +18,13 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda
from .const import DOMAIN, LOGGER
+type TailwindConfigEntry = ConfigEntry[TailwindDataUpdateCoordinator]
+
class TailwindDataUpdateCoordinator(DataUpdateCoordinator[TailwindDeviceStatus]):
"""Class to manage fetching Tailwind data."""
- def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
+ def __init__(self, hass: HomeAssistant, entry: TailwindConfigEntry) -> None:
"""Initialize the coordinator."""
self.tailwind = Tailwind(
host=entry.data[CONF_HOST],
@@ -32,6 +34,7 @@ class TailwindDataUpdateCoordinator(DataUpdateCoordinator[TailwindDeviceStatus])
super().__init__(
hass,
LOGGER,
+ config_entry=entry,
name=f"{DOMAIN}_{entry.data[CONF_HOST]}",
update_interval=timedelta(seconds=5),
)
diff --git a/homeassistant/components/tailwind/cover.py b/homeassistant/components/tailwind/cover.py
index 116fb4a9e6c..8ea1c7d4f6d 100644
--- a/homeassistant/components/tailwind/cover.py
+++ b/homeassistant/components/tailwind/cover.py
@@ -23,8 +23,8 @@ from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN, LOGGER
+from .coordinator import TailwindConfigEntry
from .entity import TailwindDoorEntity
-from .typing import TailwindConfigEntry
async def async_setup_entry(
diff --git a/homeassistant/components/tailwind/diagnostics.py b/homeassistant/components/tailwind/diagnostics.py
index 5d681356647..b7a51b56775 100644
--- a/homeassistant/components/tailwind/diagnostics.py
+++ b/homeassistant/components/tailwind/diagnostics.py
@@ -6,7 +6,7 @@ from typing import Any
from homeassistant.core import HomeAssistant
-from .typing import TailwindConfigEntry
+from .coordinator import TailwindConfigEntry
async def async_get_config_entry_diagnostics(
diff --git a/homeassistant/components/tailwind/manifest.json b/homeassistant/components/tailwind/manifest.json
index 97d08737a87..7ad43c929a7 100644
--- a/homeassistant/components/tailwind/manifest.json
+++ b/homeassistant/components/tailwind/manifest.json
@@ -11,8 +11,7 @@
"documentation": "https://www.home-assistant.io/integrations/tailwind",
"integration_type": "device",
"iot_class": "local_polling",
- "quality_scale": "platinum",
- "requirements": ["gotailwind==0.2.4"],
+ "requirements": ["gotailwind==0.3.0"],
"zeroconf": [
{
"type": "_http._tcp.local.",
diff --git a/homeassistant/components/tailwind/number.py b/homeassistant/components/tailwind/number.py
index 0ff1f444280..b67df9a6a25 100644
--- a/homeassistant/components/tailwind/number.py
+++ b/homeassistant/components/tailwind/number.py
@@ -15,8 +15,8 @@ from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN
+from .coordinator import TailwindConfigEntry
from .entity import TailwindEntity
-from .typing import TailwindConfigEntry
@dataclass(frozen=True, kw_only=True)
diff --git a/homeassistant/components/tailwind/quality_scale.yaml b/homeassistant/components/tailwind/quality_scale.yaml
new file mode 100644
index 00000000000..90c5d0d5837
--- /dev/null
+++ b/homeassistant/components/tailwind/quality_scale.yaml
@@ -0,0 +1,76 @@
+rules:
+ # Bronze
+ action-setup:
+ status: exempt
+ comment: Integration does not register custom actions.
+ appropriate-polling: done
+ brands: done
+ common-modules: done
+ config-flow-test-coverage: done
+ config-flow: done
+ dependency-transparency: done
+ docs-actions: todo
+ docs-high-level-description: todo
+ docs-installation-instructions: done
+ docs-removal-instructions: todo
+ 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: todo
+ docs-installation-parameters: todo
+ entity-unavailable: done
+ integration-owner: done
+ log-when-unavailable: done
+ parallel-updates: todo
+ reauthentication-flow: done
+ test-coverage: done
+ # Gold
+ devices: done
+ diagnostics: done
+ discovery-update-info: done
+ discovery: done
+ docs-data-update: todo
+ docs-examples: todo
+ docs-known-limitations: todo
+ docs-supported-devices: done
+ docs-supported-functions: todo
+ docs-troubleshooting: todo
+ docs-use-cases: todo
+ dynamic-devices:
+ status: exempt
+ comment: |
+ This integration connects to a single device.
+ entity-category: done
+ entity-device-class: done
+ entity-disabled-by-default: done
+ entity-translations: done
+ exception-translations:
+ status: exempt
+ comment: |
+ The coordinator needs translation when the update failed.
+ icon-translations: done
+ reconfiguration-flow: todo
+ repair-issues:
+ status: exempt
+ comment: |
+ This integration does not raise any repairable issues.
+ stale-devices:
+ status: exempt
+ comment: |
+ This integration connects to a single device.
+
+ # Platinum
+ async-dependency: done
+ inject-websession: done
+ strict-typing: done
diff --git a/homeassistant/components/tailwind/typing.py b/homeassistant/components/tailwind/typing.py
deleted file mode 100644
index 514a94a8e78..00000000000
--- a/homeassistant/components/tailwind/typing.py
+++ /dev/null
@@ -1,7 +0,0 @@
-"""Typings for the Tailwind integration."""
-
-from homeassistant.config_entries import ConfigEntry
-
-from .coordinator import TailwindDataUpdateCoordinator
-
-type TailwindConfigEntry = ConfigEntry[TailwindDataUpdateCoordinator]
diff --git a/homeassistant/components/tank_utility/manifest.json b/homeassistant/components/tank_utility/manifest.json
index d73c62fa5ec..76240252696 100644
--- a/homeassistant/components/tank_utility/manifest.json
+++ b/homeassistant/components/tank_utility/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/tank_utility",
"iot_class": "cloud_polling",
"loggers": ["tank_utility"],
+ "quality_scale": "legacy",
"requirements": ["tank-utility==1.5.0"]
}
diff --git a/homeassistant/components/tankerkoenig/manifest.json b/homeassistant/components/tankerkoenig/manifest.json
index eeb8646bea7..72248d006e0 100644
--- a/homeassistant/components/tankerkoenig/manifest.json
+++ b/homeassistant/components/tankerkoenig/manifest.json
@@ -6,6 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/tankerkoenig",
"iot_class": "cloud_polling",
"loggers": ["aiotankerkoenig"],
- "quality_scale": "platinum",
"requirements": ["aiotankerkoenig==0.4.2"]
}
diff --git a/homeassistant/components/tapsaff/manifest.json b/homeassistant/components/tapsaff/manifest.json
index 861329827d7..c4853ca1c8d 100644
--- a/homeassistant/components/tapsaff/manifest.json
+++ b/homeassistant/components/tapsaff/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/tapsaff",
"iot_class": "local_polling",
"loggers": ["tapsaff"],
+ "quality_scale": "legacy",
"requirements": ["tapsaff==0.2.1"]
}
diff --git a/homeassistant/components/tasmota/fan.py b/homeassistant/components/tasmota/fan.py
index 15664201d99..e927bd6ad72 100644
--- a/homeassistant/components/tasmota/fan.py
+++ b/homeassistant/components/tasmota/fan.py
@@ -72,7 +72,6 @@ class TasmotaFan(
)
_fan_speed = tasmota_const.FAN_SPEED_MEDIUM
_tasmota_entity: tasmota_fan.TasmotaFan
- _enable_turn_on_off_backwards_compatibility = False
def __init__(self, **kwds: Any) -> None:
"""Initialize the Tasmota fan."""
diff --git a/homeassistant/components/tasmota/light.py b/homeassistant/components/tasmota/light.py
index 9b69ee60524..a06e77eceb1 100644
--- a/homeassistant/components/tasmota/light.py
+++ b/homeassistant/components/tasmota/light.py
@@ -18,7 +18,7 @@ from hatasmota.models import DiscoveryHashType
from homeassistant.components import light
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
- ATTR_COLOR_TEMP,
+ ATTR_COLOR_TEMP_KELVIN,
ATTR_EFFECT,
ATTR_HS_COLOR,
ATTR_TRANSITION,
@@ -32,6 +32,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
+from homeassistant.util import color as color_util
from .const import DATA_REMOVE_DISCOVER_COMPONENT
from .discovery import TASMOTA_DISCOVERY_ENTITY_NEW
@@ -199,19 +200,27 @@ class TasmotaLight(
return self._color_mode
@property
- def color_temp(self) -> int | None:
- """Return the color temperature in mired."""
- return self._color_temp
+ def color_temp_kelvin(self) -> int | None:
+ """Return the color temperature value in Kelvin."""
+ return (
+ color_util.color_temperature_mired_to_kelvin(self._color_temp)
+ if self._color_temp
+ else None
+ )
@property
- def min_mireds(self) -> int:
- """Return the coldest color_temp that this light supports."""
- return self._tasmota_entity.min_mireds
+ def max_color_temp_kelvin(self) -> int:
+ """Return the coldest color_temp_kelvin that this light supports."""
+ return color_util.color_temperature_mired_to_kelvin(
+ self._tasmota_entity.min_mireds
+ )
@property
- def max_mireds(self) -> int:
- """Return the warmest color_temp that this light supports."""
- return self._tasmota_entity.max_mireds
+ def min_color_temp_kelvin(self) -> int:
+ """Return the warmest color_temp_kelvin that this light supports."""
+ return color_util.color_temperature_mired_to_kelvin(
+ self._tasmota_entity.max_mireds
+ )
@property
def effect(self) -> str | None:
@@ -255,8 +264,13 @@ class TasmotaLight(
if ATTR_BRIGHTNESS in kwargs and brightness_supported(supported_color_modes):
attributes["brightness"] = scale_brightness(kwargs[ATTR_BRIGHTNESS])
- if ATTR_COLOR_TEMP in kwargs and ColorMode.COLOR_TEMP in supported_color_modes:
- attributes["color_temp"] = int(kwargs[ATTR_COLOR_TEMP])
+ if (
+ ATTR_COLOR_TEMP_KELVIN in kwargs
+ and ColorMode.COLOR_TEMP in supported_color_modes
+ ):
+ attributes["color_temp"] = color_util.color_temperature_kelvin_to_mired(
+ kwargs[ATTR_COLOR_TEMP_KELVIN]
+ )
if ATTR_EFFECT in kwargs:
attributes["effect"] = kwargs[ATTR_EFFECT]
diff --git a/homeassistant/components/tcp/manifest.json b/homeassistant/components/tcp/manifest.json
index e15200f49f8..7eacff6c50a 100644
--- a/homeassistant/components/tcp/manifest.json
+++ b/homeassistant/components/tcp/manifest.json
@@ -3,5 +3,6 @@
"name": "TCP",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/tcp",
- "iot_class": "local_polling"
+ "iot_class": "local_polling",
+ "quality_scale": "legacy"
}
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/manifest.json b/homeassistant/components/technove/manifest.json
index ae0e491235f..722aa4004e1 100644
--- a/homeassistant/components/technove/manifest.json
+++ b/homeassistant/components/technove/manifest.json
@@ -6,7 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/technove",
"integration_type": "device",
"iot_class": "local_polling",
- "quality_scale": "platinum",
"requirements": ["python-technove==1.3.1"],
"zeroconf": ["_technove-stations._tcp.local."]
}
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/ted5000/manifest.json b/homeassistant/components/ted5000/manifest.json
index b2aa68f884b..3e28d963957 100644
--- a/homeassistant/components/ted5000/manifest.json
+++ b/homeassistant/components/ted5000/manifest.json
@@ -4,5 +4,6 @@
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/ted5000",
"iot_class": "local_polling",
+ "quality_scale": "legacy",
"requirements": ["xmltodict==0.13.0"]
}
diff --git a/homeassistant/components/tedee/__init__.py b/homeassistant/components/tedee/__init__.py
index cd593f68e3a..95348053805 100644
--- a/homeassistant/components/tedee/__init__.py
+++ b/homeassistant/components/tedee/__init__.py
@@ -7,7 +7,7 @@ from typing import Any
from aiohttp.hdrs import METH_POST
from aiohttp.web import Request, Response
-from pytedee_async.exception import TedeeDataUpdateException, TedeeWebhookException
+from aiotedee.exception import TedeeDataUpdateException, TedeeWebhookException
from homeassistant.components.http import HomeAssistantView
from homeassistant.components.webhook import (
@@ -16,7 +16,6 @@ from homeassistant.components.webhook import (
async_register as webhook_register,
async_unregister as webhook_unregister,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_WEBHOOK_ID, EVENT_HOMEASSISTANT_STOP, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
@@ -99,7 +98,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TedeeConfigEntry) -> boo
return True
-async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_unload_entry(hass: HomeAssistant, entry: TedeeConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
@@ -131,7 +130,9 @@ def get_webhook_handler(
return async_webhook_handler
-async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
+async def async_migrate_entry(
+ hass: HomeAssistant, config_entry: TedeeConfigEntry
+) -> bool:
"""Migrate old entry."""
if config_entry.version > 1:
# This means the user has downgraded from a future version
diff --git a/homeassistant/components/tedee/binary_sensor.py b/homeassistant/components/tedee/binary_sensor.py
index 5eab7bfa254..94d3f0b6831 100644
--- a/homeassistant/components/tedee/binary_sensor.py
+++ b/homeassistant/components/tedee/binary_sensor.py
@@ -3,8 +3,8 @@
from collections.abc import Callable
from dataclasses import dataclass
-from pytedee_async import TedeeLock
-from pytedee_async.lock import TedeeLockState
+from aiotedee import TedeeLock
+from aiotedee.lock import TedeeLockState
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
@@ -18,6 +18,9 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .coordinator import TedeeConfigEntry
from .entity import TedeeDescriptionEntity
+# Coordinator is used to centralize the data updates
+PARALLEL_UPDATES = 0
+
@dataclass(frozen=True, kw_only=True)
class TedeeBinarySensorEntityDescription(
diff --git a/homeassistant/components/tedee/config_flow.py b/homeassistant/components/tedee/config_flow.py
index 65d4ec12e80..422d818d1b5 100644
--- a/homeassistant/components/tedee/config_flow.py
+++ b/homeassistant/components/tedee/config_flow.py
@@ -4,7 +4,7 @@ from collections.abc import Mapping
import logging
from typing import Any
-from pytedee_async import (
+from aiotedee import (
TedeeAuthException,
TedeeClient,
TedeeClientException,
diff --git a/homeassistant/components/tedee/coordinator.py b/homeassistant/components/tedee/coordinator.py
index de3090a3f78..4012b6d07c5 100644
--- a/homeassistant/components/tedee/coordinator.py
+++ b/homeassistant/components/tedee/coordinator.py
@@ -8,7 +8,7 @@ import logging
import time
from typing import Any
-from pytedee_async import (
+from aiotedee import (
TedeeClient,
TedeeClientException,
TedeeDataUpdateException,
@@ -16,7 +16,7 @@ from pytedee_async import (
TedeeLock,
TedeeWebhookException,
)
-from pytedee_async.bridge import TedeeBridge
+from aiotedee.bridge import TedeeBridge
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST
@@ -99,14 +99,19 @@ class TedeeApiCoordinator(DataUpdateCoordinator[dict[int, TedeeLock]]):
await update_fn()
except TedeeLocalAuthException as ex:
raise ConfigEntryAuthFailed(
- "Authentication failed. Local access token is invalid"
+ translation_domain=DOMAIN,
+ translation_key="authentification_failed",
) from ex
except TedeeDataUpdateException as ex:
_LOGGER.debug("Error while updating data: %s", str(ex))
- raise UpdateFailed(f"Error while updating data: {ex!s}") from ex
+ raise UpdateFailed(
+ translation_domain=DOMAIN, translation_key="update_failed"
+ ) from ex
except (TedeeClientException, TimeoutError) as ex:
- raise UpdateFailed(f"Querying API failed. Error: {ex!s}") from ex
+ raise UpdateFailed(
+ translation_domain=DOMAIN, translation_key="api_error"
+ ) from ex
def webhook_received(self, message: dict[str, Any]) -> None:
"""Handle webhook message."""
diff --git a/homeassistant/components/tedee/entity.py b/homeassistant/components/tedee/entity.py
index c72e293a292..96cc6f2b3f5 100644
--- a/homeassistant/components/tedee/entity.py
+++ b/homeassistant/components/tedee/entity.py
@@ -1,6 +1,6 @@
"""Bases for Tedee entities."""
-from pytedee_async.lock import TedeeLock
+from aiotedee.lock import TedeeLock
from homeassistant.core import callback
from homeassistant.helpers.device_registry import DeviceInfo
diff --git a/homeassistant/components/tedee/lock.py b/homeassistant/components/tedee/lock.py
index 34d313f3e48..38df85a9cdb 100644
--- a/homeassistant/components/tedee/lock.py
+++ b/homeassistant/components/tedee/lock.py
@@ -2,7 +2,7 @@
from typing import Any
-from pytedee_async import TedeeClientException, TedeeLock, TedeeLockState
+from aiotedee import TedeeClientException, TedeeLock, TedeeLockState
from homeassistant.components.lock import LockEntity, LockEntityFeature
from homeassistant.core import HomeAssistant
@@ -13,6 +13,8 @@ from .const import DOMAIN
from .coordinator import TedeeApiCoordinator, TedeeConfigEntry
from .entity import TedeeEntity
+PARALLEL_UPDATES = 1
+
async def async_setup_entry(
hass: HomeAssistant,
diff --git a/homeassistant/components/tedee/manifest.json b/homeassistant/components/tedee/manifest.json
index 4f071267a25..bca51f08f93 100644
--- a/homeassistant/components/tedee/manifest.json
+++ b/homeassistant/components/tedee/manifest.json
@@ -6,7 +6,7 @@
"dependencies": ["http", "webhook"],
"documentation": "https://www.home-assistant.io/integrations/tedee",
"iot_class": "local_push",
- "loggers": ["pytedee_async"],
+ "loggers": ["aiotedee"],
"quality_scale": "platinum",
- "requirements": ["pytedee-async==0.2.20"]
+ "requirements": ["aiotedee==0.2.20"]
}
diff --git a/homeassistant/components/tedee/quality_scale.yaml b/homeassistant/components/tedee/quality_scale.yaml
new file mode 100644
index 00000000000..974c8f82ec9
--- /dev/null
+++ b/homeassistant/components/tedee/quality_scale.yaml
@@ -0,0 +1,86 @@
+rules:
+ # Bronze
+ action-setup:
+ status: exempt
+ comment: |
+ No custom actions
+ appropriate-polling: done
+ brands: done
+ common-modules: done
+ config-flow-test-coverage: done
+ config-flow: done
+ dependency-transparency: done
+ docs-actions:
+ status: exempt
+ comment: |
+ No custom actions
+ 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: done
+ test-before-setup: done
+ unique-config-entry: done
+
+ # Silver
+ action-exceptions:
+ status: exempt
+ comment: |
+ No custom actions
+ config-entry-unloading: done
+ docs-configuration-parameters:
+ status: exempt
+ comment: |
+ Options flow not documented, doesn't have one
+ docs-installation-parameters: done
+ entity-unavailable: done
+ integration-owner: done
+ log-when-unavailable:
+ status: done
+ comment: |
+ Handled by coordinator
+ parallel-updates: done
+ reauthentication-flow: done
+ test-coverage: done
+
+ # Gold
+ devices: done
+ diagnostics: done
+ discovery-update-info:
+ status: exempt
+ comment: |
+ No discovery
+ discovery:
+ status: exempt
+ comment: |
+ No discovery supported atm
+ 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: done
+ 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: |
+ Currently no repairs/issues
+ stale-devices: done
+ # Platinum
+ async-dependency: done
+ inject-websession: done
+ strict-typing: done
diff --git a/homeassistant/components/tedee/sensor.py b/homeassistant/components/tedee/sensor.py
index 33894a5eb52..d61e7360dc4 100644
--- a/homeassistant/components/tedee/sensor.py
+++ b/homeassistant/components/tedee/sensor.py
@@ -3,7 +3,7 @@
from collections.abc import Callable
from dataclasses import dataclass
-from pytedee_async import TedeeLock
+from aiotedee import TedeeLock
from homeassistant.components.sensor import (
SensorDeviceClass,
@@ -18,6 +18,9 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .coordinator import TedeeConfigEntry
from .entity import TedeeDescriptionEntity
+# Coordinator is used to centralize the data updates
+PARALLEL_UPDATES = 0
+
@dataclass(frozen=True, kw_only=True)
class TedeeSensorEntityDescription(SensorEntityDescription):
diff --git a/homeassistant/components/tedee/strings.json b/homeassistant/components/tedee/strings.json
index b6966fa2933..78cacd706d3 100644
--- a/homeassistant/components/tedee/strings.json
+++ b/homeassistant/components/tedee/strings.json
@@ -66,12 +66,21 @@
}
},
"exceptions": {
+ "api_error": {
+ "message": "Error while communicating with the API"
+ },
+ "authentication_failed": {
+ "message": "Authentication failed. Local access token is invalid"
+ },
"lock_failed": {
"message": "Failed to lock the door. Lock {lock_id}"
},
"unlock_failed": {
"message": "Failed to unlock the door. Lock {lock_id}"
},
+ "update_failed": {
+ "message": "Error while updating data"
+ },
"open_failed": {
"message": "Failed to unlatch the door. Lock {lock_id}"
}
diff --git a/homeassistant/components/telegram/manifest.json b/homeassistant/components/telegram/manifest.json
index ce4457b3129..9022f357970 100644
--- a/homeassistant/components/telegram/manifest.json
+++ b/homeassistant/components/telegram/manifest.json
@@ -4,5 +4,6 @@
"codeowners": [],
"dependencies": ["telegram_bot"],
"documentation": "https://www.home-assistant.io/integrations/telegram",
- "iot_class": "cloud_polling"
+ "iot_class": "cloud_polling",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/telegram_bot/manifest.json b/homeassistant/components/telegram_bot/manifest.json
index b432c88762f..3474d39b1d6 100644
--- a/homeassistant/components/telegram_bot/manifest.json
+++ b/homeassistant/components/telegram_bot/manifest.json
@@ -6,5 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/telegram_bot",
"iot_class": "cloud_push",
"loggers": ["telegram"],
+ "quality_scale": "legacy",
"requirements": ["python-telegram-bot[socks]==21.5"]
}
diff --git a/homeassistant/components/tellduslive/manifest.json b/homeassistant/components/tellduslive/manifest.json
index dc1389c15c5..4ebf1a334bd 100644
--- a/homeassistant/components/tellduslive/manifest.json
+++ b/homeassistant/components/tellduslive/manifest.json
@@ -5,6 +5,5 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/tellduslive",
"iot_class": "cloud_polling",
- "quality_scale": "silver",
"requirements": ["tellduslive==0.10.12"]
}
diff --git a/homeassistant/components/tellstick/manifest.json b/homeassistant/components/tellstick/manifest.json
index c64a51b09e4..40956b06ac6 100644
--- a/homeassistant/components/tellstick/manifest.json
+++ b/homeassistant/components/tellstick/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/tellstick",
"iot_class": "assumed_state",
"loggers": ["tellcore"],
+ "quality_scale": "legacy",
"requirements": ["tellcore-net==0.4", "tellcore-py==1.1.2"]
}
diff --git a/homeassistant/components/telnet/manifest.json b/homeassistant/components/telnet/manifest.json
index 48a79afc528..68353104839 100644
--- a/homeassistant/components/telnet/manifest.json
+++ b/homeassistant/components/telnet/manifest.json
@@ -3,5 +3,6 @@
"name": "Telnet",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/telnet",
- "iot_class": "local_polling"
+ "iot_class": "local_polling",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/temper/manifest.json b/homeassistant/components/temper/manifest.json
index dbad8827877..ad1fcd40525 100644
--- a/homeassistant/components/temper/manifest.json
+++ b/homeassistant/components/temper/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/temper",
"iot_class": "local_polling",
"loggers": ["pyusb", "temperusb"],
+ "quality_scale": "legacy",
"requirements": ["temperusb==1.6.1"]
}
diff --git a/homeassistant/components/template/config_flow.py b/homeassistant/components/template/config_flow.py
index c1c023c0ea4..e6cc377bc26 100644
--- a/homeassistant/components/template/config_flow.py
+++ b/homeassistant/components/template/config_flow.py
@@ -157,7 +157,7 @@ def generate_schema(domain: str, flow_type: str) -> vol.Schema:
type=selector.TextSelectorType.TEXT, multiline=False
)
),
- vol.Optional(CONF_SET_VALUE): selector.ActionSelector(),
+ vol.Required(CONF_SET_VALUE): selector.ActionSelector(),
}
if domain == Platform.SELECT:
@@ -235,8 +235,12 @@ def _validate_unit(options: dict[str, Any]) -> None:
and (units := DEVICE_CLASS_UNITS.get(device_class)) is not None
and (unit := options.get(CONF_UNIT_OF_MEASUREMENT)) not in units
):
+ # Sort twice to make sure strings with same case-insensitive order of
+ # letters are sorted consistently still.
sorted_units = sorted(
- [f"'{unit!s}'" if unit else "no unit of measurement" for unit in units],
+ sorted(
+ [f"'{unit!s}'" if unit else "no unit of measurement" for unit in units],
+ ),
key=str.casefold,
)
if len(sorted_units) == 1:
diff --git a/homeassistant/components/template/fan.py b/homeassistant/components/template/fan.py
index cedd7d0d725..7720ef7e1b3 100644
--- a/homeassistant/components/template/fan.py
+++ b/homeassistant/components/template/fan.py
@@ -124,7 +124,6 @@ class TemplateFan(TemplateEntity, FanEntity):
"""A template fan component."""
_attr_should_poll = False
- _enable_turn_on_off_backwards_compatibility = False
def __init__(
self,
diff --git a/homeassistant/components/template/light.py b/homeassistant/components/template/light.py
index cae6c0cebc1..9391e368e2b 100644
--- a/homeassistant/components/template/light.py
+++ b/homeassistant/components/template/light.py
@@ -9,13 +9,15 @@ import voluptuous as vol
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
- ATTR_COLOR_TEMP,
+ ATTR_COLOR_TEMP_KELVIN,
ATTR_EFFECT,
ATTR_HS_COLOR,
ATTR_RGB_COLOR,
ATTR_RGBW_COLOR,
ATTR_RGBWW_COLOR,
ATTR_TRANSITION,
+ DEFAULT_MAX_KELVIN,
+ DEFAULT_MIN_KELVIN,
ENTITY_ID_FORMAT,
PLATFORM_SCHEMA as LIGHT_PLATFORM_SCHEMA,
ColorMode,
@@ -39,6 +41,7 @@ from homeassistant.helpers.entity import async_generate_entity_id
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.script import Script
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
+from homeassistant.util import color as color_util
from .const import DOMAIN
from .template_entity import (
@@ -77,6 +80,9 @@ CONF_TEMPERATURE_TEMPLATE = "temperature_template"
CONF_WHITE_VALUE_ACTION = "set_white_value"
CONF_WHITE_VALUE_TEMPLATE = "white_value_template"
+DEFAULT_MIN_MIREDS = 153
+DEFAULT_MAX_MIREDS = 500
+
LIGHT_SCHEMA = vol.All(
cv.deprecated(CONF_ENTITY_ID),
vol.Schema(
@@ -262,25 +268,27 @@ class LightTemplate(TemplateEntity, LightEntity):
return self._brightness
@property
- def color_temp(self) -> int | None:
- """Return the CT color value in mireds."""
- return self._temperature
+ def color_temp_kelvin(self) -> int | None:
+ """Return the color temperature value in Kelvin."""
+ if self._temperature is None:
+ return None
+ return color_util.color_temperature_mired_to_kelvin(self._temperature)
@property
- def max_mireds(self) -> int:
- """Return the max mireds value in mireds."""
+ def min_color_temp_kelvin(self) -> int:
+ """Return the warmest color_temp_kelvin that this light supports."""
if self._max_mireds is not None:
- return self._max_mireds
+ return color_util.color_temperature_mired_to_kelvin(self._max_mireds)
- return super().max_mireds
+ return DEFAULT_MIN_KELVIN
@property
- def min_mireds(self) -> int:
- """Return the min mireds value in mireds."""
+ def max_color_temp_kelvin(self) -> int:
+ """Return the coldest color_temp_kelvin that this light supports."""
if self._min_mireds is not None:
- return self._min_mireds
+ return color_util.color_temperature_mired_to_kelvin(self._min_mireds)
- return super().min_mireds
+ return DEFAULT_MAX_KELVIN
@property
def hs_color(self) -> tuple[float, float] | None:
@@ -447,13 +455,16 @@ class LightTemplate(TemplateEntity, LightEntity):
self._brightness = kwargs[ATTR_BRIGHTNESS]
optimistic_set = True
- if self._temperature_template is None and ATTR_COLOR_TEMP in kwargs:
+ if self._temperature_template is None and ATTR_COLOR_TEMP_KELVIN in kwargs:
+ color_temp = color_util.color_temperature_kelvin_to_mired(
+ kwargs[ATTR_COLOR_TEMP_KELVIN]
+ )
_LOGGER.debug(
"Optimistically setting color temperature to %s",
- kwargs[ATTR_COLOR_TEMP],
+ color_temp,
)
self._color_mode = ColorMode.COLOR_TEMP
- self._temperature = kwargs[ATTR_COLOR_TEMP]
+ self._temperature = color_temp
if self._hs_template is None and self._color_template is None:
self._hs_color = None
if self._rgb_template is None:
@@ -544,8 +555,10 @@ class LightTemplate(TemplateEntity, LightEntity):
if ATTR_TRANSITION in kwargs and self._supports_transition is True:
common_params["transition"] = kwargs[ATTR_TRANSITION]
- if ATTR_COLOR_TEMP in kwargs and self._temperature_script:
- common_params["color_temp"] = kwargs[ATTR_COLOR_TEMP]
+ if ATTR_COLOR_TEMP_KELVIN in kwargs and self._temperature_script:
+ common_params["color_temp"] = color_util.color_temperature_kelvin_to_mired(
+ kwargs[ATTR_COLOR_TEMP_KELVIN]
+ )
await self.async_run_script(
self._temperature_script,
@@ -756,7 +769,9 @@ class LightTemplate(TemplateEntity, LightEntity):
self._temperature = None
return
temperature = int(render)
- if self.min_mireds <= temperature <= self.max_mireds:
+ min_mireds = self._min_mireds or DEFAULT_MIN_MIREDS
+ max_mireds = self._max_mireds or DEFAULT_MAX_MIREDS
+ if min_mireds <= temperature <= max_mireds:
self._temperature = temperature
else:
_LOGGER.error(
@@ -766,8 +781,8 @@ class LightTemplate(TemplateEntity, LightEntity):
),
temperature,
self.entity_id,
- self.min_mireds,
- self.max_mireds,
+ min_mireds,
+ max_mireds,
)
self._temperature = None
except ValueError:
diff --git a/homeassistant/components/template/lock.py b/homeassistant/components/template/lock.py
index 6ea8aff4c1a..f194154a50c 100644
--- a/homeassistant/components/template/lock.py
+++ b/homeassistant/components/template/lock.py
@@ -2,13 +2,14 @@
from __future__ import annotations
-from typing import Any
+from typing import TYPE_CHECKING, Any
import voluptuous as vol
from homeassistant.components.lock import (
PLATFORM_SCHEMA as LOCK_PLATFORM_SCHEMA,
LockEntity,
+ LockEntityFeature,
LockState,
)
from homeassistant.const import (
@@ -17,7 +18,6 @@ from homeassistant.const import (
CONF_OPTIMISTIC,
CONF_UNIQUE_ID,
CONF_VALUE_TEMPLATE,
- STATE_ON,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ServiceValidationError, TemplateError
@@ -36,6 +36,7 @@ from .template_entity import (
CONF_CODE_FORMAT_TEMPLATE = "code_format_template"
CONF_LOCK = "lock"
CONF_UNLOCK = "unlock"
+CONF_OPEN = "open"
DEFAULT_NAME = "Template Lock"
DEFAULT_OPTIMISTIC = False
@@ -45,6 +46,7 @@ PLATFORM_SCHEMA = LOCK_PLATFORM_SCHEMA.extend(
vol.Optional(CONF_NAME): cv.string,
vol.Required(CONF_LOCK): cv.SCRIPT_SCHEMA,
vol.Required(CONF_UNLOCK): cv.SCRIPT_SCHEMA,
+ vol.Optional(CONF_OPEN): cv.SCRIPT_SCHEMA,
vol.Required(CONF_VALUE_TEMPLATE): cv.template,
vol.Optional(CONF_CODE_FORMAT_TEMPLATE): cv.template,
vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean,
@@ -53,7 +55,9 @@ PLATFORM_SCHEMA = LOCK_PLATFORM_SCHEMA.extend(
).extend(TEMPLATE_ENTITY_AVAILABILITY_SCHEMA_LEGACY.schema)
-async def _async_create_entities(hass, config):
+async def _async_create_entities(
+ hass: HomeAssistant, config: dict[str, Any]
+) -> list[TemplateLock]:
"""Create the Template lock."""
config = rewrite_common_legacy_to_modern_conf(hass, config)
return [TemplateLock(hass, config, config.get(CONF_UNIQUE_ID))]
@@ -76,29 +80,33 @@ class TemplateLock(TemplateEntity, LockEntity):
def __init__(
self,
- hass,
- config,
- unique_id,
- ):
+ hass: HomeAssistant,
+ config: dict[str, Any],
+ unique_id: str | None,
+ ) -> None:
"""Initialize the lock."""
super().__init__(
hass, config=config, fallback_name=DEFAULT_NAME, unique_id=unique_id
)
- self._state = None
+ self._state: LockState | None = None
name = self._attr_name
+ assert name
self._state_template = config.get(CONF_VALUE_TEMPLATE)
self._command_lock = Script(hass, config[CONF_LOCK], name, DOMAIN)
self._command_unlock = Script(hass, config[CONF_UNLOCK], name, DOMAIN)
+ if CONF_OPEN in config:
+ self._command_open = Script(hass, config[CONF_OPEN], name, DOMAIN)
+ self._attr_supported_features |= LockEntityFeature.OPEN
self._code_format_template = config.get(CONF_CODE_FORMAT_TEMPLATE)
- self._code_format = None
- self._code_format_template_error = None
+ self._code_format: str | None = None
+ self._code_format_template_error: TemplateError | None = None
self._optimistic = config.get(CONF_OPTIMISTIC)
self._attr_assumed_state = bool(self._optimistic)
@property
def is_locked(self) -> bool:
"""Return true if lock is locked."""
- return self._state in ("true", STATE_ON, LockState.LOCKED)
+ return self._state == LockState.LOCKED
@property
def is_jammed(self) -> bool:
@@ -115,8 +123,13 @@ class TemplateLock(TemplateEntity, LockEntity):
"""Return true if lock is locking."""
return self._state == LockState.LOCKING
+ @property
+ def is_open(self) -> bool:
+ """Return true if lock is open."""
+ return self._state == LockState.OPEN
+
@callback
- def _update_state(self, result):
+ def _update_state(self, result: str | TemplateError) -> None:
"""Update the state from the template."""
super()._update_state(result)
if isinstance(result, TemplateError):
@@ -128,7 +141,23 @@ class TemplateLock(TemplateEntity, LockEntity):
return
if isinstance(result, str):
- self._state = result.lower()
+ if result.lower() in (
+ "true",
+ "on",
+ "locked",
+ ):
+ self._state = LockState.LOCKED
+ elif result.lower() in (
+ "false",
+ "off",
+ "unlocked",
+ ):
+ self._state = LockState.UNLOCKED
+ else:
+ try:
+ self._state = LockState(result.lower())
+ except ValueError:
+ self._state = None
return
self._state = None
@@ -141,6 +170,8 @@ class TemplateLock(TemplateEntity, LockEntity):
@callback
def _async_setup_templates(self) -> None:
"""Set up templates."""
+ if TYPE_CHECKING:
+ assert self._state_template is not None
self.add_template_attribute(
"_state", self._state_template, None, self._update_state
)
@@ -168,10 +199,12 @@ class TemplateLock(TemplateEntity, LockEntity):
async def async_lock(self, **kwargs: Any) -> None:
"""Lock the device."""
+ # Check if we need to raise for incorrect code format
+ # template before processing the action.
self._raise_template_error_if_available()
if self._optimistic:
- self._state = True
+ self._state = LockState.LOCKED
self.async_write_ha_state()
tpl_vars = {ATTR_CODE: kwargs.get(ATTR_CODE) if kwargs else None}
@@ -182,10 +215,12 @@ class TemplateLock(TemplateEntity, LockEntity):
async def async_unlock(self, **kwargs: Any) -> None:
"""Unlock the device."""
+ # Check if we need to raise for incorrect code format
+ # template before processing the action.
self._raise_template_error_if_available()
if self._optimistic:
- self._state = False
+ self._state = LockState.UNLOCKED
self.async_write_ha_state()
tpl_vars = {ATTR_CODE: kwargs.get(ATTR_CODE) if kwargs else None}
@@ -194,7 +229,24 @@ class TemplateLock(TemplateEntity, LockEntity):
self._command_unlock, run_variables=tpl_vars, context=self._context
)
+ async def async_open(self, **kwargs: Any) -> None:
+ """Open the device."""
+ # Check if we need to raise for incorrect code format
+ # template before processing the action.
+ self._raise_template_error_if_available()
+
+ if self._optimistic:
+ self._state = LockState.OPEN
+ self.async_write_ha_state()
+
+ tpl_vars = {ATTR_CODE: kwargs.get(ATTR_CODE) if kwargs else None}
+
+ await self.async_run_script(
+ self._command_open, run_variables=tpl_vars, context=self._context
+ )
+
def _raise_template_error_if_available(self):
+ """Raise an error if the rendered code format is not valid."""
if self._code_format_template_error is not None:
raise ServiceValidationError(
translation_domain=DOMAIN,
diff --git a/homeassistant/components/template/manifest.json b/homeassistant/components/template/manifest.json
index 57188aebaa3..f1225f74f06 100644
--- a/homeassistant/components/template/manifest.json
+++ b/homeassistant/components/template/manifest.json
@@ -2,7 +2,7 @@
"domain": "template",
"name": "Template",
"after_dependencies": ["group"],
- "codeowners": ["@PhracturedBlue", "@tetienne", "@home-assistant/core"],
+ "codeowners": ["@PhracturedBlue", "@home-assistant/core"],
"config_flow": true,
"dependencies": ["blueprint"],
"documentation": "https://www.home-assistant.io/integrations/template",
diff --git a/homeassistant/components/template/vacuum.py b/homeassistant/components/template/vacuum.py
index 1d021bcb571..19029cc708b 100644
--- a/homeassistant/components/template/vacuum.py
+++ b/homeassistant/components/template/vacuum.py
@@ -17,13 +17,8 @@ from homeassistant.components.vacuum import (
SERVICE_SET_FAN_SPEED,
SERVICE_START,
SERVICE_STOP,
- STATE_CLEANING,
- STATE_DOCKED,
- STATE_ERROR,
- STATE_IDLE,
- STATE_PAUSED,
- STATE_RETURNING,
StateVacuumEntity,
+ VacuumActivity,
VacuumEntityFeature,
)
from homeassistant.const import (
@@ -58,12 +53,12 @@ CONF_FAN_SPEED_TEMPLATE = "fan_speed_template"
ENTITY_ID_FORMAT = VACUUM_DOMAIN + ".{}"
_VALID_STATES = [
- STATE_CLEANING,
- STATE_DOCKED,
- STATE_PAUSED,
- STATE_IDLE,
- STATE_RETURNING,
- STATE_ERROR,
+ VacuumActivity.CLEANING,
+ VacuumActivity.DOCKED,
+ VacuumActivity.PAUSED,
+ VacuumActivity.IDLE,
+ VacuumActivity.RETURNING,
+ VacuumActivity.ERROR,
]
VACUUM_SCHEMA = vol.All(
@@ -202,7 +197,7 @@ class TemplateVacuum(TemplateEntity, StateVacuumEntity):
self._attr_fan_speed_list = config[CONF_FAN_SPEED_LIST]
@property
- def state(self) -> str | None:
+ def activity(self) -> VacuumActivity | None:
"""Return the status of the vacuum cleaner."""
return self._state
diff --git a/homeassistant/components/tensorflow/manifest.json b/homeassistant/components/tensorflow/manifest.json
index 906ce02f5b1..1cd856f31d0 100644
--- a/homeassistant/components/tensorflow/manifest.json
+++ b/homeassistant/components/tensorflow/manifest.json
@@ -5,11 +5,12 @@
"documentation": "https://www.home-assistant.io/integrations/tensorflow",
"iot_class": "local_polling",
"loggers": ["tensorflow"],
+ "quality_scale": "legacy",
"requirements": [
"tensorflow==2.5.0",
"tf-models-official==2.5.0",
"pycocotools==2.0.6",
- "numpy==2.1.2",
- "Pillow==10.4.0"
+ "numpy==2.2.1",
+ "Pillow==11.1.0"
]
}
diff --git a/homeassistant/components/tesla_fleet/__init__.py b/homeassistant/components/tesla_fleet/__init__.py
index 70db4a183aa..ff50a99748e 100644
--- a/homeassistant/components/tesla_fleet/__init__.py
+++ b/homeassistant/components/tesla_fleet/__init__.py
@@ -34,7 +34,6 @@ from homeassistant.helpers.config_entry_oauth2_flow import (
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.device_registry import DeviceInfo
-from .config_flow import OAuth2FlowHandler
from .const import DOMAIN, LOGGER, MODELS
from .coordinator import (
TeslaFleetEnergySiteInfoCoordinator,
@@ -42,7 +41,6 @@ from .coordinator import (
TeslaFleetVehicleDataCoordinator,
)
from .models import TeslaFleetData, TeslaFleetEnergyData, TeslaFleetVehicleData
-from .oauth import TeslaSystemImplementation
PLATFORMS: Final = [
Platform.BINARY_SENSOR,
@@ -66,6 +64,15 @@ CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
async def async_setup_entry(hass: HomeAssistant, entry: TeslaFleetConfigEntry) -> bool:
"""Set up TeslaFleet config."""
+ try:
+ implementation = await async_get_config_entry_implementation(hass, entry)
+ except ValueError as e:
+ # Remove invalid implementation from config entry then raise AuthFailed
+ hass.config_entries.async_update_entry(
+ entry, data={"auth_implementation": None}
+ )
+ raise ConfigEntryAuthFailed from e
+
access_token = entry.data[CONF_TOKEN][CONF_ACCESS_TOKEN]
session = async_get_clientsession(hass)
@@ -73,12 +80,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslaFleetConfigEntry) -
scopes: list[Scope] = [Scope(s) for s in token["scp"]]
region: str = token["ou_code"].lower()
- OAuth2FlowHandler.async_register_implementation(
- hass,
- TeslaSystemImplementation(hass),
- )
-
- implementation = await async_get_config_entry_implementation(hass, entry)
oauth_session = OAuth2Session(hass, entry, implementation)
refresh_lock = asyncio.Lock()
@@ -134,7 +135,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslaFleetConfigEntry) -
signing = product["command_signing"] == "required"
if signing:
if not tesla.private_key:
- await tesla.get_private_key("config/tesla_fleet.key")
+ await tesla.get_private_key(hass.config.path("tesla_fleet.key"))
api = VehicleSigned(tesla.vehicle, vin)
else:
api = VehicleSpecific(tesla.vehicle, vin)
diff --git a/homeassistant/components/tesla_fleet/climate.py b/homeassistant/components/tesla_fleet/climate.py
index 9a1533a688f..06e9c9d7c64 100644
--- a/homeassistant/components/tesla_fleet/climate.py
+++ b/homeassistant/components/tesla_fleet/climate.py
@@ -74,7 +74,6 @@ class TeslaFleetClimateEntity(TeslaFleetVehicleEntity, ClimateEntity):
| ClimateEntityFeature.PRESET_MODE
)
_attr_preset_modes = ["off", "keep", "dog", "camp"]
- _enable_turn_on_off_backwards_compatibility = False
def __init__(
self,
@@ -220,7 +219,7 @@ class TeslaFleetCabinOverheatProtectionEntity(TeslaFleetVehicleEntity, ClimateEn
_attr_max_temp = COP_LEVELS["High"]
_attr_temperature_unit = UnitOfTemperature.CELSIUS
_attr_hvac_modes = list(COP_MODES.values())
- _enable_turn_on_off_backwards_compatibility = False
+
_attr_entity_registry_enabled_default = False
def __init__(
diff --git a/homeassistant/components/tesla_fleet/config_flow.py b/homeassistant/components/tesla_fleet/config_flow.py
index ca36c6f511b..feeb5e74ca6 100644
--- a/homeassistant/components/tesla_fleet/config_flow.py
+++ b/homeassistant/components/tesla_fleet/config_flow.py
@@ -12,7 +12,6 @@ from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult
from homeassistant.helpers import config_entry_oauth2_flow
from .const import DOMAIN, LOGGER
-from .oauth import TeslaSystemImplementation
class OAuth2FlowHandler(
@@ -31,11 +30,6 @@ class OAuth2FlowHandler(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle a flow start."""
- self.async_register_implementation(
- self.hass,
- TeslaSystemImplementation(self.hass),
- )
-
return await super().async_step_user()
async def async_oauth_create_entry(
diff --git a/homeassistant/components/tesla_fleet/const.py b/homeassistant/components/tesla_fleet/const.py
index 53e34092326..9b3baf49bfb 100644
--- a/homeassistant/components/tesla_fleet/const.py
+++ b/homeassistant/components/tesla_fleet/const.py
@@ -21,6 +21,7 @@ SCOPES = [
Scope.OPENID,
Scope.OFFLINE_ACCESS,
Scope.VEHICLE_DEVICE_DATA,
+ Scope.VEHICLE_LOCATION,
Scope.VEHICLE_CMDS,
Scope.VEHICLE_CHARGING_CMDS,
Scope.ENERGY_DEVICE_DATA,
@@ -32,6 +33,8 @@ MODELS = {
"3": "Model 3",
"X": "Model X",
"Y": "Model Y",
+ "C": "Cybertruck",
+ "T": "Tesla Semi",
}
diff --git a/homeassistant/components/tesla_fleet/manifest.json b/homeassistant/components/tesla_fleet/manifest.json
index 8d6e5f11068..aecc6a04af3 100644
--- a/homeassistant/components/tesla_fleet/manifest.json
+++ b/homeassistant/components/tesla_fleet/manifest.json
@@ -7,6 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/tesla_fleet",
"iot_class": "cloud_polling",
"loggers": ["tesla-fleet-api"],
- "quality_scale": "gold",
- "requirements": ["tesla-fleet-api==0.8.4"]
+ "requirements": ["tesla-fleet-api==0.9.2"]
}
diff --git a/homeassistant/components/tesla_fleet/oauth.py b/homeassistant/components/tesla_fleet/oauth.py
index 00976abf56f..b25c5216009 100644
--- a/homeassistant/components/tesla_fleet/oauth.py
+++ b/homeassistant/components/tesla_fleet/oauth.py
@@ -1,8 +1,5 @@
"""Provide oauth implementations for the Tesla Fleet integration."""
-import base64
-import hashlib
-import secrets
from typing import Any
from homeassistant.components.application_credentials import (
@@ -11,58 +8,8 @@ from homeassistant.components.application_credentials import (
ClientCredential,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers import config_entry_oauth2_flow
-from .const import AUTHORIZE_URL, CLIENT_ID, DOMAIN, SCOPES, TOKEN_URL
-
-
-class TeslaSystemImplementation(config_entry_oauth2_flow.LocalOAuth2Implementation):
- """Tesla Fleet API open source Oauth2 implementation."""
-
- code_verifier: str
- code_challenge: str
-
- def __init__(self, hass: HomeAssistant) -> None:
- """Initialize open source Oauth2 implementation."""
-
- # Setup PKCE
- self.code_verifier = secrets.token_urlsafe(32)
- hashed_verifier = hashlib.sha256(self.code_verifier.encode()).digest()
- self.code_challenge = (
- base64.urlsafe_b64encode(hashed_verifier).decode().replace("=", "")
- )
- super().__init__(
- hass,
- DOMAIN,
- CLIENT_ID,
- "",
- AUTHORIZE_URL,
- TOKEN_URL,
- )
-
- @property
- def name(self) -> str:
- """Name of the implementation."""
- return "Built-in open source client ID"
-
- @property
- def extra_authorize_data(self) -> dict[str, Any]:
- """Extra data that needs to be appended to the authorize url."""
- return {
- "scope": " ".join(SCOPES),
- "code_challenge": self.code_challenge, # PKCE
- }
-
- async def async_resolve_external_data(self, external_data: Any) -> dict:
- """Resolve the authorization code to tokens."""
- return await self._token_request(
- {
- "grant_type": "authorization_code",
- "code": external_data["code"],
- "redirect_uri": external_data["state"]["redirect_uri"],
- "code_verifier": self.code_verifier, # PKCE
- }
- )
+from .const import AUTHORIZE_URL, SCOPES, TOKEN_URL
class TeslaUserImplementation(AuthImplementation):
@@ -83,4 +30,4 @@ class TeslaUserImplementation(AuthImplementation):
@property
def extra_authorize_data(self) -> dict[str, Any]:
"""Extra data that needs to be appended to the authorize url."""
- return {"scope": " ".join(SCOPES)}
+ return {"prompt": "login", "scope": " ".join(SCOPES)}
diff --git a/homeassistant/components/teslemetry/__init__.py b/homeassistant/components/teslemetry/__init__.py
index aa1d2b42660..5779283b955 100644
--- a/homeassistant/components/teslemetry/__init__.py
+++ b/homeassistant/components/teslemetry/__init__.py
@@ -85,6 +85,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) -
scopes = calls[0]["scopes"]
region = calls[0]["region"]
+ vehicle_metadata = calls[0]["vehicles"]
products = calls[1]["response"]
device_registry = dr.async_get(hass)
@@ -102,7 +103,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) -
)
for product in products:
- if "vin" in product and Scope.VEHICLE_DEVICE_DATA in scopes:
+ if (
+ "vin" in product
+ and vehicle_metadata.get(product["vin"], {}).get("access")
+ and Scope.VEHICLE_DEVICE_DATA in scopes
+ ):
# Remove the protobuff 'cached_data' that we do not use to save memory
product.pop("cached_data", None)
vin = product["vin"]
@@ -253,7 +258,6 @@ def create_handle_vehicle_stream(vin: str, coordinator) -> Callable[[dict], None
"""Handle vehicle data from the stream."""
if "vehicle_data" in data:
LOGGER.debug("Streaming received vehicle data from %s", vin)
- coordinator.updated_once = True
coordinator.async_set_updated_data(flatten(data["vehicle_data"]))
elif "state" in data:
LOGGER.debug("Streaming received state from %s", vin)
diff --git a/homeassistant/components/teslemetry/binary_sensor.py b/homeassistant/components/teslemetry/binary_sensor.py
index b51a67a0b4e..29ebfea4db1 100644
--- a/homeassistant/components/teslemetry/binary_sensor.py
+++ b/homeassistant/components/teslemetry/binary_sensor.py
@@ -223,15 +223,12 @@ class TeslemetryVehicleBinarySensorEntity(TeslemetryVehicleEntity, BinarySensorE
def _async_update_attrs(self) -> None:
"""Update the attributes of the binary sensor."""
- if self.coordinator.updated_once:
- 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)
- else:
+ 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)
class TeslemetryEnergyLiveBinarySensorEntity(
diff --git a/homeassistant/components/teslemetry/climate.py b/homeassistant/components/teslemetry/climate.py
index 5e933d1dbce..95b769a1c2d 100644
--- a/homeassistant/components/teslemetry/climate.py
+++ b/homeassistant/components/teslemetry/climate.py
@@ -74,7 +74,6 @@ class TeslemetryClimateEntity(TeslemetryVehicleEntity, ClimateEntity):
| ClimateEntityFeature.PRESET_MODE
)
_attr_preset_modes = ["off", "keep", "dog", "camp"]
- _enable_turn_on_off_backwards_compatibility = False
def __init__(
self,
@@ -97,9 +96,7 @@ class TeslemetryClimateEntity(TeslemetryVehicleEntity, ClimateEntity):
def _async_update_attrs(self) -> None:
"""Update the attributes of the entity."""
value = self.get("climate_state_is_climate_on")
- if value is None:
- self._attr_hvac_mode = None
- elif value:
+ if value:
self._attr_hvac_mode = HVACMode.HEAT_COOL
else:
self._attr_hvac_mode = HVACMode.OFF
@@ -209,7 +206,7 @@ class TeslemetryCabinOverheatProtectionEntity(TeslemetryVehicleEntity, ClimateEn
_attr_max_temp = COP_LEVELS["High"]
_attr_temperature_unit = UnitOfTemperature.CELSIUS
_attr_hvac_modes = list(COP_MODES.values())
- _enable_turn_on_off_backwards_compatibility = False
+
_attr_entity_registry_enabled_default = False
def __init__(
diff --git a/homeassistant/components/teslemetry/coordinator.py b/homeassistant/components/teslemetry/coordinator.py
index f37d0613de9..303a3250edf 100644
--- a/homeassistant/components/teslemetry/coordinator.py
+++ b/homeassistant/components/teslemetry/coordinator.py
@@ -6,21 +6,19 @@ from typing import Any
from tesla_fleet_api import EnergySpecific, VehicleSpecific
from tesla_fleet_api.const import TeslaEnergyPeriod, VehicleDataEndpoint
from tesla_fleet_api.exceptions import (
- Forbidden,
InvalidToken,
SubscriptionRequired,
TeslaFleetError,
- VehicleOffline,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
-from .const import ENERGY_HISTORY_FIELDS, LOGGER, TeslemetryState
+from .const import ENERGY_HISTORY_FIELDS, LOGGER
from .helpers import flatten
-VEHICLE_INTERVAL = timedelta(seconds=30)
+VEHICLE_INTERVAL = timedelta(seconds=60)
VEHICLE_WAIT = timedelta(minutes=15)
ENERGY_LIVE_INTERVAL = timedelta(seconds=30)
ENERGY_INFO_INTERVAL = timedelta(seconds=30)
@@ -39,7 +37,6 @@ ENDPOINTS = [
class TeslemetryVehicleDataCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"""Class to manage fetching data from the Teslemetry API."""
- updated_once: bool
last_active: datetime
def __init__(
@@ -54,63 +51,24 @@ class TeslemetryVehicleDataCoordinator(DataUpdateCoordinator[dict[str, Any]]):
)
self.api = api
self.data = flatten(product)
- self.updated_once = False
self.last_active = datetime.now()
async def _async_update_data(self) -> dict[str, Any]:
"""Update vehicle data using Teslemetry API."""
- self.update_interval = VEHICLE_INTERVAL
-
try:
- if self.data["state"] != TeslemetryState.ONLINE:
- response = await self.api.vehicle()
- self.data["state"] = response["response"]["state"]
-
- if self.data["state"] != TeslemetryState.ONLINE:
- return self.data
-
- response = await self.api.vehicle_data(endpoints=ENDPOINTS)
- data = response["response"]
-
- except VehicleOffline:
- self.data["state"] = TeslemetryState.OFFLINE
- return self.data
- except InvalidToken as e:
- raise ConfigEntryAuthFailed from e
- except SubscriptionRequired as e:
+ data = (await self.api.vehicle_data(endpoints=ENDPOINTS))["response"]
+ except (InvalidToken, SubscriptionRequired) as e:
raise ConfigEntryAuthFailed from e
except TeslaFleetError as e:
raise UpdateFailed(e.message) from e
- self.updated_once = True
-
- if self.api.pre2021 and data["state"] == TeslemetryState.ONLINE:
- # Handle pre-2021 vehicles which cannot sleep by themselves
- if (
- data["charge_state"].get("charging_state") == "Charging"
- or data["vehicle_state"].get("is_user_present")
- or data["vehicle_state"].get("sentry_mode")
- ):
- # Vehicle is active, reset timer
- self.last_active = datetime.now()
- else:
- elapsed = datetime.now() - self.last_active
- if elapsed > timedelta(minutes=20):
- # Vehicle didn't sleep, try again in 15 minutes
- self.last_active = datetime.now()
- elif elapsed > timedelta(minutes=15):
- # Let vehicle go to sleep now
- self.update_interval = VEHICLE_WAIT
-
return flatten(data)
class TeslemetryEnergySiteLiveCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"""Class to manage fetching energy site live status from the Teslemetry API."""
- updated_once: bool
-
def __init__(self, hass: HomeAssistant, api: EnergySpecific) -> None:
"""Initialize Teslemetry Energy Site Live coordinator."""
super().__init__(
@@ -126,7 +84,7 @@ class TeslemetryEnergySiteLiveCoordinator(DataUpdateCoordinator[dict[str, Any]])
try:
data = (await self.api.live_status())["response"]
- except (InvalidToken, Forbidden, SubscriptionRequired) as e:
+ except (InvalidToken, SubscriptionRequired) as e:
raise ConfigEntryAuthFailed from e
except TeslaFleetError as e:
raise UpdateFailed(e.message) from e
@@ -142,8 +100,6 @@ class TeslemetryEnergySiteLiveCoordinator(DataUpdateCoordinator[dict[str, Any]])
class TeslemetryEnergySiteInfoCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"""Class to manage fetching energy site info from the Teslemetry API."""
- updated_once: bool
-
def __init__(self, hass: HomeAssistant, api: EnergySpecific, product: dict) -> None:
"""Initialize Teslemetry Energy Info coordinator."""
super().__init__(
@@ -160,7 +116,7 @@ class TeslemetryEnergySiteInfoCoordinator(DataUpdateCoordinator[dict[str, Any]])
try:
data = (await self.api.site_info())["response"]
- except (InvalidToken, Forbidden, SubscriptionRequired) as e:
+ except (InvalidToken, SubscriptionRequired) as e:
raise ConfigEntryAuthFailed from e
except TeslaFleetError as e:
raise UpdateFailed(e.message) from e
@@ -171,8 +127,6 @@ class TeslemetryEnergySiteInfoCoordinator(DataUpdateCoordinator[dict[str, Any]])
class TeslemetryEnergyHistoryCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"""Class to manage fetching energy site info from the Teslemetry API."""
- updated_once: bool
-
def __init__(self, hass: HomeAssistant, api: EnergySpecific) -> None:
"""Initialize Teslemetry Energy Info coordinator."""
super().__init__(
@@ -188,13 +142,11 @@ class TeslemetryEnergyHistoryCoordinator(DataUpdateCoordinator[dict[str, Any]]):
try:
data = (await self.api.energy_history(TeslaEnergyPeriod.DAY))["response"]
- except (InvalidToken, Forbidden, SubscriptionRequired) as e:
+ except (InvalidToken, SubscriptionRequired) 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", []):
diff --git a/homeassistant/components/teslemetry/cover.py b/homeassistant/components/teslemetry/cover.py
index 8775da931d5..d14ef385b9c 100644
--- a/homeassistant/components/teslemetry/cover.py
+++ b/homeassistant/components/teslemetry/cover.py
@@ -73,9 +73,6 @@ class TeslemetryWindowEntity(TeslemetryVehicleEntity, CoverEntity):
# All closed set to closed
elif CLOSED == fd == fp == rd == rp:
self._attr_is_closed = True
- # Otherwise, set to unknown
- else:
- self._attr_is_closed = None
async def async_open_cover(self, **kwargs: Any) -> None:
"""Vent windows."""
diff --git a/homeassistant/components/teslemetry/lock.py b/homeassistant/components/teslemetry/lock.py
index 0a7a557ed88..4600391145b 100644
--- a/homeassistant/components/teslemetry/lock.py
+++ b/homeassistant/components/teslemetry/lock.py
@@ -82,8 +82,6 @@ class TeslemetryCableLockEntity(TeslemetryVehicleEntity, LockEntity):
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
async def async_lock(self, **kwargs: Any) -> None:
diff --git a/homeassistant/components/teslemetry/manifest.json b/homeassistant/components/teslemetry/manifest.json
index 6b667094d62..a2782d25393 100644
--- a/homeassistant/components/teslemetry/manifest.json
+++ b/homeassistant/components/teslemetry/manifest.json
@@ -6,6 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/teslemetry",
"iot_class": "cloud_polling",
"loggers": ["tesla-fleet-api"],
- "quality_scale": "platinum",
- "requirements": ["tesla-fleet-api==0.8.4", "teslemetry-stream==0.4.2"]
+ "requirements": ["tesla-fleet-api==0.9.2", "teslemetry-stream==0.4.2"]
}
diff --git a/homeassistant/components/teslemetry/select.py b/homeassistant/components/teslemetry/select.py
index 192e2b194a8..baf1d80ac6c 100644
--- a/homeassistant/components/teslemetry/select.py
+++ b/homeassistant/components/teslemetry/select.py
@@ -90,10 +90,12 @@ async def async_setup_entry(
)
for description in SEAT_HEATER_DESCRIPTIONS
for vehicle in entry.runtime_data.vehicles
+ if description.key in vehicle.coordinator.data
),
(
TeslemetryWheelHeaterSelectEntity(vehicle, entry.runtime_data.scopes)
for vehicle in entry.runtime_data.vehicles
+ if vehicle.coordinator.data.get("climate_state_steering_wheel_heater")
),
(
TeslemetryOperationSelectEntity(energysite, entry.runtime_data.scopes)
@@ -137,7 +139,7 @@ class TeslemetrySeatHeaterSelectEntity(TeslemetryVehicleEntity, SelectEntity):
"""Handle updated data from the coordinator."""
self._attr_available = self.entity_description.available_fn(self)
value = self._value
- if value is None:
+ if not isinstance(value, int):
self._attr_current_option = None
else:
self._attr_current_option = self._attr_options[value]
@@ -182,7 +184,7 @@ class TeslemetryWheelHeaterSelectEntity(TeslemetryVehicleEntity, SelectEntity):
"""Handle updated data from the coordinator."""
value = self._value
- if value is None:
+ if not isinstance(value, int):
self._attr_current_option = None
else:
self._attr_current_option = self._attr_options[value]
diff --git a/homeassistant/components/teslemetry/switch.py b/homeassistant/components/teslemetry/switch.py
index 91ef3074bae..6a1cff4c5da 100644
--- a/homeassistant/components/teslemetry/switch.py
+++ b/homeassistant/components/teslemetry/switch.py
@@ -102,6 +102,7 @@ async def async_setup_entry(
)
for vehicle in entry.runtime_data.vehicles
for description in VEHICLE_DESCRIPTIONS
+ if description.key in vehicle.coordinator.data
),
(
TeslemetryChargeSwitchEntity(
@@ -150,10 +151,7 @@ class TeslemetryVehicleSwitchEntity(TeslemetryVehicleEntity, TeslemetrySwitchEnt
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 = bool(self._value)
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn on the Switch."""
diff --git a/homeassistant/components/tessie/climate.py b/homeassistant/components/tessie/climate.py
index e0649432e05..1d26926aeaa 100644
--- a/homeassistant/components/tessie/climate.py
+++ b/homeassistant/components/tessie/climate.py
@@ -60,7 +60,6 @@ class TessieClimateEntity(TessieEntity, ClimateEntity):
TessieClimateKeeper.DOG,
TessieClimateKeeper.CAMP,
]
- _enable_turn_on_off_backwards_compatibility = False
def __init__(
self,
diff --git a/homeassistant/components/tessie/const.py b/homeassistant/components/tessie/const.py
index 90862eff969..4731f5168a2 100644
--- a/homeassistant/components/tessie/const.py
+++ b/homeassistant/components/tessie/const.py
@@ -13,6 +13,16 @@ MODELS = {
"models": "Model S",
}
+TRANSLATED_ERRORS = {
+ "unknown": "unknown",
+ "not supported": "not_supported",
+ "cable connected": "cable_connected",
+ "already active": "already_active",
+ "already inactive": "already_inactive",
+ "incorrect pin": "incorrect_pin",
+ "no cable": "no_cable",
+}
+
class TessieState(StrEnum):
"""Tessie status."""
diff --git a/homeassistant/components/tessie/entity.py b/homeassistant/components/tessie/entity.py
index 42a3c92b2be..a2b6d3c9761 100644
--- a/homeassistant/components/tessie/entity.py
+++ b/homeassistant/components/tessie/entity.py
@@ -10,7 +10,7 @@ from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
-from .const import DOMAIN
+from .const import DOMAIN, TRANSLATED_ERRORS
from .coordinator import (
TessieEnergySiteInfoCoordinator,
TessieEnergySiteLiveCoordinator,
@@ -107,10 +107,11 @@ class TessieEntity(TessieBaseEntity):
if response["result"] is False:
name: str = getattr(self, "name", self.entity_id)
reason: str = response.get("reason", "unknown")
+ translation_key = TRANSLATED_ERRORS.get(reason, "command_failed")
raise HomeAssistantError(
translation_domain=DOMAIN,
- translation_key=reason.replace(" ", "_"),
- translation_placeholders={"name": name},
+ translation_key=translation_key,
+ translation_placeholders={"name": name, "message": reason},
)
def _async_update_attrs(self) -> None:
diff --git a/homeassistant/components/tessie/manifest.json b/homeassistant/components/tessie/manifest.json
index 92aa289ca47..8f7c9890664 100644
--- a/homeassistant/components/tessie/manifest.json
+++ b/homeassistant/components/tessie/manifest.json
@@ -6,6 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/tessie",
"iot_class": "cloud_polling",
"loggers": ["tessie", "tesla-fleet-api"],
- "quality_scale": "platinum",
- "requirements": ["tessie-api==0.1.1", "tesla-fleet-api==0.8.4"]
+ "requirements": ["tessie-api==0.1.1", "tesla-fleet-api==0.9.2"]
}
diff --git a/homeassistant/components/tessie/strings.json b/homeassistant/components/tessie/strings.json
index 5b677594b42..4ac645a0270 100644
--- a/homeassistant/components/tessie/strings.json
+++ b/homeassistant/components/tessie/strings.json
@@ -521,7 +521,7 @@
"message": "{name} is already inactive."
},
"incorrect_pin": {
- "message": "Incorrect pin for {name}."
+ "message": "Incorrect PIN for {name}."
},
"no_cable": {
"message": "Insert cable to lock"
diff --git a/homeassistant/components/tfiac/climate.py b/homeassistant/components/tfiac/climate.py
index 81517a6f1f5..e3aa9060787 100644
--- a/homeassistant/components/tfiac/climate.py
+++ b/homeassistant/components/tfiac/climate.py
@@ -88,7 +88,6 @@ class TfiacClimate(ClimateEntity):
| ClimateEntityFeature.TURN_ON
)
_attr_temperature_unit = UnitOfTemperature.FAHRENHEIT
- _enable_turn_on_off_backwards_compatibility = False
def __init__(self, hass, client):
"""Init class."""
diff --git a/homeassistant/components/tfiac/manifest.json b/homeassistant/components/tfiac/manifest.json
index 243710241a2..94f82c99d21 100644
--- a/homeassistant/components/tfiac/manifest.json
+++ b/homeassistant/components/tfiac/manifest.json
@@ -5,5 +5,6 @@
"disabled": "This integration is disabled because we cannot build a valid wheel.",
"documentation": "https://www.home-assistant.io/integrations/tfiac",
"iot_class": "local_polling",
+ "quality_scale": "legacy",
"requirements": ["pytfiac==0.4"]
}
diff --git a/homeassistant/components/thermoworks_smoke/manifest.json b/homeassistant/components/thermoworks_smoke/manifest.json
index 7baec9cdb74..f67b041b1e5 100644
--- a/homeassistant/components/thermoworks_smoke/manifest.json
+++ b/homeassistant/components/thermoworks_smoke/manifest.json
@@ -6,5 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/thermoworks_smoke",
"iot_class": "cloud_polling",
"loggers": ["thermoworks_smoke"],
+ "quality_scale": "legacy",
"requirements": ["stringcase==1.2.0", "thermoworks-smoke==0.1.8"]
}
diff --git a/homeassistant/components/thethingsnetwork/__init__.py b/homeassistant/components/thethingsnetwork/__init__.py
index 253ce7a052e..d3c6c8356cb 100644
--- a/homeassistant/components/thethingsnetwork/__init__.py
+++ b/homeassistant/components/thethingsnetwork/__init__.py
@@ -2,55 +2,15 @@
import logging
-import voluptuous as vol
-
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_KEY, CONF_HOST
from homeassistant.core import HomeAssistant
-from homeassistant.helpers import issue_registry as ir
-import homeassistant.helpers.config_validation as cv
-from homeassistant.helpers.typing import ConfigType
-from .const import CONF_APP_ID, DOMAIN, PLATFORMS, TTN_API_HOST
+from .const import DOMAIN, PLATFORMS, TTN_API_HOST
from .coordinator import TTNCoordinator
_LOGGER = logging.getLogger(__name__)
-CONFIG_SCHEMA = vol.Schema(
- {
- # Configuration via yaml not longer supported - keeping to warn about migration
- DOMAIN: vol.Schema(
- {
- vol.Required(CONF_APP_ID): cv.string,
- vol.Required("access_key"): cv.string,
- }
- )
- },
- extra=vol.ALLOW_EXTRA,
-)
-
-
-async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
- """Initialize of The Things Network component."""
-
- if DOMAIN in config:
- ir.async_create_issue(
- hass,
- DOMAIN,
- "manual_migration",
- breaks_in_ha_version="2024.12.0",
- is_fixable=False,
- severity=ir.IssueSeverity.ERROR,
- translation_key="manual_migration",
- translation_placeholders={
- "domain": DOMAIN,
- "v2_v3_migration_url": "https://www.thethingsnetwork.org/forum/c/v2-to-v3-upgrade/102",
- "v2_deprecation_url": "https://www.thethingsnetwork.org/forum/t/the-things-network-v2-is-permanently-shutting-down-completed/50710",
- },
- )
-
- return True
-
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Establish connection with The Things Network."""
diff --git a/homeassistant/components/thethingsnetwork/strings.json b/homeassistant/components/thethingsnetwork/strings.json
index 98572cb318c..f5a4fcef8fd 100644
--- a/homeassistant/components/thethingsnetwork/strings.json
+++ b/homeassistant/components/thethingsnetwork/strings.json
@@ -22,11 +22,5 @@
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
}
- },
- "issues": {
- "manual_migration": {
- "description": "Configuring {domain} using YAML was removed as part of migrating to [The Things Network v3]({v2_v3_migration_url}). [The Things Network v2 has shutted down]({v2_deprecation_url}).\n\nPlease remove the {domain} entry from the configuration.yaml and add re-add the integration using the config_flow",
- "title": "The {domain} YAML configuration is not supported"
- }
}
}
diff --git a/homeassistant/components/thingspeak/manifest.json b/homeassistant/components/thingspeak/manifest.json
index ffdc11d9214..aac0ca06426 100644
--- a/homeassistant/components/thingspeak/manifest.json
+++ b/homeassistant/components/thingspeak/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/thingspeak",
"iot_class": "cloud_push",
"loggers": ["thingspeak"],
+ "quality_scale": "legacy",
"requirements": ["thingspeak==1.0.0"]
}
diff --git a/homeassistant/components/thinkingcleaner/manifest.json b/homeassistant/components/thinkingcleaner/manifest.json
index f480340fcf8..048fcfffa05 100644
--- a/homeassistant/components/thinkingcleaner/manifest.json
+++ b/homeassistant/components/thinkingcleaner/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/thinkingcleaner",
"iot_class": "local_polling",
"loggers": ["pythinkingcleaner"],
+ "quality_scale": "legacy",
"requirements": ["pythinkingcleaner==0.0.3"]
}
diff --git a/homeassistant/components/thomson/manifest.json b/homeassistant/components/thomson/manifest.json
index 08961cb2746..7f49b57d724 100644
--- a/homeassistant/components/thomson/manifest.json
+++ b/homeassistant/components/thomson/manifest.json
@@ -3,5 +3,6 @@
"name": "Thomson",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/thomson",
- "iot_class": "local_polling"
+ "iot_class": "local_polling",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/threshold/binary_sensor.py b/homeassistant/components/threshold/binary_sensor.py
index da7d92f7051..3d52d2225be 100644
--- a/homeassistant/components/threshold/binary_sensor.py
+++ b/homeassistant/components/threshold/binary_sensor.py
@@ -61,15 +61,29 @@ _LOGGER = logging.getLogger(__name__)
DEFAULT_NAME: Final = "Threshold"
-PLATFORM_SCHEMA = BINARY_SENSOR_PLATFORM_SCHEMA.extend(
- {
- vol.Required(CONF_ENTITY_ID): cv.entity_id,
- vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA,
- vol.Optional(CONF_HYSTERESIS, default=DEFAULT_HYSTERESIS): vol.Coerce(float),
- vol.Optional(CONF_LOWER): vol.Coerce(float),
- vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
- vol.Optional(CONF_UPPER): vol.Coerce(float),
- }
+
+def no_missing_threshold(value: dict) -> dict:
+ """Validate data point list is greater than polynomial degrees."""
+ if value.get(CONF_LOWER) is None and value.get(CONF_UPPER) is None:
+ raise vol.Invalid("Lower or Upper thresholds are not provided")
+
+ return value
+
+
+PLATFORM_SCHEMA = vol.All(
+ BINARY_SENSOR_PLATFORM_SCHEMA.extend(
+ {
+ vol.Required(CONF_ENTITY_ID): cv.entity_id,
+ vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA,
+ vol.Optional(CONF_HYSTERESIS, default=DEFAULT_HYSTERESIS): vol.Coerce(
+ float
+ ),
+ vol.Optional(CONF_LOWER): vol.Coerce(float),
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Optional(CONF_UPPER): vol.Coerce(float),
+ }
+ ),
+ no_missing_threshold,
)
@@ -126,9 +140,6 @@ async def async_setup_platform(
hysteresis: float = config[CONF_HYSTERESIS]
device_class: BinarySensorDeviceClass | None = config.get(CONF_DEVICE_CLASS)
- if lower is None and upper is None:
- raise ValueError("Lower or Upper thresholds not provided")
-
async_add_entities(
[
ThresholdSensor(
diff --git a/homeassistant/components/threshold/strings.json b/homeassistant/components/threshold/strings.json
index fc9ee8fb7bf..94a1932cbbc 100644
--- a/homeassistant/components/threshold/strings.json
+++ b/homeassistant/components/threshold/strings.json
@@ -3,7 +3,7 @@
"config": {
"step": {
"user": {
- "title": "Add Threshold Sensor",
+ "title": "Create Threshold Sensor",
"description": "Create a binary sensor that turns on and off depending on the value of a sensor\n\nOnly lower limit configured - Turn on when the input sensor's value is less than the lower limit.\nOnly upper limit configured - Turn on when the input sensor's value is greater than the upper limit.\nBoth lower and upper limit configured - Turn on when the input sensor's value is in the range [lower limit .. upper limit].",
"data": {
"entity_id": "Input sensor",
diff --git a/homeassistant/components/tibber/__init__.py b/homeassistant/components/tibber/__init__.py
index ce05b8070f6..9b5c7ee1168 100644
--- a/homeassistant/components/tibber/__init__.py
+++ b/homeassistant/components/tibber/__init__.py
@@ -6,15 +6,9 @@ import aiohttp
import tibber
from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import (
- CONF_ACCESS_TOKEN,
- CONF_NAME,
- EVENT_HOMEASSISTANT_STOP,
- Platform,
-)
+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 discovery
from homeassistant.helpers.aiohttp_client import async_get_clientsession
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.typing import ConfigType
@@ -73,19 +67,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
- # Use discovery to load platform legacy notify platform
- # The use of the legacy notify service was deprecated with HA Core 2024.6
- # Support will be removed with HA Core 2024.12
- hass.async_create_task(
- discovery.async_load_platform(
- hass,
- Platform.NOTIFY,
- DOMAIN,
- {CONF_NAME: DOMAIN},
- hass.data[DATA_HASS_CONFIG],
- )
- )
-
return True
diff --git a/homeassistant/components/tibber/manifest.json b/homeassistant/components/tibber/manifest.json
index 205bc1352eb..3a3a772a934 100644
--- a/homeassistant/components/tibber/manifest.json
+++ b/homeassistant/components/tibber/manifest.json
@@ -7,6 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/tibber",
"iot_class": "cloud_polling",
"loggers": ["tibber"],
- "quality_scale": "silver",
- "requirements": ["pyTibber==0.30.4"]
+ "requirements": ["pyTibber==0.30.8"]
}
diff --git a/homeassistant/components/tibber/notify.py b/homeassistant/components/tibber/notify.py
index 1c9f86ed502..fdeeeba68ef 100644
--- a/homeassistant/components/tibber/notify.py
+++ b/homeassistant/components/tibber/notify.py
@@ -2,38 +2,21 @@
from __future__ import annotations
-from collections.abc import Callable
-from typing import Any
-
from tibber import Tibber
from homeassistant.components.notify import (
- ATTR_TITLE,
ATTR_TITLE_DEFAULT,
- BaseNotificationService,
NotifyEntity,
NotifyEntityFeature,
- migrate_notify_issue,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from . import DOMAIN as TIBBER_DOMAIN
-async def async_get_service(
- hass: HomeAssistant,
- config: ConfigType,
- discovery_info: DiscoveryInfoType | None = None,
-) -> TibberNotificationService:
- """Get the Tibber notification service."""
- tibber_connection: Tibber = hass.data[TIBBER_DOMAIN]
- return TibberNotificationService(tibber_connection.send_notification)
-
-
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
@@ -41,31 +24,6 @@ async def async_setup_entry(
async_add_entities([TibberNotificationEntity(entry.entry_id)])
-class TibberNotificationService(BaseNotificationService):
- """Implement the notification service for Tibber."""
-
- def __init__(self, notify: Callable) -> None:
- """Initialize the service."""
- self._notify = notify
-
- async def async_send_message(self, message: str = "", **kwargs: Any) -> None:
- """Send a message to Tibber devices."""
- migrate_notify_issue(
- self.hass,
- TIBBER_DOMAIN,
- "Tibber",
- "2024.12.0",
- service_name=self._service_name,
- )
- title = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT)
- try:
- await self._notify(title=title, message=message)
- except TimeoutError as exc:
- raise HomeAssistantError(
- translation_domain=TIBBER_DOMAIN, translation_key="send_message_timeout"
- ) from exc
-
-
class TibberNotificationEntity(NotifyEntity):
"""Implement the notification entity service for Tibber."""
diff --git a/homeassistant/components/tibber/sensor.py b/homeassistant/components/tibber/sensor.py
index 125dc8eae6f..c1ec7bf2a9e 100644
--- a/homeassistant/components/tibber/sensor.py
+++ b/homeassistant/components/tibber/sensor.py
@@ -397,7 +397,7 @@ class TibberSensorElPrice(TibberSensor):
if (
not self._tibber_home.last_data_timestamp
or (self._tibber_home.last_data_timestamp - now).total_seconds()
- < 11 * 3600 + self._spread_load_constant
+ < 10 * 3600 - self._spread_load_constant
or not self.available
):
_LOGGER.debug("Asking for new data")
diff --git a/homeassistant/components/tibber/services.py b/homeassistant/components/tibber/services.py
index 72943a0215a..938e96b9917 100644
--- a/homeassistant/components/tibber/services.py
+++ b/homeassistant/components/tibber/services.py
@@ -4,7 +4,6 @@ from __future__ import annotations
import datetime as dt
from datetime import datetime
-from functools import partial
from typing import Any, Final
import voluptuous as vol
@@ -33,8 +32,8 @@ SERVICE_SCHEMA: Final = vol.Schema(
)
-async def __get_prices(call: ServiceCall, *, hass: HomeAssistant) -> ServiceResponse:
- tibber_connection = hass.data[DOMAIN]
+async def __get_prices(call: ServiceCall) -> ServiceResponse:
+ tibber_connection = call.hass.data[DOMAIN]
start = __get_date(call.data.get(ATTR_START), "start")
end = __get_date(call.data.get(ATTR_END), "end")
@@ -79,7 +78,6 @@ def __get_date(date_input: str | None, mode: str | None) -> datetime:
return dt_util.as_local(value)
raise ServiceValidationError(
- "Invalid datetime provided.",
translation_domain=DOMAIN,
translation_key="invalid_date",
translation_placeholders={
@@ -95,7 +93,7 @@ def async_setup_services(hass: HomeAssistant) -> None:
hass.services.async_register(
DOMAIN,
PRICE_SERVICE_NAME,
- partial(__get_prices, hass=hass),
+ __get_prices,
schema=SERVICE_SCHEMA,
supports_response=SupportsResponse.ONLY,
)
diff --git a/homeassistant/components/tibber/strings.json b/homeassistant/components/tibber/strings.json
index 8d73d435c8c..05b98b97995 100644
--- a/homeassistant/components/tibber/strings.json
+++ b/homeassistant/components/tibber/strings.json
@@ -119,6 +119,9 @@
}
},
"exceptions": {
+ "invalid_date": {
+ "message": "Invalid datetime provided {date}"
+ },
"send_message_timeout": {
"message": "Timeout sending message with Tibber"
}
diff --git a/homeassistant/components/tikteck/manifest.json b/homeassistant/components/tikteck/manifest.json
index 067dd6f92cf..57e5269d3b0 100644
--- a/homeassistant/components/tikteck/manifest.json
+++ b/homeassistant/components/tikteck/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/tikteck",
"iot_class": "local_polling",
"loggers": ["tikteck"],
+ "quality_scale": "legacy",
"requirements": ["tikteck==0.4"]
}
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/manifest.json b/homeassistant/components/tile/manifest.json
index 8dceddcb77f..f8acbc0bf1a 100644
--- a/homeassistant/components/tile/manifest.json
+++ b/homeassistant/components/tile/manifest.json
@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["pytile"],
- "requirements": ["pytile==2023.12.0"]
+ "requirements": ["pytile==2024.12.0"]
}
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/timer/strings.json b/homeassistant/components/timer/strings.json
index 064ec81df1d..4fd80f565a2 100644
--- a/homeassistant/components/timer/strings.json
+++ b/homeassistant/components/timer/strings.json
@@ -34,33 +34,33 @@
"services": {
"start": {
"name": "[%key:common::action::start%]",
- "description": "Starts a timer.",
+ "description": "Starts a timer or restarts it with a provided duration.",
"fields": {
"duration": {
"name": "Duration",
- "description": "Duration the timer requires to finish. [optional]."
+ "description": "Custom duration to restart the timer with."
}
}
},
"pause": {
"name": "[%key:common::action::pause%]",
- "description": "Pauses a timer."
+ "description": "Pauses a running timer, retaining the remaining duration for later continuation."
},
"cancel": {
"name": "Cancel",
- "description": "Cancels a timer."
+ "description": "Resets a timer's duration to the last known initial value without firing the timer finished event."
},
"finish": {
"name": "Finish",
- "description": "Finishes a timer."
+ "description": "Finishes a running timer earlier than scheduled."
},
"change": {
"name": "Change",
- "description": "Changes a timer.",
+ "description": "Changes a timer by adding or subtracting a given duration.",
"fields": {
"duration": {
"name": "Duration",
- "description": "Duration to add or subtract to the running timer."
+ "description": "Duration to add to or subtract from the running timer."
}
}
},
diff --git a/homeassistant/components/tmb/manifest.json b/homeassistant/components/tmb/manifest.json
index 16efc870504..0e0324a62f4 100644
--- a/homeassistant/components/tmb/manifest.json
+++ b/homeassistant/components/tmb/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/tmb",
"iot_class": "local_polling",
"loggers": ["tmb"],
+ "quality_scale": "legacy",
"requirements": ["tmb==0.0.4"]
}
diff --git a/homeassistant/components/tod/strings.json b/homeassistant/components/tod/strings.json
index bd4a48df915..c32b996c29a 100644
--- a/homeassistant/components/tod/strings.json
+++ b/homeassistant/components/tod/strings.json
@@ -3,7 +3,7 @@
"config": {
"step": {
"user": {
- "title": "Add Times of the Day Sensor",
+ "title": "Create Times of the Day Sensor",
"description": "Create a binary sensor that turns on or off depending on the time.",
"data": {
"after_time": "On time",
diff --git a/homeassistant/components/todo/strings.json b/homeassistant/components/todo/strings.json
index 717aa310ecd..cffb22e89f0 100644
--- a/homeassistant/components/todo/strings.json
+++ b/homeassistant/components/todo/strings.json
@@ -7,8 +7,8 @@
},
"services": {
"get_items": {
- "name": "Get to-do list items",
- "description": "Get items on a to-do list.",
+ "name": "Get items",
+ "description": "Gets items on a to-do list.",
"fields": {
"status": {
"name": "Status",
@@ -17,8 +17,8 @@
}
},
"add_item": {
- "name": "Add to-do list item",
- "description": "Add a new to-do list item.",
+ "name": "Add item",
+ "description": "Adds a new to-do list item.",
"fields": {
"item": {
"name": "Item name",
@@ -39,16 +39,16 @@
}
},
"update_item": {
- "name": "Update to-do list item",
- "description": "Update an existing to-do list item based on its name.",
+ "name": "Update item",
+ "description": "Updates an existing to-do list item based on its name.",
"fields": {
"item": {
"name": "Item name",
- "description": "The name for the to-do list item."
+ "description": "The current name of the to-do item."
},
"rename": {
"name": "Rename item",
- "description": "The new name of the to-do item"
+ "description": "The new name for the to-do item"
},
"status": {
"name": "Set status",
@@ -69,16 +69,16 @@
}
},
"remove_completed_items": {
- "name": "Remove all completed to-do list items",
- "description": "Remove all to-do list items that have been completed."
+ "name": "Remove completed items",
+ "description": "Removes all to-do list items that have been completed."
},
"remove_item": {
- "name": "Remove a to-do list item",
- "description": "Remove an existing to-do list item by its name.",
+ "name": "Remove item",
+ "description": "Removes an existing to-do list item by its name.",
"fields": {
"item": {
"name": "Item name",
- "description": "The name for the to-do list items."
+ "description": "The name for the to-do list item."
}
}
}
diff --git a/homeassistant/components/todoist/strings.json b/homeassistant/components/todoist/strings.json
index 5b083ac58bf..721b491bbf5 100644
--- a/homeassistant/components/todoist/strings.json
+++ b/homeassistant/components/todoist/strings.json
@@ -78,7 +78,7 @@
"description": "When should user be reminded of this task, in natural language."
},
"reminder_date_lang": {
- "name": "Reminder data language",
+ "name": "Reminder date language",
"description": "The language of reminder_date_string."
},
"reminder_date": {
diff --git a/homeassistant/components/tolo/climate.py b/homeassistant/components/tolo/climate.py
index 8c5176b3e4e..5e6428525c1 100644
--- a/homeassistant/components/tolo/climate.py
+++ b/homeassistant/components/tolo/climate.py
@@ -60,7 +60,6 @@ class SaunaClimate(ToloSaunaCoordinatorEntity, ClimateEntity):
)
_attr_target_temperature_step = 1
_attr_temperature_unit = UnitOfTemperature.CELSIUS
- _enable_turn_on_off_backwards_compatibility = False
def __init__(
self, coordinator: ToloSaunaUpdateCoordinator, entry: ConfigEntry
diff --git a/homeassistant/components/tolo/config_flow.py b/homeassistant/components/tolo/config_flow.py
index 5cf91bdc3a8..d5d7e33a5e0 100644
--- a/homeassistant/components/tolo/config_flow.py
+++ b/homeassistant/components/tolo/config_flow.py
@@ -23,7 +23,7 @@ class ToloSaunaConfigFlow(ConfigFlow, domain=DOMAIN):
VERSION = 1
- _discovered_host: str | None = None
+ _discovered_host: str
@staticmethod
def _check_device_availability(host: str) -> bool:
diff --git a/homeassistant/components/tolo/fan.py b/homeassistant/components/tolo/fan.py
index 9b62346a83b..9e48778b507 100644
--- a/homeassistant/components/tolo/fan.py
+++ b/homeassistant/components/tolo/fan.py
@@ -29,7 +29,6 @@ class ToloFan(ToloSaunaCoordinatorEntity, FanEntity):
_attr_translation_key = "fan"
_attr_supported_features = FanEntityFeature.TURN_OFF | FanEntityFeature.TURN_ON
- _enable_turn_on_off_backwards_compatibility = False
def __init__(
self, coordinator: ToloSaunaUpdateCoordinator, entry: ConfigEntry
diff --git a/homeassistant/components/tomato/manifest.json b/homeassistant/components/tomato/manifest.json
index 6db69d50d82..081d55bc46d 100644
--- a/homeassistant/components/tomato/manifest.json
+++ b/homeassistant/components/tomato/manifest.json
@@ -3,5 +3,6 @@
"name": "Tomato",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/tomato",
- "iot_class": "local_polling"
+ "iot_class": "local_polling",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/toon/climate.py b/homeassistant/components/toon/climate.py
index 365706ba4fd..0c2e5b9b232 100644
--- a/homeassistant/components/toon/climate.py
+++ b/homeassistant/components/toon/climate.py
@@ -52,7 +52,6 @@ class ToonThermostatDevice(ToonDisplayDeviceEntity, ClimateEntity):
ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE
)
_attr_temperature_unit = UnitOfTemperature.CELSIUS
- _enable_turn_on_off_backwards_compatibility = False
def __init__(
self,
diff --git a/homeassistant/components/toon/strings.json b/homeassistant/components/toon/strings.json
index ed29e77a58c..3072896653d 100644
--- a/homeassistant/components/toon/strings.json
+++ b/homeassistant/components/toon/strings.json
@@ -16,6 +16,7 @@
"already_configured": "The selected agreement is already configured.",
"unknown_authorize_url_generation": "[%key:common::config_flow::abort::unknown_authorize_url_generation%]",
"authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]",
+ "connection_error": "[%key:common::config_flow::error::cannot_connect%]",
"missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]",
"no_agreements": "This account has no Toon displays.",
"no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]",
diff --git a/homeassistant/components/torque/manifest.json b/homeassistant/components/torque/manifest.json
index b966365bdd4..44047c67dd2 100644
--- a/homeassistant/components/torque/manifest.json
+++ b/homeassistant/components/torque/manifest.json
@@ -4,5 +4,6 @@
"codeowners": [],
"dependencies": ["http"],
"documentation": "https://www.home-assistant.io/integrations/torque",
- "iot_class": "local_push"
+ "iot_class": "local_push",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/totalconnect/__init__.py b/homeassistant/components/totalconnect/__init__.py
index 0d8b915770a..9f291ea15a6 100644
--- a/homeassistant/components/totalconnect/__init__.py
+++ b/homeassistant/components/totalconnect/__init__.py
@@ -8,13 +8,17 @@ from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
-from .const import AUTO_BYPASS, CONF_USERCODES, DOMAIN
+from .const import AUTO_BYPASS, CONF_USERCODES
from .coordinator import TotalConnectDataUpdateCoordinator
PLATFORMS = [Platform.ALARM_CONTROL_PANEL, Platform.BINARY_SENSOR, Platform.BUTTON]
+type TotalConnectConfigEntry = ConfigEntry[TotalConnectDataUpdateCoordinator]
-async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+
+async def async_setup_entry(
+ hass: HomeAssistant, entry: TotalConnectConfigEntry
+) -> bool:
"""Set up upon config entry in user interface."""
conf = entry.data
username = conf[CONF_USERNAME]
@@ -40,8 +44,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
coordinator = TotalConnectDataUpdateCoordinator(hass, client)
await coordinator.async_config_entry_first_refresh()
- hass.data.setdefault(DOMAIN, {})
- hass.data[DOMAIN][entry.entry_id] = coordinator
+ entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
entry.async_on_unload(entry.add_update_listener(update_listener))
@@ -49,18 +52,16 @@ 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: TotalConnectConfigEntry
+) -> 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)
-async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
+async def update_listener(hass: HomeAssistant, entry: TotalConnectConfigEntry) -> None:
"""Update listener."""
bypass = entry.options.get(AUTO_BYPASS, False)
- client = hass.data[DOMAIN][entry.entry_id].client
+ client = entry.runtime_data.client
for location_id in client.locations:
client.locations[location_id].auto_bypass_low_battery = bypass
diff --git a/homeassistant/components/totalconnect/alarm_control_panel.py b/homeassistant/components/totalconnect/alarm_control_panel.py
index bc33129a741..48ba78acc92 100644
--- a/homeassistant/components/totalconnect/alarm_control_panel.py
+++ b/homeassistant/components/totalconnect/alarm_control_panel.py
@@ -30,7 +30,7 @@ async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Set up TotalConnect alarm panels based on a config entry."""
- coordinator: TotalConnectDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
+ coordinator = entry.runtime_data
code_required = entry.options.get(CODE_REQUIRED, False)
async_add_entities(
diff --git a/homeassistant/components/totalconnect/binary_sensor.py b/homeassistant/components/totalconnect/binary_sensor.py
index 3126efff88a..9a3c2558999 100644
--- a/homeassistant/components/totalconnect/binary_sensor.py
+++ b/homeassistant/components/totalconnect/binary_sensor.py
@@ -17,7 +17,6 @@ from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from .const import DOMAIN
from .coordinator import TotalConnectDataUpdateCoordinator
from .entity import TotalConnectLocationEntity, TotalConnectZoneEntity
@@ -125,7 +124,7 @@ async def async_setup_entry(
"""Set up TotalConnect device sensors based on a config entry."""
sensors: list = []
- coordinator: TotalConnectDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
+ coordinator = entry.runtime_data
client_locations = coordinator.client.locations
diff --git a/homeassistant/components/totalconnect/button.py b/homeassistant/components/totalconnect/button.py
index fc5b5e89587..e228f03ec6b 100644
--- a/homeassistant/components/totalconnect/button.py
+++ b/homeassistant/components/totalconnect/button.py
@@ -12,7 +12,6 @@ from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from .const import DOMAIN
from .coordinator import TotalConnectDataUpdateCoordinator
from .entity import TotalConnectLocationEntity, TotalConnectZoneEntity
@@ -43,7 +42,7 @@ async def async_setup_entry(
) -> None:
"""Set up TotalConnect buttons based on a config entry."""
buttons: list = []
- coordinator: TotalConnectDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
+ coordinator = entry.runtime_data
for location_id, location in coordinator.client.locations.items():
buttons.extend(
diff --git a/homeassistant/components/totalconnect/diagnostics.py b/homeassistant/components/totalconnect/diagnostics.py
index b590c54e2ba..85f52ccc670 100644
--- a/homeassistant/components/totalconnect/diagnostics.py
+++ b/homeassistant/components/totalconnect/diagnostics.py
@@ -8,8 +8,6 @@ from homeassistant.components.diagnostics import async_redact_data
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from .const import DOMAIN
-
TO_REDACT = [
"username",
"Password",
@@ -27,7 +25,7 @@ async def async_get_config_entry_diagnostics(
hass: HomeAssistant, config_entry: ConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
- client = hass.data[DOMAIN][config_entry.entry_id].client
+ client = config_entry.runtime_data.client
data: dict[str, Any] = {}
data["client"] = {
diff --git a/homeassistant/components/totalconnect/manifest.json b/homeassistant/components/totalconnect/manifest.json
index 87ec14621d9..33306a7adba 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.5"]
+ "requirements": ["total-connect-client==2024.12"]
}
diff --git a/homeassistant/components/totalconnect/quality_scale.yaml b/homeassistant/components/totalconnect/quality_scale.yaml
new file mode 100644
index 00000000000..606f1b3b6c3
--- /dev/null
+++ b/homeassistant/components/totalconnect/quality_scale.yaml
@@ -0,0 +1,62 @@
+rules:
+ # Bronze
+ config-flow: done
+ test-before-configure: done
+ unique-config-entry: done
+ config-flow-test-coverage: todo
+ runtime-data: done
+ test-before-setup: done
+ appropriate-polling: done
+ entity-unique-id: done
+ has-entity-name: done
+ entity-event-setup: todo
+ dependency-transparency: done
+ action-setup: todo
+ common-modules: done
+ docs-high-level-description: done
+ docs-installation-instructions: done
+ docs-removal-instructions: todo
+ docs-actions: done
+ brands: done
+
+ # Silver
+ config-entry-unloading: done
+ log-when-unavailable: todo
+ entity-unavailable: todo
+ action-exceptions: todo
+ reauthentication-flow: done
+ parallel-updates: todo
+ test-coverage: done
+ integration-owner: done
+ docs-installation-parameters: done
+ docs-configuration-parameters: done
+
+ # Gold
+ entity-translations: done
+ entity-device-class: done
+ devices: done
+ entity-category: done
+ entity-disabled-by-default: done
+ discovery: todo
+ stale-devices: todo
+ diagnostics: done
+ exception-translations: todo
+ icon-translations: todo
+ reconfiguration-flow: todo
+ dynamic-devices: todo
+ 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-examples: done
+
+ # Platinum
+ async-dependency: todo
+ inject-websession: todo
+ strict-typing: todo
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 7b14404ee34..e9d27341cb7 100644
--- a/homeassistant/components/touchline/climate.py
+++ b/homeassistant/components/touchline/climate.py
@@ -70,7 +70,6 @@ class Touchline(ClimateEntity):
ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE
)
_attr_temperature_unit = UnitOfTemperature.CELSIUS
- _enable_turn_on_off_backwards_compatibility = False
def __init__(self, touchline_thermostat):
"""Initialize the Touchline device."""
diff --git a/homeassistant/components/touchline/manifest.json b/homeassistant/components/touchline/manifest.json
index 340edb8381a..c003cca97a4 100644
--- a/homeassistant/components/touchline/manifest.json
+++ b/homeassistant/components/touchline/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/touchline",
"iot_class": "local_polling",
"loggers": ["pytouchline"],
+ "quality_scale": "legacy",
"requirements": ["pytouchline==0.7"]
}
diff --git a/homeassistant/components/touchline_sl/climate.py b/homeassistant/components/touchline_sl/climate.py
index 93328823749..8a0ffc4cd86 100644
--- a/homeassistant/components/touchline_sl/climate.py
+++ b/homeassistant/components/touchline_sl/climate.py
@@ -2,22 +2,19 @@
from typing import Any
-from pytouchlinesl import Zone
-
from homeassistant.components.climate import (
ClimateEntity,
ClimateEntityFeature,
+ HVACAction,
HVACMode,
)
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import TouchlineSLConfigEntry
-from .const import DOMAIN
from .coordinator import TouchlineSLModuleCoordinator
+from .entity import TouchlineSLZoneEntity
async def async_setup_entry(
@@ -37,10 +34,10 @@ async def async_setup_entry(
CONSTANT_TEMPERATURE = "constant_temperature"
-class TouchlineSLZone(CoordinatorEntity[TouchlineSLModuleCoordinator], ClimateEntity):
+class TouchlineSLZone(TouchlineSLZoneEntity, ClimateEntity):
"""Roth Touchline SL Zone."""
- _attr_has_entity_name = True
+ _attr_hvac_action = HVACAction.IDLE
_attr_hvac_mode = HVACMode.HEAT
_attr_hvac_modes = [HVACMode.HEAT]
_attr_name = None
@@ -52,22 +49,12 @@ class TouchlineSLZone(CoordinatorEntity[TouchlineSLModuleCoordinator], ClimateEn
def __init__(self, coordinator: TouchlineSLModuleCoordinator, zone_id: int) -> None:
"""Construct a Touchline SL climate zone."""
- super().__init__(coordinator)
- self.zone_id: int = zone_id
+ super().__init__(coordinator, zone_id)
self._attr_unique_id = (
f"module-{self.coordinator.data.module.id}-zone-{self.zone_id}"
)
- self._attr_device_info = DeviceInfo(
- identifiers={(DOMAIN, str(zone_id))},
- name=self.zone.name,
- manufacturer="Roth",
- via_device=(DOMAIN, coordinator.data.module.id),
- model="zone",
- suggested_area=self.zone.name,
- )
-
# Call this in __init__ so data is populated right away, since it's
# already available in the coordinator data.
self.set_attr()
@@ -78,16 +65,6 @@ class TouchlineSLZone(CoordinatorEntity[TouchlineSLModuleCoordinator], ClimateEn
self.set_attr()
super()._handle_coordinator_update()
- @property
- def zone(self) -> Zone:
- """Return the device object from the coordinator data."""
- return self.coordinator.data.zones[self.zone_id]
-
- @property
- def available(self) -> bool:
- """Return if the device is available."""
- return super().available and self.zone_id in self.coordinator.data.zones
-
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperature."""
if (temperature := kwargs.get(ATTR_TEMPERATURE)) is None:
@@ -124,3 +101,16 @@ class TouchlineSLZone(CoordinatorEntity[TouchlineSLModuleCoordinator], ClimateEn
elif self.zone.mode == "globalSchedule":
schedule = self.zone.schedule
self._attr_preset_mode = schedule.name
+
+ if self.zone.algorithm == "heating":
+ self._attr_hvac_action = (
+ HVACAction.HEATING if self.zone.relay_on else HVACAction.IDLE
+ )
+ self._attr_hvac_mode = HVACMode.HEAT
+ self._attr_hvac_modes = [HVACMode.HEAT]
+ elif self.zone.algorithm == "cooling":
+ self._attr_hvac_action = (
+ HVACAction.COOLING if self.zone.relay_on else HVACAction.IDLE
+ )
+ self._attr_hvac_mode = HVACMode.COOL
+ self._attr_hvac_modes = [HVACMode.COOL]
diff --git a/homeassistant/components/touchline_sl/entity.py b/homeassistant/components/touchline_sl/entity.py
new file mode 100644
index 00000000000..637ad8955eb
--- /dev/null
+++ b/homeassistant/components/touchline_sl/entity.py
@@ -0,0 +1,38 @@
+"""Base class for Touchline SL zone entities."""
+
+from pytouchlinesl import Zone
+
+from homeassistant.helpers.device_registry import DeviceInfo
+from homeassistant.helpers.update_coordinator import CoordinatorEntity
+
+from .const import DOMAIN
+from .coordinator import TouchlineSLModuleCoordinator
+
+
+class TouchlineSLZoneEntity(CoordinatorEntity[TouchlineSLModuleCoordinator]):
+ """Defines a base Touchline SL zone entity."""
+
+ _attr_has_entity_name = True
+
+ def __init__(self, coordinator: TouchlineSLModuleCoordinator, zone_id: int) -> None:
+ """Initialize touchline entity."""
+ super().__init__(coordinator)
+ self.zone_id = zone_id
+ self._attr_device_info = DeviceInfo(
+ identifiers={(DOMAIN, str(zone_id))},
+ name=self.zone.name,
+ manufacturer="Roth",
+ via_device=(DOMAIN, coordinator.data.module.id),
+ model="zone",
+ suggested_area=self.zone.name,
+ )
+
+ @property
+ def zone(self) -> Zone:
+ """Return the device object from the coordinator data."""
+ return self.coordinator.data.zones[self.zone_id]
+
+ @property
+ def available(self) -> bool:
+ """Return if the device is available."""
+ return super().available and self.zone_id in self.coordinator.data.zones
diff --git a/homeassistant/components/touchline_sl/manifest.json b/homeassistant/components/touchline_sl/manifest.json
index dd591cbf038..ab07ae770fd 100644
--- a/homeassistant/components/touchline_sl/manifest.json
+++ b/homeassistant/components/touchline_sl/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/touchline_sl",
"integration_type": "hub",
"iot_class": "cloud_polling",
- "requirements": ["pytouchlinesl==0.1.8"]
+ "requirements": ["pytouchlinesl==0.3.0"]
}
diff --git a/homeassistant/components/tplink/__init__.py b/homeassistant/components/tplink/__init__.py
index ee1d90e70b4..e2a2f99517f 100644
--- a/homeassistant/components/tplink/__init__.py
+++ b/homeassistant/components/tplink/__init__.py
@@ -47,10 +47,12 @@ from homeassistant.helpers.typing import ConfigType
from .const import (
CONF_AES_KEYS,
+ CONF_CAMERA_CREDENTIALS,
CONF_CONFIG_ENTRY_MINOR_VERSION,
CONF_CONNECTION_PARAMETERS,
CONF_CREDENTIALS_HASH,
CONF_DEVICE_CONFIG,
+ CONF_LIVE_VIEW,
CONF_USES_HTTP,
CONNECT_TIMEOUT,
DISCOVERY_TIMEOUT,
@@ -148,7 +150,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TPLinkConfigEntry) -> bo
if conn_params_dict := entry.data.get(CONF_CONNECTION_PARAMETERS):
try:
conn_params = Device.ConnectionParameters.from_dict(conn_params_dict)
- except KasaException:
+ except (KasaException, TypeError, ValueError, LookupError):
_LOGGER.warning(
"Invalid connection parameters dict for %s: %s", host, conn_params_dict
)
@@ -226,7 +228,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: TPLinkConfigEntry) -> bo
for child in device.children
]
- entry.runtime_data = TPLinkData(parent_coordinator, child_coordinators)
+ camera_creds: Credentials | None = None
+ if camera_creds_dict := entry.data.get(CONF_CAMERA_CREDENTIALS):
+ camera_creds = Credentials(
+ camera_creds_dict[CONF_USERNAME], camera_creds_dict[CONF_PASSWORD]
+ )
+ live_view = entry.data.get(CONF_LIVE_VIEW)
+
+ entry.runtime_data = TPLinkData(
+ parent_coordinator, child_coordinators, camera_creds, live_view
+ )
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
diff --git a/homeassistant/components/tplink/binary_sensor.py b/homeassistant/components/tplink/binary_sensor.py
index 34375bccf4f..318d0803e53 100644
--- a/homeassistant/components/tplink/binary_sensor.py
+++ b/homeassistant/components/tplink/binary_sensor.py
@@ -3,7 +3,7 @@
from __future__ import annotations
from dataclasses import dataclass
-from typing import Final
+from typing import Final, cast
from kasa import Feature
@@ -26,6 +26,9 @@ class TPLinkBinarySensorEntityDescription(
"""Base class for a TPLink feature based sensor entity description."""
+# Coordinator is used to centralize the data updates
+PARALLEL_UPDATES = 0
+
BINARY_SENSOR_DESCRIPTIONS: Final = (
TPLinkBinarySensorEntityDescription(
key="overheated",
@@ -96,6 +99,7 @@ class TPLinkBinarySensorEntity(CoordinatedTPLinkFeatureEntity, BinarySensorEntit
entity_description: TPLinkBinarySensorEntityDescription
@callback
- def _async_update_attrs(self) -> None:
+ def _async_update_attrs(self) -> bool:
"""Update the entity's attributes."""
- self._attr_is_on = self._feature.value
+ self._attr_is_on = cast(bool | None, self._feature.value)
+ return True
diff --git a/homeassistant/components/tplink/button.py b/homeassistant/components/tplink/button.py
index 131325e489d..d8a7c8f1281 100644
--- a/homeassistant/components/tplink/button.py
+++ b/homeassistant/components/tplink/button.py
@@ -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",
@@ -50,6 +54,22 @@ BUTTON_DESCRIPTIONS: Final = [
key="reboot",
device_class=ButtonDeviceClass.RESTART,
),
+ TPLinkButtonEntityDescription(
+ key="pan_left",
+ available_fn=lambda dev: dev.is_on,
+ ),
+ TPLinkButtonEntityDescription(
+ key="pan_right",
+ available_fn=lambda dev: dev.is_on,
+ ),
+ TPLinkButtonEntityDescription(
+ key="tilt_up",
+ available_fn=lambda dev: dev.is_on,
+ ),
+ TPLinkButtonEntityDescription(
+ key="tilt_down",
+ available_fn=lambda dev: dev.is_on,
+ ),
]
BUTTON_DESCRIPTIONS_MAP = {desc.key: desc for desc in BUTTON_DESCRIPTIONS}
@@ -88,5 +108,6 @@ class TPLinkButtonEntity(CoordinatedTPLinkFeatureEntity, ButtonEntity):
"""Execute action."""
await self._feature.set_value(True)
- def _async_update_attrs(self) -> None:
+ def _async_update_attrs(self) -> bool:
"""No need to update anything."""
+ return self.entity_description.available_fn(self._device)
diff --git a/homeassistant/components/tplink/camera.py b/homeassistant/components/tplink/camera.py
new file mode 100644
index 00000000000..e1db7254428
--- /dev/null
+++ b/homeassistant/components/tplink/camera.py
@@ -0,0 +1,230 @@
+"""Support for TPLink camera entities."""
+
+import asyncio
+from dataclasses import dataclass
+import logging
+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 homeassistant.components import ffmpeg, stream
+from homeassistant.components.camera import (
+ Camera,
+ CameraEntityDescription,
+ CameraEntityFeature,
+)
+from homeassistant.config_entries import ConfigFlowContext
+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 .const import CONF_CAMERA_CREDENTIALS
+from .coordinator import TPLinkDataUpdateCoordinator
+from .entity import CoordinatedTPLinkEntity, TPLinkModuleEntityDescription
+
+_LOGGER = logging.getLogger(__name__)
+
+
+@dataclass(frozen=True, kw_only=True)
+class TPLinkCameraEntityDescription(
+ CameraEntityDescription, TPLinkModuleEntityDescription
+):
+ """Base class for camera entity description."""
+
+
+# Coordinator is used to centralize the data updates
+# For actions the integration handles locking of concurrent device request
+PARALLEL_UPDATES = 0
+
+CAMERA_DESCRIPTIONS: tuple[TPLinkCameraEntityDescription, ...] = (
+ TPLinkCameraEntityDescription(
+ key="live_view",
+ translation_key="live_view",
+ available_fn=lambda dev: dev.is_on,
+ ),
+)
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ config_entry: TPLinkConfigEntry,
+ async_add_entities: AddEntitiesCallback,
+) -> None:
+ """Set up camera entities."""
+ 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,
+ )
+ for description in CAMERA_DESCRIPTIONS
+ if (camera_module := device.modules.get(Module.Camera)) and live_view
+ )
+
+
+class TPLinkCameraEntity(CoordinatedTPLinkEntity, Camera):
+ """Representation of a TPLink camera."""
+
+ IMAGE_INTERVAL = 5 * 60
+
+ _attr_supported_features = CameraEntityFeature.STREAM | CameraEntityFeature.ON_OFF
+
+ entity_description: TPLinkCameraEntityDescription
+
+ 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
+ )
+ 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}"
+
+ @callback
+ def _async_update_attrs(self) -> bool:
+ """Update the entity's attributes."""
+ self._attr_is_on = self._camera_module.is_on
+ return self.entity_description.available_fn(self._device)
+
+ async def stream_source(self) -> str | None:
+ """Return the source of the stream."""
+ return self._camera_module.stream_rtsp_url(
+ self._camera_credentials, stream_resolution=StreamResolution.HD
+ )
+
+ async def _async_check_stream_auth(self, video_url: str) -> None:
+ """Check for an auth error and start reauth flow."""
+ try:
+ await stream.async_check_stream_client_error(self.hass, video_url)
+ except stream.StreamOpenClientError as ex:
+ if ex.error_code is stream.StreamClientError.Unauthorized:
+ _LOGGER.debug(
+ "Camera stream failed authentication for %s",
+ self._device.host,
+ )
+ self._can_stream = False
+ self.coordinator.config_entry.async_start_reauth(
+ self.hass,
+ ConfigFlowContext(
+ reauth_source=CONF_CAMERA_CREDENTIALS, # type: ignore[typeddict-unknown-key]
+ ),
+ {"device": self._device},
+ )
+
+ async def async_camera_image(
+ self, width: int | None = None, height: int | None = None
+ ) -> bytes | None:
+ """Return a still image response from the camera."""
+ now = time.monotonic()
+
+ if self._image and now - self._last_update < self.IMAGE_INTERVAL:
+ return self._image
+
+ # Don't try to capture a new image if a stream is running
+ if self._http_mpeg_stream_running:
+ return self._image
+
+ if self._can_stream and (video_url := self._video_url):
+ # Sometimes the front end makes multiple image requests
+ async with self._image_lock:
+ if self._image and (now - self._last_update) < self.IMAGE_INTERVAL:
+ return self._image
+
+ _LOGGER.debug("Updating camera image for %s", self._device.host)
+ image = await ffmpeg.async_get_image(
+ self.hass,
+ video_url,
+ width=width,
+ height=height,
+ )
+ if image:
+ self._image = image
+ self._last_update = now
+ _LOGGER.debug("Updated camera image for %s", self._device.host)
+ # This coroutine is called by camera with an asyncio.timeout
+ # so image could be None whereas an auth issue returns b''
+ elif image == b"":
+ _LOGGER.debug(
+ "Empty camera image returned for %s", self._device.host
+ )
+ # image could be empty if a stream is running so check for explicit auth error
+ await self._async_check_stream_auth(video_url)
+ else:
+ _LOGGER.debug(
+ "None camera image returned for %s", self._device.host
+ )
+
+ return self._image
+
+ async def handle_async_mjpeg_stream(
+ self, request: web.Request
+ ) -> web.StreamResponse | None:
+ """Generate an HTTP MJPEG stream from the camera.
+
+ The frontend falls back to calling this method if the HLS
+ stream fails.
+ """
+ _LOGGER.debug("Starting http mjpeg stream for %s", self._device.host)
+ if self._video_url is None or self._can_stream is False:
+ return None
+
+ mjpeg_stream = CameraMjpeg(self._ffmpeg_manager.binary)
+ await mjpeg_stream.open_camera(self._video_url)
+ self._http_mpeg_stream_running = True
+ try:
+ stream_reader = await mjpeg_stream.get_reader()
+ return await async_aiohttp_proxy_stream(
+ self.hass,
+ request,
+ stream_reader,
+ self._ffmpeg_manager.ffmpeg_stream_content_type,
+ )
+ finally:
+ self._http_mpeg_stream_running = False
+ await mjpeg_stream.close()
+ _LOGGER.debug("Stopped http mjpeg stream for %s", self._device.host)
+
+ async def async_turn_on(self) -> None:
+ """Turn on camera."""
+ await self._camera_module.set_state(True)
+
+ async def async_turn_off(self) -> None:
+ """Turn off camera."""
+ await self._camera_module.set_state(False)
diff --git a/homeassistant/components/tplink/climate.py b/homeassistant/components/tplink/climate.py
index f86992ea0cf..cef9a732cfd 100644
--- a/homeassistant/components/tplink/climate.py
+++ b/homeassistant/components/tplink/climate.py
@@ -25,6 +25,10 @@ from .const import UNIT_MAPPING
from .coordinator import TPLinkDataUpdateCoordinator
from .entity import CoordinatedTPLinkEntity, 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,
@@ -67,7 +71,6 @@ class TPLinkClimateEntity(CoordinatedTPLinkEntity, ClimateEntity):
_attr_precision = PRECISION_TENTHS
# This disables the warning for async_turn_{on,off}, can be removed later.
- _enable_turn_on_off_backwards_compatibility = False
def __init__(
self,
@@ -114,10 +117,10 @@ class TPLinkClimateEntity(CoordinatedTPLinkEntity, ClimateEntity):
await self._state_feature.set_value(False)
@callback
- def _async_update_attrs(self) -> None:
+ def _async_update_attrs(self) -> bool:
"""Update the entity's attributes."""
- self._attr_current_temperature = self._temp_feature.value
- self._attr_target_temperature = self._target_feature.value
+ self._attr_current_temperature = cast(float | None, self._temp_feature.value)
+ self._attr_target_temperature = cast(float | None, self._target_feature.value)
self._attr_hvac_mode = (
HVACMode.HEAT if self._state_feature.value else HVACMode.OFF
@@ -132,9 +135,12 @@ class TPLinkClimateEntity(CoordinatedTPLinkEntity, ClimateEntity):
self._mode_feature.value,
)
self._attr_hvac_action = HVACAction.OFF
- return
+ return True
- self._attr_hvac_action = STATE_TO_ACTION[self._mode_feature.value]
+ self._attr_hvac_action = STATE_TO_ACTION[
+ cast(ThermostatState, self._mode_feature.value)
+ ]
+ return True
def _get_unique_id(self) -> str:
"""Return unique id."""
diff --git a/homeassistant/components/tplink/config_flow.py b/homeassistant/components/tplink/config_flow.py
index 63f1b4e125b..9bc278f8948 100644
--- a/homeassistant/components/tplink/config_flow.py
+++ b/homeassistant/components/tplink/config_flow.py
@@ -4,7 +4,7 @@ from __future__ import annotations
from collections.abc import Mapping
import logging
-from typing import TYPE_CHECKING, Any, Self
+from typing import TYPE_CHECKING, Any, Self, cast
from kasa import (
AuthenticationError,
@@ -13,13 +13,15 @@ from kasa import (
DeviceConfig,
Discover,
KasaException,
+ Module,
TimeoutError,
)
import voluptuous as vol
-from homeassistant.components import dhcp
+from homeassistant.components import dhcp, ffmpeg, stream
from homeassistant.config_entries import (
SOURCE_REAUTH,
+ SOURCE_RECONFIGURE,
ConfigEntry,
ConfigEntryState,
ConfigFlow,
@@ -31,6 +33,7 @@ from homeassistant.const import (
CONF_HOST,
CONF_MAC,
CONF_MODEL,
+ CONF_NAME,
CONF_PASSWORD,
CONF_PORT,
CONF_USERNAME,
@@ -48,9 +51,11 @@ from . import (
)
from .const import (
CONF_AES_KEYS,
+ CONF_CAMERA_CREDENTIALS,
CONF_CONFIG_ENTRY_MINOR_VERSION,
CONF_CONNECTION_PARAMETERS,
CONF_CREDENTIALS_HASH,
+ CONF_LIVE_VIEW,
CONF_USES_HTTP,
CONNECT_TIMEOUT,
DOMAIN,
@@ -62,6 +67,16 @@ STEP_AUTH_DATA_SCHEMA = vol.Schema(
{vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str}
)
+STEP_RECONFIGURE_DATA_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str})
+
+STEP_CAMERA_AUTH_DATA_SCHEMA = vol.Schema(
+ {
+ vol.Required(CONF_LIVE_VIEW): bool,
+ vol.Optional(CONF_USERNAME): str,
+ vol.Optional(CONF_PASSWORD): str,
+ }
+)
+
class TPLinkConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for tplink."""
@@ -227,7 +242,12 @@ class TPLinkConfigFlow(ConfigFlow, domain=DOMAIN):
self.hass.async_create_task(
self._async_reload_requires_auth_entries(), eager_start=False
)
- return self._async_create_entry_from_device(self._discovered_device)
+ if self._async_supports_camera_credentials(device):
+ return await self.async_step_camera_auth_confirm()
+
+ return self._async_create_or_update_entry_from_device(
+ self._discovered_device
+ )
self.context["title_placeholders"] = placeholders
return self.async_show_form(
@@ -253,7 +273,12 @@ class TPLinkConfigFlow(ConfigFlow, domain=DOMAIN):
"""Confirm discovery."""
assert self._discovered_device is not None
if user_input is not None:
- return self._async_create_entry_from_device(self._discovered_device)
+ if self._async_supports_camera_credentials(self._discovered_device):
+ return await self.async_step_camera_auth_confirm()
+
+ return self._async_create_or_update_entry_from_device(
+ self._discovered_device
+ )
self._set_confirm_only()
placeholders = self._async_make_placeholders_from_discovery()
@@ -282,6 +307,13 @@ class TPLinkConfigFlow(ConfigFlow, domain=DOMAIN):
return host, port
+ def _async_supports_camera_credentials(self, device: Device) -> bool:
+ """Return True if device could have separate camera credentials."""
+ if camera_module := device.modules.get(Module.Camera):
+ self._discovered_device = device
+ return bool(camera_module.stream_rtsp_url())
+ return False
+
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
@@ -324,7 +356,11 @@ class TPLinkConfigFlow(ConfigFlow, domain=DOMAIN):
else:
if not device:
return await self.async_step_user_auth_confirm()
- return self._async_create_entry_from_device(device)
+
+ if self._async_supports_camera_credentials(device):
+ return await self.async_step_camera_auth_confirm()
+
+ return self._async_create_or_update_entry_from_device(device)
return self.async_show_form(
step_id="user",
@@ -375,7 +411,10 @@ class TPLinkConfigFlow(ConfigFlow, domain=DOMAIN):
self.hass.async_create_task(
self._async_reload_requires_auth_entries(), eager_start=False
)
- return self._async_create_entry_from_device(device)
+ if self._async_supports_camera_credentials(device):
+ return await self.async_step_camera_auth_confirm()
+
+ return self._async_create_or_update_entry_from_device(device)
return self.async_show_form(
step_id="user_auth_confirm",
@@ -384,6 +423,104 @@ class TPLinkConfigFlow(ConfigFlow, domain=DOMAIN):
description_placeholders=placeholders,
)
+ def _create_camera_entry(
+ self, device: Device, un: str, pw: str
+ ) -> ConfigFlowResult:
+ entry_data: dict[str, bool | dict[str, str]] = {CONF_LIVE_VIEW: True}
+ entry_data[CONF_CAMERA_CREDENTIALS] = {
+ CONF_USERNAME: un,
+ CONF_PASSWORD: pw,
+ }
+ _LOGGER.debug("Creating camera account entry for device %s", device.host)
+ return self._async_create_or_update_entry_from_device(
+ device, camera_data=entry_data
+ )
+
+ async def async_step_camera_auth_confirm(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Dialog that gives the user option to set camera credentials."""
+ errors: dict[str, str] = {}
+ placeholders: dict[str, str] = {}
+ device = self._discovered_device
+ assert device
+
+ if user_input:
+ live_view = user_input[CONF_LIVE_VIEW]
+ if not live_view:
+ return self._async_create_or_update_entry_from_device(
+ device, camera_data={CONF_LIVE_VIEW: False}
+ )
+
+ un = user_input.get(CONF_USERNAME)
+ pw = user_input.get(CONF_PASSWORD)
+
+ if user_input and un and pw:
+ camera_creds = Credentials(un, cast(str, pw))
+
+ camera_module = device.modules[Module.Camera]
+ rtsp_url = camera_module.stream_rtsp_url(camera_creds)
+ assert rtsp_url
+
+ # If camera fails to create HLS stream via 'stream' then try
+ # ffmpeg.async_get_image as some cameras do not work with HLS
+ # and the frontend will fallback to mpeg on error
+ try:
+ await stream.async_check_stream_client_error(self.hass, rtsp_url)
+ except stream.StreamOpenClientError as ex:
+ if ex.error_code is stream.StreamClientError.Unauthorized:
+ errors["base"] = "invalid_camera_auth"
+ else:
+ _LOGGER.debug(
+ "Device %s client error checking stream: %s", device.host, ex
+ )
+ if await ffmpeg.async_get_image(self.hass, rtsp_url):
+ return self._create_camera_entry(device, un, pw)
+
+ errors["base"] = "cannot_connect_camera"
+ placeholders["error"] = str(ex)
+ except Exception as ex: # noqa: BLE001
+ _LOGGER.debug("Device %s error checking stream: %s", device.host, ex)
+ if await ffmpeg.async_get_image(self.hass, rtsp_url):
+ return self._create_camera_entry(device, un, pw)
+
+ errors["base"] = "cannot_connect_camera"
+ placeholders["error"] = str(ex)
+ else:
+ return self._create_camera_entry(device, un, pw)
+
+ elif user_input:
+ errors["base"] = "camera_creds"
+
+ entry = None
+ if self.source == SOURCE_RECONFIGURE:
+ entry = self._get_reconfigure_entry()
+ elif self.source == SOURCE_REAUTH:
+ entry = self._get_reauth_entry()
+
+ if entry:
+ placeholders[CONF_NAME] = entry.data[CONF_ALIAS]
+ placeholders[CONF_MODEL] = entry.data[CONF_MODEL]
+ placeholders[CONF_HOST] = entry.data[CONF_HOST]
+
+ if user_input:
+ form_data = {**user_input}
+ elif entry:
+ form_data = {**entry.data.get(CONF_CAMERA_CREDENTIALS, {})}
+ form_data[CONF_LIVE_VIEW] = entry.data.get(CONF_LIVE_VIEW, False)
+ else:
+ form_data = {}
+
+ self.context["title_placeholders"] = placeholders
+ return self.async_show_form(
+ step_id="camera_auth_confirm",
+ data_schema=self.add_suggested_values_to_schema(
+ STEP_CAMERA_AUTH_DATA_SCHEMA, form_data
+ ),
+ errors=errors,
+ description_placeholders=placeholders,
+ )
+
async def async_step_pick_device(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
@@ -403,7 +540,11 @@ class TPLinkConfigFlow(ConfigFlow, domain=DOMAIN):
return await self.async_step_user_auth_confirm()
except KasaException:
return self.async_abort(reason="cannot_connect")
- return self._async_create_entry_from_device(device)
+
+ if self._async_supports_camera_credentials(device):
+ return await self.async_step_camera_auth_confirm()
+
+ return self._async_create_or_update_entry_from_device(device)
configured_devices = {
entry.unique_id for entry in self._async_current_entries()
@@ -444,11 +585,19 @@ class TPLinkConfigFlow(ConfigFlow, domain=DOMAIN):
_config_entries.flow.async_abort(flow["flow_id"])
@callback
- def _async_create_entry_from_device(self, device: Device) -> ConfigFlowResult:
+ def _async_create_or_update_entry_from_device(
+ self, device: Device, *, camera_data: dict | None = None
+ ) -> ConfigFlowResult:
"""Create a config entry from a smart device."""
- # This is only ever called after a successful device update so we know that
- # the credential_hash is correct and should be saved.
- self._abort_if_unique_id_configured(updates={CONF_HOST: device.host})
+ entry = None
+ if self.source == SOURCE_RECONFIGURE:
+ entry = self._get_reconfigure_entry()
+ elif self.source == SOURCE_REAUTH:
+ entry = self._get_reauth_entry()
+
+ if not entry:
+ self._abort_if_unique_id_configured(updates={CONF_HOST: device.host})
+
data: dict[str, Any] = {
CONF_HOST: device.host,
CONF_ALIAS: device.alias,
@@ -456,16 +605,28 @@ class TPLinkConfigFlow(ConfigFlow, domain=DOMAIN):
CONF_CONNECTION_PARAMETERS: device.config.connection_type.to_dict(),
CONF_USES_HTTP: device.config.uses_http,
}
+ if camera_data is not None:
+ data[CONF_LIVE_VIEW] = camera_data[CONF_LIVE_VIEW]
+ if camera_creds := camera_data.get(CONF_CAMERA_CREDENTIALS):
+ data[CONF_CAMERA_CREDENTIALS] = camera_creds
+
if device.config.aes_keys:
data[CONF_AES_KEYS] = device.config.aes_keys
+
+ # This is only ever called after a successful device update so we know that
+ # the credential_hash is correct and should be saved.
if device.credentials_hash:
data[CONF_CREDENTIALS_HASH] = device.credentials_hash
if port := device.config.port_override:
data[CONF_PORT] = port
- return self.async_create_entry(
- title=f"{device.alias} {device.model}",
- data=data,
- )
+
+ if not entry:
+ return self.async_create_entry(
+ title=f"{device.alias} {device.model}",
+ data=data,
+ )
+
+ return self.async_update_reload_and_abort(entry, data=data)
async def _async_try_connect_all(
self,
@@ -546,7 +707,8 @@ class TPLinkConfigFlow(ConfigFlow, domain=DOMAIN):
credentials: Credentials | None,
) -> Device:
"""Try to connect."""
- self._async_abort_entries_match({CONF_HOST: discovered_device.host})
+ if self.source not in {SOURCE_RECONFIGURE, SOURCE_REAUTH}:
+ self._async_abort_entries_match({CONF_HOST: discovered_device.host})
config = discovered_device.config
if credentials:
@@ -566,6 +728,10 @@ class TPLinkConfigFlow(ConfigFlow, domain=DOMAIN):
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Start the reauthentication flow if the device needs updated credentials."""
+ if self.context.get("reauth_source") == CONF_CAMERA_CREDENTIALS:
+ self._discovered_device = entry_data["device"]
+ return await self.async_step_camera_auth_confirm()
+
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
@@ -634,3 +800,62 @@ class TPLinkConfigFlow(ConfigFlow, domain=DOMAIN):
errors=errors,
description_placeholders=placeholders,
)
+
+ async def async_step_reconfigure(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Trigger a reconfiguration flow."""
+ errors: dict[str, str] = {}
+ placeholders: dict[str, str] = {}
+
+ reconfigure_entry = self._get_reconfigure_entry()
+ assert reconfigure_entry.unique_id
+ await self.async_set_unique_id(reconfigure_entry.unique_id)
+
+ host = reconfigure_entry.data[CONF_HOST]
+ port = reconfigure_entry.data.get(CONF_PORT)
+
+ if user_input is not None:
+ host, port = self._async_get_host_port(host)
+
+ self.host = host
+ credentials = await get_credentials(self.hass)
+ try:
+ device = await self._async_try_discover_and_update(
+ host,
+ credentials,
+ raise_on_progress=False,
+ raise_on_timeout=False,
+ port=port,
+ ) or await self._async_try_connect_all(
+ host,
+ credentials=credentials,
+ raise_on_progress=False,
+ port=port,
+ )
+ except AuthenticationError: # Error from the update()
+ return await self.async_step_user_auth_confirm()
+ except KasaException as ex:
+ errors["base"] = "cannot_connect"
+ placeholders["error"] = str(ex)
+ else:
+ if not device:
+ return await self.async_step_user_auth_confirm()
+
+ if self._async_supports_camera_credentials(device):
+ return await self.async_step_camera_auth_confirm()
+
+ return self._async_create_or_update_entry_from_device(device)
+
+ return self.async_show_form(
+ step_id="reconfigure",
+ data_schema=self.add_suggested_values_to_schema(
+ STEP_RECONFIGURE_DATA_SCHEMA,
+ {CONF_HOST: f"{host}:{port}" if port else host},
+ ),
+ errors=errors,
+ description_placeholders={
+ **placeholders,
+ CONF_MAC: reconfigure_entry.unique_id,
+ },
+ )
diff --git a/homeassistant/components/tplink/const.py b/homeassistant/components/tplink/const.py
index 28e4b04bcf9..61c1bf1cb9b 100644
--- a/homeassistant/components/tplink/const.py
+++ b/homeassistant/components/tplink/const.py
@@ -24,12 +24,15 @@ CONF_CREDENTIALS_HASH: Final = "credentials_hash"
CONF_CONNECTION_PARAMETERS: Final = "connection_parameters"
CONF_USES_HTTP: Final = "uses_http"
CONF_AES_KEYS: Final = "aes_keys"
+CONF_CAMERA_CREDENTIALS = "camera_credentials"
+CONF_LIVE_VIEW = "live_view"
CONF_CONFIG_ENTRY_MINOR_VERSION: Final = 5
PLATFORMS: Final = [
Platform.BINARY_SENSOR,
Platform.BUTTON,
+ Platform.CAMERA,
Platform.CLIMATE,
Platform.FAN,
Platform.LIGHT,
diff --git a/homeassistant/components/tplink/entity.py b/homeassistant/components/tplink/entity.py
index ef9e2ad5eee..935857e5db1 100644
--- a/homeassistant/components/tplink/entity.py
+++ b/homeassistant/components/tplink/entity.py
@@ -89,6 +89,15 @@ class TPLinkFeatureEntityDescription(EntityDescription):
"""Base class for a TPLink feature based entity description."""
deprecated_info: DeprecatedInfo | None = None
+ available_fn: Callable[[Device], bool] = lambda _: True
+
+
+@dataclass(frozen=True, kw_only=True)
+class TPLinkModuleEntityDescription(EntityDescription):
+ """Base class for a TPLink module based entity description."""
+
+ deprecated_info: DeprecatedInfo | None = None
+ available_fn: Callable[[Device], bool] = lambda _: True
def async_refresh_after[_T: CoordinatedTPLinkEntity, **_P](
@@ -200,15 +209,18 @@ class CoordinatedTPLinkEntity(CoordinatorEntity[TPLinkDataUpdateCoordinator], AB
@abstractmethod
@callback
- def _async_update_attrs(self) -> None:
- """Platforms implement this to update the entity internals."""
+ def _async_update_attrs(self) -> bool:
+ """Platforms implement this to update the entity internals.
+
+ The return value is used to the set the entity available attribute.
+ """
raise NotImplementedError
@callback
def _async_call_update_attrs(self) -> None:
"""Call update_attrs and make entity unavailable on errors."""
try:
- self._async_update_attrs()
+ available = self._async_update_attrs()
except Exception as ex: # noqa: BLE001
if self._attr_available:
_LOGGER.warning(
@@ -219,7 +231,7 @@ class CoordinatedTPLinkEntity(CoordinatorEntity[TPLinkDataUpdateCoordinator], AB
)
self._attr_available = False
else:
- self._attr_available = True
+ self._attr_available = available
@callback
def _handle_coordinator_update(self) -> None:
diff --git a/homeassistant/components/tplink/fan.py b/homeassistant/components/tplink/fan.py
index f90eadbc531..92cf049c11a 100644
--- a/homeassistant/components/tplink/fan.py
+++ b/homeassistant/components/tplink/fan.py
@@ -20,6 +20,10 @@ from . import TPLinkConfigEntry
from .coordinator import TPLinkDataUpdateCoordinator
from .entity import CoordinatedTPLinkEntity, 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__)
@@ -64,7 +68,6 @@ class TPLinkFanEntity(CoordinatedTPLinkEntity, FanEntity):
| FanEntityFeature.TURN_OFF
| FanEntityFeature.TURN_ON
)
- _enable_turn_on_off_backwards_compatibility = False
def __init__(
self,
@@ -107,7 +110,7 @@ class TPLinkFanEntity(CoordinatedTPLinkEntity, FanEntity):
await self.fan_module.set_fan_speed_level(value_in_range)
@callback
- def _async_update_attrs(self) -> None:
+ def _async_update_attrs(self) -> bool:
"""Update the entity's attributes."""
fan_speed = self.fan_module.fan_speed_level
self._attr_is_on = fan_speed != 0
@@ -115,3 +118,4 @@ class TPLinkFanEntity(CoordinatedTPLinkEntity, FanEntity):
self._attr_percentage = ranged_value_to_percentage(SPEED_RANGE, fan_speed)
else:
self._attr_percentage = None
+ return True
diff --git a/homeassistant/components/tplink/icons.json b/homeassistant/components/tplink/icons.json
index 0abd68543c5..9cc0326b59f 100644
--- a/homeassistant/components/tplink/icons.json
+++ b/homeassistant/components/tplink/icons.json
@@ -20,6 +20,18 @@
},
"stop_alarm": {
"default": "mdi:bell-cancel"
+ },
+ "pan_left": {
+ "default": "mdi:chevron-left"
+ },
+ "pan_right": {
+ "default": "mdi:chevron-right"
+ },
+ "tilt_up": {
+ "default": "mdi:chevron-up"
+ },
+ "tilt_down": {
+ "default": "mdi:chevron-down"
}
},
"select": {
@@ -77,6 +89,30 @@
"state": {
"on": "mdi:motion-sensor"
}
+ },
+ "motion_detection": {
+ "default": "mdi:motion-sensor-off",
+ "state": {
+ "on": "mdi:motion-sensor"
+ }
+ },
+ "person_detection": {
+ "default": "mdi:account-off",
+ "state": {
+ "on": "mdi:account"
+ }
+ },
+ "tamper_detection": {
+ "default": "mdi:shield-off",
+ "state": {
+ "on": "mdi:shield"
+ }
+ },
+ "baby_cry_detection": {
+ "default": "mdi:baby-face-outline",
+ "state": {
+ "on": "mdi:baby-face"
+ }
}
},
"sensor": {
@@ -117,6 +153,12 @@
},
"target_temperature": {
"default": "mdi:thermometer"
+ },
+ "pan_step": {
+ "default": "mdi:unfold-more-vertical"
+ },
+ "tilt_step": {
+ "default": "mdi:unfold-more-horizontal"
}
}
},
diff --git a/homeassistant/components/tplink/light.py b/homeassistant/components/tplink/light.py
index 8d6ec27f81c..e65fda52e44 100644
--- a/homeassistant/components/tplink/light.py
+++ b/homeassistant/components/tplink/light.py
@@ -6,7 +6,7 @@ from collections.abc import Sequence
import logging
from typing import Any
-from kasa import Device, DeviceType, LightState, Module
+from kasa import Device, DeviceType, KasaException, LightState, Module
from kasa.interfaces import Light, LightEffect
from kasa.iot import IotDevice
import voluptuous as vol
@@ -24,15 +24,21 @@ from homeassistant.components.light import (
filter_supported_color_modes,
)
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.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
+# 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__)
SERVICE_RANDOM_EFFECT = "random_effect"
@@ -200,14 +206,13 @@ class TPLinkLightEntity(CoordinatedTPLinkEntity, LightEntity):
# 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 light_module.is_variable_color_temp:
+ if color_temp_feat := light_module.get_feature("color_temp"):
modes.add(ColorMode.COLOR_TEMP)
- temp_range = light_module.valid_temperature_range
- self._attr_min_color_temp_kelvin = temp_range.min
- self._attr_max_color_temp_kelvin = temp_range.max
- if light_module.is_color:
+ self._attr_min_color_temp_kelvin = color_temp_feat.minimum_value
+ self._attr_max_color_temp_kelvin = color_temp_feat.maximum_value
+ if light_module.has_feature("hsv"):
modes.add(ColorMode.HS)
- if light_module.is_dimmable:
+ if light_module.has_feature("brightness"):
modes.add(ColorMode.BRIGHTNESS)
self._attr_supported_color_modes = filter_supported_color_modes(modes)
if len(self._attr_supported_color_modes) == 1:
@@ -270,15 +275,17 @@ class TPLinkLightEntity(CoordinatedTPLinkEntity, LightEntity):
self, color_temp: float, brightness: int | None, transition: int | None
) -> None:
light_module = self._light_module
- valid_temperature_range = light_module.valid_temperature_range
+ color_temp_feat = light_module.get_feature("color_temp")
+ assert color_temp_feat
+
requested_color_temp = round(color_temp)
# Clamp color temp to valid range
# since if the light in a group we will
# get requests for color temps for the range
# of the group and not the light
clamped_color_temp = min(
- valid_temperature_range.max,
- max(valid_temperature_range.min, requested_color_temp),
+ color_temp_feat.maximum_value,
+ max(color_temp_feat.minimum_value, requested_color_temp),
)
await light_module.set_color_temp(
clamped_color_temp,
@@ -325,17 +332,20 @@ class TPLinkLightEntity(CoordinatedTPLinkEntity, LightEntity):
# The light supports only a single color mode, return it
return self._fixed_color_mode
- # The light supports both color temp and color, determine which on is active
- if self._light_module.is_variable_color_temp and self._light_module.color_temp:
+ # The light supports both color temp and color, determine which one is active
+ if (
+ self._light_module.has_feature("color_temp")
+ and self._light_module.color_temp
+ ):
return ColorMode.COLOR_TEMP
return ColorMode.HS
@callback
- def _async_update_attrs(self) -> None:
+ def _async_update_attrs(self) -> bool:
"""Update the entity's attributes."""
light_module = self._light_module
self._attr_is_on = light_module.state.light_on is True
- if light_module.is_dimmable:
+ if light_module.has_feature("brightness"):
self._attr_brightness = round((light_module.brightness * 255.0) / 100.0)
color_mode = self._determine_color_mode()
self._attr_color_mode = color_mode
@@ -345,6 +355,8 @@ class TPLinkLightEntity(CoordinatedTPLinkEntity, LightEntity):
hue, saturation, _ = light_module.hsv
self._attr_hs_color = hue, saturation
+ return True
+
class TPLinkLightEffectEntity(TPLinkLightEntity):
"""Representation of a TPLink Smart Light Strip."""
@@ -364,7 +376,7 @@ class TPLinkLightEffectEntity(TPLinkLightEntity):
_attr_supported_features = LightEntityFeature.TRANSITION | LightEntityFeature.EFFECT
@callback
- def _async_update_attrs(self) -> None:
+ def _async_update_attrs(self) -> bool:
"""Update the entity's attributes."""
super()._async_update_attrs()
effect_module = self._effect_module
@@ -377,6 +389,7 @@ class TPLinkLightEffectEntity(TPLinkLightEntity):
self._attr_effect_list = effect_list
else:
self._attr_effect_list = None
+ return True
@async_refresh_after
async def async_turn_on(self, **kwargs: Any) -> None:
@@ -451,7 +464,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,
@@ -473,4 +496,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 cb8a55b3db2..a975e675ceb 100644
--- a/homeassistant/components/tplink/manifest.json
+++ b/homeassistant/components/tplink/manifest.json
@@ -3,7 +3,7 @@
"name": "TP-Link Smart Home",
"codeowners": ["@rytilahti", "@bdraco", "@sdb9696"],
"config_flow": true,
- "dependencies": ["network"],
+ "dependencies": ["network", "ffmpeg", "stream"],
"dhcp": [
{
"registered_devices": true
@@ -300,6 +300,5 @@
"documentation": "https://www.home-assistant.io/integrations/tplink",
"iot_class": "local_polling",
"loggers": ["kasa"],
- "quality_scale": "platinum",
- "requirements": ["python-kasa[speedups]==0.7.7"]
+ "requirements": ["python-kasa[speedups]==0.9.1"]
}
diff --git a/homeassistant/components/tplink/models.py b/homeassistant/components/tplink/models.py
index ced58d3d21f..389260a388b 100644
--- a/homeassistant/components/tplink/models.py
+++ b/homeassistant/components/tplink/models.py
@@ -4,6 +4,8 @@ from __future__ import annotations
from dataclasses import dataclass
+from kasa import Credentials
+
from .coordinator import TPLinkDataUpdateCoordinator
@@ -13,3 +15,5 @@ class TPLinkData:
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 5f80d5479d2..464597fd249 100644
--- a/homeassistant/components/tplink/number.py
+++ b/homeassistant/components/tplink/number.py
@@ -4,7 +4,7 @@ from __future__ import annotations
from dataclasses import dataclass
import logging
-from typing import Final
+from typing import Final, cast
from kasa import Device, Feature
@@ -34,6 +34,11 @@ class TPLinkNumberEntityDescription(
"""Base class for a TPLink feature based sensor 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 = (
TPLinkNumberEntityDescription(
key="smooth_transition_on",
@@ -51,6 +56,14 @@ NUMBER_DESCRIPTIONS: Final = (
key="temperature_offset",
mode=NumberMode.BOX,
),
+ TPLinkNumberEntityDescription(
+ key="pan_step",
+ mode=NumberMode.BOX,
+ ),
+ TPLinkNumberEntityDescription(
+ key="tilt_step",
+ mode=NumberMode.BOX,
+ ),
)
NUMBER_DESCRIPTIONS_MAP = {desc.key: desc for desc in NUMBER_DESCRIPTIONS}
@@ -106,6 +119,7 @@ class TPLinkNumberEntity(CoordinatedTPLinkFeatureEntity, NumberEntity):
await self._feature.set_value(int(value))
@callback
- def _async_update_attrs(self) -> None:
+ def _async_update_attrs(self) -> bool:
"""Update the entity's attributes."""
- self._attr_native_value = self._feature.value
+ self._attr_native_value = cast(float | None, self._feature.value)
+ return True
diff --git a/homeassistant/components/tplink/select.py b/homeassistant/components/tplink/select.py
index 41e3224215b..2c46bba8671 100644
--- a/homeassistant/components/tplink/select.py
+++ b/homeassistant/components/tplink/select.py
@@ -27,6 +27,10 @@ class TPLinkSelectEntityDescription(
"""Base class for a TPLink feature based sensor 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",
@@ -91,6 +95,7 @@ class TPLinkSelectEntity(CoordinatedTPLinkFeatureEntity, SelectEntity):
await self._feature.set_value(option)
@callback
- def _async_update_attrs(self) -> None:
+ def _async_update_attrs(self) -> bool:
"""Update the entity's attributes."""
- self._attr_current_option = self._feature.value
+ self._attr_current_option = cast(str | None, self._feature.value)
+ return True
diff --git a/homeassistant/components/tplink/sensor.py b/homeassistant/components/tplink/sensor.py
index 809d9002768..e18a849ccd6 100644
--- a/homeassistant/components/tplink/sensor.py
+++ b/homeassistant/components/tplink/sensor.py
@@ -3,7 +3,7 @@
from __future__ import annotations
from dataclasses import dataclass
-from typing import cast
+from typing import TYPE_CHECKING, cast
from kasa import Feature
@@ -30,6 +30,9 @@ class TPLinkSensorEntityDescription(
"""Base class for a TPLink feature based sensor entity description."""
+# Coordinator is used to centralize the data updates
+PARALLEL_UPDATES = 0
+
SENSOR_DESCRIPTIONS: tuple[TPLinkSensorEntityDescription, ...] = (
TPLinkSensorEntityDescription(
key="current_consumption",
@@ -153,7 +156,7 @@ class TPLinkSensorEntity(CoordinatedTPLinkFeatureEntity, SensorEntity):
entity_description: TPLinkSensorEntityDescription
@callback
- def _async_update_attrs(self) -> None:
+ def _async_update_attrs(self) -> bool:
"""Update the entity's attributes."""
value = self._feature.value
if value is not None and self._feature.precision_hint is not None:
@@ -161,7 +164,14 @@ 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 TYPE_CHECKING:
+ # pylint: disable-next=import-outside-toplevel
+ from datetime import date, datetime
+
+ assert isinstance(value, str | int | float | date | datetime | None)
+
self._attr_native_value = value
# Map to homeassistant units and fallback to upstream one if none found
if (unit := self._feature.unit) is not None:
self._attr_native_unit_of_measurement = UNIT_MAPPING.get(unit, unit)
+ return True
diff --git a/homeassistant/components/tplink/siren.py b/homeassistant/components/tplink/siren.py
index c4ece56f0f6..400ca5248b3 100644
--- a/homeassistant/components/tplink/siren.py
+++ b/homeassistant/components/tplink/siren.py
@@ -15,6 +15,10 @@ from . import TPLinkConfigEntry
from .coordinator import TPLinkDataUpdateCoordinator
from .entity import CoordinatedTPLinkEntity, async_refresh_after
+# Coordinator is used to centralize the data updates
+# For actions the integration handles locking of concurrent device request
+PARALLEL_UPDATES = 0
+
async def async_setup_entry(
hass: HomeAssistant,
@@ -56,6 +60,7 @@ class TPLinkSirenEntity(CoordinatedTPLinkEntity, SirenEntity):
await self._alarm_module.stop()
@callback
- def _async_update_attrs(self) -> None:
+ def _async_update_attrs(self) -> bool:
"""Update the entity's attributes."""
self._attr_is_on = self._alarm_module.active
+ return True
diff --git a/homeassistant/components/tplink/strings.json b/homeassistant/components/tplink/strings.json
index 8e5118c2720..9cf302ed717 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": {
@@ -21,10 +24,14 @@
},
"user_auth_confirm": {
"title": "Authenticate",
- "description": "The device requires authentication, please input your TP-Link credentials below.",
+ "description": "The device requires authentication, please input your TP-Link credentials below. Note, that both e-mail and password are case-sensitive.",
"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,17 +52,49 @@
"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": {
+ "title": "Reconfigure TPLink entry",
+ "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.",
+ "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."
}
}
},
"error": {
"cannot_connect": "Connection error: {error}",
- "invalid_auth": "Invalid authentication: {error}"
+ "invalid_auth": "Unable to authenticate: {error}",
+ "invalid_camera_auth": "Camera stream authentication failed",
+ "cannot_connect_camera": "Unable to access the camera stream, verify that you have set up the camera account: {error}",
+ "camera_creds": "You have to set both username and password"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
+ "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
}
},
@@ -100,6 +143,23 @@
},
"stop_alarm": {
"name": "Stop alarm"
+ },
+ "pan_left": {
+ "name": "Pan left"
+ },
+ "pan_right": {
+ "name": "Pan right"
+ },
+ "tilt_up": {
+ "name": "Tilt up"
+ },
+ "tilt_down": {
+ "name": "Tilt down"
+ }
+ },
+ "camera": {
+ "live_view": {
+ "name": "Live view"
}
},
"select": {
@@ -196,6 +256,18 @@
},
"pir_enabled": {
"name": "Motion sensor"
+ },
+ "motion_detection": {
+ "name": "Motion detection"
+ },
+ "person_detection": {
+ "name": "Person detection"
+ },
+ "tamper_detection": {
+ "name": "Tamper detection"
+ },
+ "baby_cry_detection": {
+ "name": "Baby cry detection"
}
},
"number": {
@@ -210,6 +282,12 @@
},
"temperature_offset": {
"name": "Temperature offset"
+ },
+ "pan_step": {
+ "name": "Pan degrees"
+ },
+ "tilt_step": {
+ "name": "Tilt degrees"
}
}
},
@@ -316,6 +394,9 @@
},
"device_authentication": {
"message": "Device authentication error {func}: {exc}"
+ },
+ "set_custom_effect": {
+ "message": "Error trying to set custom effect {effect}: {exc}"
}
},
"issues": {
diff --git a/homeassistant/components/tplink/switch.py b/homeassistant/components/tplink/switch.py
index c9285d86ba6..dcaef87bf35 100644
--- a/homeassistant/components/tplink/switch.py
+++ b/homeassistant/components/tplink/switch.py
@@ -4,7 +4,7 @@ from __future__ import annotations
from dataclasses import dataclass
import logging
-from typing import Any
+from typing import Any, cast
from kasa import Feature
@@ -29,6 +29,10 @@ class TPLinkSwitchEntityDescription(
"""Base class for a TPLink feature based sensor 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",
@@ -54,6 +58,18 @@ SWITCH_DESCRIPTIONS: tuple[TPLinkSwitchEntityDescription, ...] = (
TPLinkSwitchEntityDescription(
key="pir_enabled",
),
+ TPLinkSwitchEntityDescription(
+ key="motion_detection",
+ ),
+ TPLinkSwitchEntityDescription(
+ key="person_detection",
+ ),
+ TPLinkSwitchEntityDescription(
+ key="tamper_detection",
+ ),
+ TPLinkSwitchEntityDescription(
+ key="baby_cry_detection",
+ ),
)
SWITCH_DESCRIPTIONS_MAP = {desc.key: desc for desc in SWITCH_DESCRIPTIONS}
@@ -97,6 +113,7 @@ class TPLinkSwitch(CoordinatedTPLinkFeatureEntity, SwitchEntity):
await self._feature.set_value(False)
@callback
- def _async_update_attrs(self) -> None:
+ def _async_update_attrs(self) -> bool:
"""Update the entity's attributes."""
- self._attr_is_on = self._feature.value
+ self._attr_is_on = cast(bool | None, self._feature.value)
+ return True
diff --git a/homeassistant/components/tplink_lte/manifest.json b/homeassistant/components/tplink_lte/manifest.json
index 63640628e35..a880594e683 100644
--- a/homeassistant/components/tplink_lte/manifest.json
+++ b/homeassistant/components/tplink_lte/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/tplink_lte",
"iot_class": "local_polling",
"loggers": ["tp_connected"],
+ "quality_scale": "legacy",
"requirements": ["tp-connected==0.0.4"]
}
diff --git a/homeassistant/components/tplink_omada/__init__.py b/homeassistant/components/tplink_omada/__init__.py
index 573df44122c..2d33a890510 100644
--- a/homeassistant/components/tplink_omada/__init__.py
+++ b/homeassistant/components/tplink_omada/__init__.py
@@ -11,9 +11,9 @@ from tplink_omada_client.exceptions import (
UnsupportedControllerVersion,
)
-from homeassistant.config_entries import ConfigEntry
+from homeassistant.config_entries import ConfigEntry, ConfigEntryState
from homeassistant.const import Platform
-from homeassistant.core import HomeAssistant
+from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr
@@ -60,6 +60,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: OmadaConfigEntry) -> boo
entry.runtime_data = controller
+ async def handle_reconnect_client(call: ServiceCall) -> None:
+ """Handle the service action call."""
+ mac: str | None = call.data.get("mac")
+ if not mac:
+ return
+
+ await site_client.reconnect_client(mac)
+
+ hass.services.async_register(DOMAIN, "reconnect_client", handle_reconnect_client)
+
_remove_old_devices(hass, entry, controller.devices_coordinator.data)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
@@ -69,7 +79,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: OmadaConfigEntry) -> boo
async def async_unload_entry(hass: HomeAssistant, entry: OmadaConfigEntry) -> bool:
"""Unload a config entry."""
- return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
+ unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
+ loaded_entries = [
+ entry
+ for entry in hass.config_entries.async_entries(DOMAIN)
+ if entry.state == ConfigEntryState.LOADED
+ ]
+ if len(loaded_entries) == 1:
+ # This is the last loaded instance of Omada, deregister any services
+ hass.services.async_remove(DOMAIN, "reconnect_client")
+
+ return unload_ok
def _remove_old_devices(
diff --git a/homeassistant/components/tplink_omada/icons.json b/homeassistant/components/tplink_omada/icons.json
index c681b5e1f81..94f0a6b9764 100644
--- a/homeassistant/components/tplink_omada/icons.json
+++ b/homeassistant/components/tplink_omada/icons.json
@@ -27,5 +27,10 @@
"default": "mdi:memory"
}
}
+ },
+ "services": {
+ "reconnect_client": {
+ "service": "mdi:sync"
+ }
}
}
diff --git a/homeassistant/components/tplink_omada/manifest.json b/homeassistant/components/tplink_omada/manifest.json
index 6bde656dc30..af20b54675b 100644
--- a/homeassistant/components/tplink_omada/manifest.json
+++ b/homeassistant/components/tplink_omada/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/tplink_omada",
"integration_type": "hub",
"iot_class": "local_polling",
- "requirements": ["tplink-omada-client==1.4.2"]
+ "requirements": ["tplink-omada-client==1.4.3"]
}
diff --git a/homeassistant/components/tplink_omada/services.yaml b/homeassistant/components/tplink_omada/services.yaml
new file mode 100644
index 00000000000..19a64ea8625
--- /dev/null
+++ b/homeassistant/components/tplink_omada/services.yaml
@@ -0,0 +1,7 @@
+reconnect_client:
+ fields:
+ mac:
+ required: true
+ example: "01-23-45-67-89-AB"
+ selector:
+ text:
diff --git a/homeassistant/components/tplink_omada/strings.json b/homeassistant/components/tplink_omada/strings.json
index 7fcede3fb12..73cea692dbf 100644
--- a/homeassistant/components/tplink_omada/strings.json
+++ b/homeassistant/components/tplink_omada/strings.json
@@ -87,5 +87,17 @@
"name": "Memory usage"
}
}
+ },
+ "services": {
+ "reconnect_client": {
+ "name": "Reconnect wireless client",
+ "description": "Tries to get wireless client to reconnect to Omada Network.",
+ "fields": {
+ "mac": {
+ "name": "MAC address",
+ "description": "MAC address of the device."
+ }
+ }
+ }
}
}
diff --git a/homeassistant/components/tractive/sensor.py b/homeassistant/components/tractive/sensor.py
index a92efa660b6..a3c1893267c 100644
--- a/homeassistant/components/tractive/sensor.py
+++ b/homeassistant/components/tractive/sensor.py
@@ -16,6 +16,7 @@ from homeassistant.const import (
ATTR_BATTERY_LEVEL,
PERCENTAGE,
EntityCategory,
+ UnitOfEnergy,
UnitOfTime,
)
from homeassistant.core import HomeAssistant, callback
@@ -127,7 +128,7 @@ SENSOR_TYPES: tuple[TractiveSensorEntityDescription, ...] = (
TractiveSensorEntityDescription(
key=ATTR_CALORIES,
translation_key="calories",
- native_unit_of_measurement="kcal",
+ native_unit_of_measurement=UnitOfEnergy.KILO_CALORIE,
signal_prefix=TRACKER_WELLNESS_STATUS_UPDATED,
state_class=SensorStateClass.TOTAL,
),
diff --git a/homeassistant/components/tradfri/config_flow.py b/homeassistant/components/tradfri/config_flow.py
index 8de40140339..d9911472a67 100644
--- a/homeassistant/components/tradfri/config_flow.py
+++ b/homeassistant/components/tradfri/config_flow.py
@@ -60,10 +60,7 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
return await self._entry_from_data(auth)
except AuthError as err:
- if err.code == "invalid_security_code":
- errors[KEY_SECURITY_CODE] = err.code
- else:
- errors["base"] = err.code
+ errors["base"] = err.code
else:
user_input = {}
diff --git a/homeassistant/components/tradfri/fan.py b/homeassistant/components/tradfri/fan.py
index 75616607ee8..3f45ee3e1eb 100644
--- a/homeassistant/components/tradfri/fan.py
+++ b/homeassistant/components/tradfri/fan.py
@@ -69,7 +69,6 @@ class TradfriAirPurifierFan(TradfriBaseEntity, FanEntity):
# ... with step size 1
# 50 = Max
_attr_speed_count = ATTR_MAX_FAN_STEPS
- _enable_turn_on_off_backwards_compatibility = False
def __init__(
self,
diff --git a/homeassistant/components/tradfri/light.py b/homeassistant/components/tradfri/light.py
index b0bf6d24019..a71691e6e90 100644
--- a/homeassistant/components/tradfri/light.py
+++ b/homeassistant/components/tradfri/light.py
@@ -9,7 +9,7 @@ from pytradfri.command import Command
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
- ATTR_COLOR_TEMP,
+ ATTR_COLOR_TEMP_KELVIN,
ATTR_HS_COLOR,
ATTR_TRANSITION,
ColorMode,
@@ -87,8 +87,16 @@ class TradfriLight(TradfriBaseEntity, LightEntity):
self._fixed_color_mode = next(iter(self._attr_supported_color_modes))
if self._device_control:
- self._attr_min_mireds = self._device_control.min_mireds
- self._attr_max_mireds = self._device_control.max_mireds
+ self._attr_max_color_temp_kelvin = (
+ color_util.color_temperature_mired_to_kelvin(
+ self._device_control.min_mireds
+ )
+ )
+ self._attr_min_color_temp_kelvin = (
+ color_util.color_temperature_mired_to_kelvin(
+ self._device_control.max_mireds
+ )
+ )
def _refresh(self) -> None:
"""Refresh the device."""
@@ -118,11 +126,11 @@ class TradfriLight(TradfriBaseEntity, LightEntity):
return cast(int, self._device_data.dimmer)
@property
- def color_temp(self) -> int | None:
- """Return the color temp value in mireds."""
- if not self._device_data:
+ def color_temp_kelvin(self) -> int | None:
+ """Return the color temperature value in Kelvin."""
+ if not self._device_data or not (color_temp := self._device_data.color_temp):
return None
- return cast(int, self._device_data.color_temp)
+ return color_util.color_temperature_mired_to_kelvin(color_temp)
@property
def hs_color(self) -> tuple[float, float] | None:
@@ -191,18 +199,19 @@ class TradfriLight(TradfriBaseEntity, LightEntity):
transition_time = None
temp_command = None
- if ATTR_COLOR_TEMP in kwargs and (
+ if ATTR_COLOR_TEMP_KELVIN in kwargs and (
self._device_control.can_set_temp or self._device_control.can_set_color
):
- temp = kwargs[ATTR_COLOR_TEMP]
+ temp_k = kwargs[ATTR_COLOR_TEMP_KELVIN]
# White Spectrum bulb
if self._device_control.can_set_temp:
- if temp > self.max_mireds:
- temp = self.max_mireds
- elif temp < self.min_mireds:
- temp = self.min_mireds
+ temp = color_util.color_temperature_kelvin_to_mired(temp_k)
+ if temp < (min_mireds := self._device_control.min_mireds):
+ temp = min_mireds
+ elif temp > (max_mireds := self._device_control.max_mireds):
+ temp = max_mireds
temp_data = {
- ATTR_COLOR_TEMP: temp,
+ "color_temp": temp,
"transition_time": transition_time,
}
temp_command = self._device_control.set_color_temp(**temp_data)
@@ -210,7 +219,6 @@ class TradfriLight(TradfriBaseEntity, LightEntity):
# Color bulb (CWS)
# color_temp needs to be set with hue/saturation
elif self._device_control.can_set_color:
- temp_k = color_util.color_temperature_mired_to_kelvin(temp)
hs_color = color_util.color_temperature_to_hs(temp_k)
hue = int(hs_color[0] * (self._device_control.max_hue / 360))
sat = int(hs_color[1] * (self._device_control.max_saturation / 100))
diff --git a/homeassistant/components/tradfri/strings.json b/homeassistant/components/tradfri/strings.json
index 69a28a567ab..9ed7e167e71 100644
--- a/homeassistant/components/tradfri/strings.json
+++ b/homeassistant/components/tradfri/strings.json
@@ -14,7 +14,7 @@
}
},
"error": {
- "invalid_key": "Failed to register with provided key. If this keeps happening, try restarting the gateway.",
+ "invalid_security_code": "Failed to register with provided key. If this keeps happening, try restarting the gateway.",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"timeout": "Timeout validating the code.",
"cannot_authenticate": "Cannot authenticate, is Gateway paired with another server like e.g. Homekit?"
diff --git a/homeassistant/components/trafikverket_camera/__init__.py b/homeassistant/components/trafikverket_camera/__init__.py
index 938bfce2318..fc5588f40ac 100644
--- a/homeassistant/components/trafikverket_camera/__init__.py
+++ b/homeassistant/components/trafikverket_camera/__init__.py
@@ -4,7 +4,7 @@ from __future__ import annotations
import logging
-from pytrafikverket.trafikverket_camera import TrafikverketCamera
+from pytrafikverket import TrafikverketCamera
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_KEY, CONF_ID, CONF_LOCATION
@@ -25,7 +25,7 @@ TVCameraConfigEntry = ConfigEntry[TVDataUpdateCoordinator]
async def async_setup_entry(hass: HomeAssistant, entry: TVCameraConfigEntry) -> bool:
"""Set up Trafikverket Camera from a config entry."""
- coordinator = TVDataUpdateCoordinator(hass)
+ coordinator = TVDataUpdateCoordinator(hass, entry)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
@@ -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_camera/config_flow.py b/homeassistant/components/trafikverket_camera/config_flow.py
index 18e210beb16..29f3db7beac 100644
--- a/homeassistant/components/trafikverket_camera/config_flow.py
+++ b/homeassistant/components/trafikverket_camera/config_flow.py
@@ -5,9 +5,13 @@ from __future__ import annotations
from collections.abc import Mapping
from typing import Any
-from pytrafikverket.exceptions import InvalidAuthentication, NoCameraFound, UnknownError
-from pytrafikverket.models import CameraInfoModel
-from pytrafikverket.trafikverket_camera import TrafikverketCamera
+from pytrafikverket import (
+ CameraInfoModel,
+ InvalidAuthentication,
+ NoCameraFound,
+ TrafikverketCamera,
+ UnknownError,
+)
import voluptuous as vol
from homeassistant.config_entries import (
diff --git a/homeassistant/components/trafikverket_camera/coordinator.py b/homeassistant/components/trafikverket_camera/coordinator.py
index 7bc5c556c00..649eb102575 100644
--- a/homeassistant/components/trafikverket_camera/coordinator.py
+++ b/homeassistant/components/trafikverket_camera/coordinator.py
@@ -9,14 +9,14 @@ import logging
from typing import TYPE_CHECKING
import aiohttp
-from pytrafikverket.exceptions import (
+from pytrafikverket import (
+ CameraInfoModel,
InvalidAuthentication,
MultipleCamerasFound,
NoCameraFound,
+ TrafikverketCamera,
UnknownError,
)
-from pytrafikverket.models import CameraInfoModel
-from pytrafikverket.trafikverket_camera import TrafikverketCamera
from homeassistant.const import CONF_API_KEY, CONF_ID
from homeassistant.core import HomeAssistant
@@ -44,21 +44,20 @@ class CameraData:
class TVDataUpdateCoordinator(DataUpdateCoordinator[CameraData]):
"""A Trafikverket Data Update Coordinator."""
- config_entry: TVCameraConfigEntry
-
- def __init__(self, hass: HomeAssistant) -> None:
+ def __init__(self, hass: HomeAssistant, config_entry: TVCameraConfigEntry) -> None:
"""Initialize the Trafikverket coordinator."""
super().__init__(
hass,
_LOGGER,
+ config_entry=config_entry,
name=DOMAIN,
update_interval=TIME_BETWEEN_UPDATES,
)
self.session = async_get_clientsession(hass)
self._camera_api = TrafikverketCamera(
- self.session, self.config_entry.data[CONF_API_KEY]
+ self.session, config_entry.data[CONF_API_KEY]
)
- self._id = self.config_entry.data[CONF_ID]
+ self._id = config_entry.data[CONF_ID]
async def _async_update_data(self) -> CameraData:
"""Fetch data from Trafikverket."""
diff --git a/homeassistant/components/trafikverket_camera/manifest.json b/homeassistant/components/trafikverket_camera/manifest.json
index f424f47f7c5..08d945e0a0c 100644
--- a/homeassistant/components/trafikverket_camera/manifest.json
+++ b/homeassistant/components/trafikverket_camera/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/trafikverket_camera",
"iot_class": "cloud_polling",
"loggers": ["pytrafikverket"],
- "requirements": ["pytrafikverket==1.0.0"]
+ "requirements": ["pytrafikverket==1.1.1"]
}
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/manifest.json b/homeassistant/components/trafikverket_ferry/manifest.json
index 0b7b056754c..4177587db7e 100644
--- a/homeassistant/components/trafikverket_ferry/manifest.json
+++ b/homeassistant/components/trafikverket_ferry/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/trafikverket_ferry",
"iot_class": "cloud_polling",
"loggers": ["pytrafikverket"],
- "requirements": ["pytrafikverket==1.0.0"]
+ "requirements": ["pytrafikverket==1.1.1"]
}
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 3e807df9301..d09077dd01a 100644
--- a/homeassistant/components/trafikverket_train/__init__.py
+++ b/homeassistant/components/trafikverket_train/__init__.py
@@ -2,6 +2,8 @@
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
@@ -11,11 +13,13 @@ from .coordinator import TVDataUpdateCoordinator
TVTrainConfigEntry = ConfigEntry[TVDataUpdateCoordinator]
+_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
@@ -33,12 +37,33 @@ 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)
+
+
+async def async_migrate_entry(hass: HomeAssistant, entry: TVTrainConfigEntry) -> bool:
+ """Migrate config entry."""
+ _LOGGER.debug("Migrating from version %s", entry.version)
+
+ if entry.version > 1:
+ # 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)
+
+ _LOGGER.debug(
+ "Migration to version %s.%s successful",
+ entry.version,
+ entry.minor_version,
+ )
+
+ return True
diff --git a/homeassistant/components/trafikverket_train/config_flow.py b/homeassistant/components/trafikverket_train/config_flow.py
index f498a7b0d0e..363b9bb2542 100644
--- a/homeassistant/components/trafikverket_train/config_flow.py
+++ b/homeassistant/components/trafikverket_train/config_flow.py
@@ -37,7 +37,7 @@ from homeassistant.helpers.selector import (
import homeassistant.util.dt as dt_util
from .const import CONF_FILTER_PRODUCT, CONF_FROM, CONF_TIME, CONF_TO, DOMAIN
-from .util import create_unique_id, next_departuredate
+from .util import next_departuredate
_LOGGER = logging.getLogger(__name__)
@@ -93,8 +93,8 @@ async def validate_input(
try:
web_session = async_get_clientsession(hass)
train_api = TrafikverketTrain(web_session, api_key)
- from_station = await train_api.async_get_train_station(train_from)
- to_station = await train_api.async_get_train_station(train_to)
+ 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
@@ -125,6 +125,7 @@ class TVTrainConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Trafikverket Train integration."""
VERSION = 1
+ MINOR_VERSION = 2
@staticmethod
@callback
@@ -202,11 +203,16 @@ class TVTrainConfigFlow(ConfigFlow, domain=DOMAIN):
filter_product,
)
if not errors:
- unique_id = create_unique_id(
- train_from, train_to, train_time, train_days
+ 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,
+ }
)
- await self.async_set_unique_id(unique_id)
- self._abort_if_unique_id_configured()
return self.async_create_entry(
title=name,
data={
diff --git a/homeassistant/components/trafikverket_train/coordinator.py b/homeassistant/components/trafikverket_train/coordinator.py
index 16a7a649b85..c4e1a418371 100644
--- a/homeassistant/components/trafikverket_train/coordinator.py
+++ b/homeassistant/components/trafikverket_train/coordinator.py
@@ -74,30 +74,29 @@ 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_get_train_station(
+ self.to_station = await self._train_api.async_search_train_station(
self.config_entry.data[CONF_TO]
)
- self.from_station = await self._train_api.async_get_train_station(
+ self.from_station = await self._train_api.async_search_train_station(
self.config_entry.data[CONF_FROM]
)
except InvalidAuthentication as error:
diff --git a/homeassistant/components/trafikverket_train/manifest.json b/homeassistant/components/trafikverket_train/manifest.json
index 222b23dbe9a..40f3a39a2bb 100644
--- a/homeassistant/components/trafikverket_train/manifest.json
+++ b/homeassistant/components/trafikverket_train/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/trafikverket_train",
"iot_class": "cloud_polling",
"loggers": ["pytrafikverket"],
- "requirements": ["pytrafikverket==1.0.0"]
+ "requirements": ["pytrafikverket==1.1.1"]
}
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/util.py b/homeassistant/components/trafikverket_train/util.py
index 9648436f1e5..9a8dd9ea237 100644
--- a/homeassistant/components/trafikverket_train/util.py
+++ b/homeassistant/components/trafikverket_train/util.py
@@ -2,22 +2,11 @@
from __future__ import annotations
-from datetime import date, time, timedelta
+from datetime import date, timedelta
from homeassistant.const import WEEKDAYS
-def create_unique_id(
- from_station: str, to_station: str, depart_time: time | str | None, weekdays: list
-) -> str:
- """Create unique id."""
- timestr = str(depart_time) if depart_time else ""
- return (
- f"{from_station.casefold().replace(' ', '')}-{to_station.casefold().replace(' ', '')}"
- f"-{timestr.casefold().replace(' ', '')}-{weekdays!s}"
- )
-
-
def next_weekday(fromdate: date, weekday: int) -> date:
"""Return the date of the next time a specific weekday happen."""
days_ahead = weekday - fromdate.weekday()
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/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/manifest.json b/homeassistant/components/trafikverket_weatherstation/manifest.json
index 85838726178..3996379540f 100644
--- a/homeassistant/components/trafikverket_weatherstation/manifest.json
+++ b/homeassistant/components/trafikverket_weatherstation/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/trafikverket_weatherstation",
"iot_class": "cloud_polling",
"loggers": ["pytrafikverket"],
- "requirements": ["pytrafikverket==1.0.0"]
+ "requirements": ["pytrafikverket==1.1.1"]
}
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/__init__.py b/homeassistant/components/transmission/__init__.py
index 1c108831acf..1a8ffdea0c2 100644
--- a/homeassistant/components/transmission/__init__.py
+++ b/homeassistant/components/transmission/__init__.py
@@ -42,6 +42,7 @@ from homeassistant.helpers.typing import ConfigType
from .const import (
ATTR_DELETE_DATA,
+ ATTR_DOWNLOAD_PATH,
ATTR_TORRENT,
CONF_ENTRY_ID,
DEFAULT_DELETE_DATA,
@@ -82,7 +83,12 @@ SERVICE_BASE_SCHEMA = vol.Schema(
)
SERVICE_ADD_TORRENT_SCHEMA = vol.All(
- SERVICE_BASE_SCHEMA.extend({vol.Required(ATTR_TORRENT): cv.string}),
+ SERVICE_BASE_SCHEMA.extend(
+ {
+ vol.Required(ATTR_TORRENT): cv.string,
+ vol.Optional(ATTR_DOWNLOAD_PATH, default=None): cv.string,
+ }
+ ),
)
@@ -213,10 +219,18 @@ def setup_hass_services(hass: HomeAssistant) -> None:
entry_id: str = service.data[CONF_ENTRY_ID]
coordinator = _get_coordinator_from_service_data(hass, entry_id)
torrent: str = service.data[ATTR_TORRENT]
+ download_path: str | None = service.data.get(ATTR_DOWNLOAD_PATH)
if torrent.startswith(
("http", "ftp:", "magnet:")
) or hass.config.is_allowed_path(torrent):
- await hass.async_add_executor_job(coordinator.api.add_torrent, torrent)
+ if download_path:
+ await hass.async_add_executor_job(
+ partial(
+ coordinator.api.add_torrent, torrent, download_dir=download_path
+ )
+ )
+ else:
+ await hass.async_add_executor_job(coordinator.api.add_torrent, torrent)
await coordinator.async_request_refresh()
else:
_LOGGER.warning("Could not add torrent: unsupported type or no permission")
diff --git a/homeassistant/components/transmission/const.py b/homeassistant/components/transmission/const.py
index 120918b24a2..c232f26cefd 100644
--- a/homeassistant/components/transmission/const.py
+++ b/homeassistant/components/transmission/const.py
@@ -40,6 +40,7 @@ STATE_ATTR_TORRENT_INFO = "torrent_info"
ATTR_DELETE_DATA = "delete_data"
ATTR_TORRENT = "torrent"
+ATTR_DOWNLOAD_PATH = "download_path"
SERVICE_ADD_TORRENT = "add_torrent"
SERVICE_REMOVE_TORRENT = "remove_torrent"
diff --git a/homeassistant/components/transmission/coordinator.py b/homeassistant/components/transmission/coordinator.py
index e0930bd9e9e..b998ab6fbdd 100644
--- a/homeassistant/components/transmission/coordinator.py
+++ b/homeassistant/components/transmission/coordinator.py
@@ -102,7 +102,12 @@ class TransmissionDataUpdateCoordinator(DataUpdateCoordinator[SessionStats]):
for torrent in current_completed_torrents:
if torrent.id not in old_completed_torrents:
self.hass.bus.fire(
- EVENT_DOWNLOADED_TORRENT, {"name": torrent.name, "id": torrent.id}
+ EVENT_DOWNLOADED_TORRENT,
+ {
+ "name": torrent.name,
+ "id": torrent.id,
+ "download_path": torrent.download_dir,
+ },
)
self._completed_torrents = current_completed_torrents
@@ -118,7 +123,12 @@ class TransmissionDataUpdateCoordinator(DataUpdateCoordinator[SessionStats]):
for torrent in current_started_torrents:
if torrent.id not in old_started_torrents:
self.hass.bus.fire(
- EVENT_STARTED_TORRENT, {"name": torrent.name, "id": torrent.id}
+ EVENT_STARTED_TORRENT,
+ {
+ "name": torrent.name,
+ "id": torrent.id,
+ "download_path": torrent.download_dir,
+ },
)
self._started_torrents = current_started_torrents
@@ -130,7 +140,12 @@ class TransmissionDataUpdateCoordinator(DataUpdateCoordinator[SessionStats]):
for torrent in self._all_torrents:
if torrent.id not in current_torrents:
self.hass.bus.fire(
- EVENT_REMOVED_TORRENT, {"name": torrent.name, "id": torrent.id}
+ EVENT_REMOVED_TORRENT,
+ {
+ "name": torrent.name,
+ "id": torrent.id,
+ "download_path": torrent.download_dir,
+ },
)
self._all_torrents = self.torrents.copy()
diff --git a/homeassistant/components/transmission/sensor.py b/homeassistant/components/transmission/sensor.py
index 737520adb5f..652f5d51fbb 100644
--- a/homeassistant/components/transmission/sensor.py
+++ b/homeassistant/components/transmission/sensor.py
@@ -83,7 +83,6 @@ SENSOR_TYPES: tuple[TransmissionSensorEntityDescription, ...] = (
TransmissionSensorEntityDescription(
key="active_torrents",
translation_key="active_torrents",
- native_unit_of_measurement="torrents",
val_func=lambda coordinator: coordinator.data.active_torrent_count,
extra_state_attr_func=lambda coordinator: _torrents_info_attr(
coordinator=coordinator, key="active_torrents"
@@ -92,7 +91,6 @@ SENSOR_TYPES: tuple[TransmissionSensorEntityDescription, ...] = (
TransmissionSensorEntityDescription(
key="paused_torrents",
translation_key="paused_torrents",
- native_unit_of_measurement="torrents",
val_func=lambda coordinator: coordinator.data.paused_torrent_count,
extra_state_attr_func=lambda coordinator: _torrents_info_attr(
coordinator=coordinator, key="paused_torrents"
@@ -101,7 +99,6 @@ SENSOR_TYPES: tuple[TransmissionSensorEntityDescription, ...] = (
TransmissionSensorEntityDescription(
key="total_torrents",
translation_key="total_torrents",
- native_unit_of_measurement="torrents",
val_func=lambda coordinator: coordinator.data.torrent_count,
extra_state_attr_func=lambda coordinator: _torrents_info_attr(
coordinator=coordinator, key="total_torrents"
@@ -110,7 +107,6 @@ SENSOR_TYPES: tuple[TransmissionSensorEntityDescription, ...] = (
TransmissionSensorEntityDescription(
key="completed_torrents",
translation_key="completed_torrents",
- native_unit_of_measurement="torrents",
val_func=lambda coordinator: len(
_filter_torrents(coordinator.torrents, MODES["completed_torrents"])
),
@@ -121,7 +117,6 @@ SENSOR_TYPES: tuple[TransmissionSensorEntityDescription, ...] = (
TransmissionSensorEntityDescription(
key="started_torrents",
translation_key="started_torrents",
- native_unit_of_measurement="torrents",
val_func=lambda coordinator: len(
_filter_torrents(coordinator.torrents, MODES["started_torrents"])
),
diff --git a/homeassistant/components/transmission/services.yaml b/homeassistant/components/transmission/services.yaml
index 2d61bda442f..8f9aadd5009 100644
--- a/homeassistant/components/transmission/services.yaml
+++ b/homeassistant/components/transmission/services.yaml
@@ -9,6 +9,11 @@ add_torrent:
example: http://releases.ubuntu.com/19.04/ubuntu-19.04-desktop-amd64.iso.torrent
selector:
text:
+ download_path:
+ required: false
+ example: "/path/to/download/directory"
+ selector:
+ text:
remove_torrent:
fields:
diff --git a/homeassistant/components/transmission/strings.json b/homeassistant/components/transmission/strings.json
index 20ae6ca723d..aabc5827a88 100644
--- a/homeassistant/components/transmission/strings.json
+++ b/homeassistant/components/transmission/strings.json
@@ -60,19 +60,24 @@
}
},
"active_torrents": {
- "name": "Active torrents"
+ "name": "Active torrents",
+ "unit_of_measurement": "torrents"
},
"paused_torrents": {
- "name": "Paused torrents"
+ "name": "Paused torrents",
+ "unit_of_measurement": "[%key:component::transmission::entity::sensor::active_torrents::unit_of_measurement%]"
},
"total_torrents": {
- "name": "Total torrents"
+ "name": "Total torrents",
+ "unit_of_measurement": "[%key:component::transmission::entity::sensor::active_torrents::unit_of_measurement%]"
},
"completed_torrents": {
- "name": "Completed torrents"
+ "name": "Completed torrents",
+ "unit_of_measurement": "[%key:component::transmission::entity::sensor::active_torrents::unit_of_measurement%]"
},
"started_torrents": {
- "name": "Started torrents"
+ "name": "Started torrents",
+ "unit_of_measurement": "[%key:component::transmission::entity::sensor::active_torrents::unit_of_measurement%]"
}
},
"switch": {
@@ -96,6 +101,10 @@
"torrent": {
"name": "Torrent",
"description": "URL, magnet link or Base64 encoded file."
+ },
+ "download_path": {
+ "name": "Download path",
+ "description": "Optional path to specify where the torrent should be downloaded. If not specified, the default download directory is used."
}
}
},
diff --git a/homeassistant/components/transport_nsw/manifest.json b/homeassistant/components/transport_nsw/manifest.json
index 9d535b99aa1..83c138a4f91 100644
--- a/homeassistant/components/transport_nsw/manifest.json
+++ b/homeassistant/components/transport_nsw/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/transport_nsw",
"iot_class": "cloud_polling",
"loggers": ["TransportNSW"],
+ "quality_scale": "legacy",
"requirements": ["PyTransportNSW==0.1.1"]
}
diff --git a/homeassistant/components/travisci/manifest.json b/homeassistant/components/travisci/manifest.json
index e61a987c86f..be30cf8e1f9 100644
--- a/homeassistant/components/travisci/manifest.json
+++ b/homeassistant/components/travisci/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/travisci",
"iot_class": "cloud_polling",
"loggers": ["travispy"],
+ "quality_scale": "legacy",
"requirements": ["TravisPy==0.3.5"]
}
diff --git a/homeassistant/components/trend/binary_sensor.py b/homeassistant/components/trend/binary_sensor.py
index 681680f180f..9691ecf0744 100644
--- a/homeassistant/components/trend/binary_sensor.py
+++ b/homeassistant/components/trend/binary_sensor.py
@@ -227,10 +227,15 @@ class SensorTrend(BinarySensorEntity, RestoreEntity):
state = new_state.attributes.get(self._attribute)
else:
state = new_state.state
- if state not in (STATE_UNKNOWN, STATE_UNAVAILABLE):
+
+ if state in (STATE_UNKNOWN, STATE_UNAVAILABLE):
+ self._attr_available = False
+ else:
+ self._attr_available = True
sample = (new_state.last_updated.timestamp(), float(state)) # type: ignore[arg-type]
self.samples.append(sample)
- self.async_schedule_update_ha_state(True)
+
+ self.async_schedule_update_ha_state(True)
except (ValueError, TypeError) as ex:
_LOGGER.error(ex)
diff --git a/homeassistant/components/trend/manifest.json b/homeassistant/components/trend/manifest.json
index b2f47738d4a..69e8daa3ce7 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.1.2"]
+ "requirements": ["numpy==2.2.1"]
}
diff --git a/homeassistant/components/tts/__init__.py b/homeassistant/components/tts/__init__.py
index ad267b9106b..e7d1091719b 100644
--- a/homeassistant/components/tts/__init__.py
+++ b/homeassistant/components/tts/__init__.py
@@ -13,6 +13,7 @@ import logging
import mimetypes
import os
import re
+import secrets
import subprocess
import tempfile
from typing import Any, Final, TypedDict, final
@@ -540,6 +541,10 @@ class SpeechManager:
self.file_cache: dict[str, str] = {}
self.mem_cache: dict[str, TTSCache] = {}
+ # filename <-> token
+ self.filename_to_token: dict[str, str] = {}
+ self.token_to_filename: dict[str, str] = {}
+
def _init_cache(self) -> dict[str, str]:
"""Init cache folder and fetch files."""
try:
@@ -656,7 +661,17 @@ class SpeechManager:
engine_instance, cache_key, message, use_cache, language, options
)
- return f"/api/tts_proxy/{filename}"
+ # Use a randomly generated token instead of exposing the filename
+ token = self.filename_to_token.get(filename)
+ if not token:
+ # Keep extension (.mp3, etc.)
+ token = secrets.token_urlsafe(16) + os.path.splitext(filename)[1]
+
+ # Map token <-> filename
+ self.filename_to_token[filename] = token
+ self.token_to_filename[token] = filename
+
+ return f"/api/tts_proxy/{token}"
async def async_get_tts_audio(
self,
@@ -910,11 +925,15 @@ class SpeechManager:
),
)
- async def async_read_tts(self, filename: str) -> tuple[str | None, bytes]:
+ async def async_read_tts(self, token: str) -> tuple[str | None, bytes]:
"""Read a voice file and return binary.
This method is a coroutine.
"""
+ filename = self.token_to_filename.get(token)
+ if not filename:
+ raise HomeAssistantError(f"{token} was not recognized!")
+
if not (record := _RE_VOICE_FILE.match(filename.lower())) and not (
record := _RE_LEGACY_VOICE_FILE.match(filename.lower())
):
@@ -1076,6 +1095,7 @@ class TextToSpeechView(HomeAssistantView):
async def get(self, request: web.Request, filename: str) -> web.Response:
"""Start a get request."""
try:
+ # filename is actually token, but we keep its name for compatibility
content, data = await self.tts.async_read_tts(filename)
except HomeAssistantError as err:
_LOGGER.error("Error on load tts: %s", err)
diff --git a/homeassistant/components/tuya/__init__.py b/homeassistant/components/tuya/__init__.py
index 47143f3595c..c8a639cd239 100644
--- a/homeassistant/components/tuya/__init__.py
+++ b/homeassistant/components/tuya/__init__.py
@@ -146,14 +146,21 @@ class DeviceListener(SharingDeviceListener):
self.hass = hass
self.manager = manager
- def update_device(self, device: CustomerDevice) -> None:
+ def update_device(
+ self, device: CustomerDevice, updated_status_properties: list[str] | None
+ ) -> None:
"""Update device status."""
LOGGER.debug(
- "Received update for device %s: %s",
+ "Received update for device %s: %s (updated properties: %s)",
device.id,
self.manager.device_map[device.id].status,
+ updated_status_properties,
+ )
+ dispatcher_send(
+ self.hass,
+ f"{TUYA_HA_SIGNAL_UPDATE_ENTITY}_{device.id}",
+ updated_status_properties,
)
- dispatcher_send(self.hass, f"{TUYA_HA_SIGNAL_UPDATE_ENTITY}_{device.id}")
def add_device(self, device: CustomerDevice) -> None:
"""Add device added listener."""
diff --git a/homeassistant/components/tuya/climate.py b/homeassistant/components/tuya/climate.py
index 93aaaa40c26..1780256a740 100644
--- a/homeassistant/components/tuya/climate.py
+++ b/homeassistant/components/tuya/climate.py
@@ -77,6 +77,9 @@ CLIMATE_DESCRIPTIONS: dict[str, TuyaClimateEntityDescription] = {
key="wkf",
switch_only_hvac_mode=HVACMode.HEAT,
),
+ # Electric Fireplace
+ # https://developer.tuya.com/en/docs/iot/f?id=Kacpeobojffop
+ "dbl": TuyaClimateEntityDescription(key="dbl", switch_only_hvac_mode=HVACMode.HEAT),
}
@@ -120,7 +123,6 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity):
_set_temperature: IntegerTypeData | None = None
entity_description: TuyaClimateEntityDescription
_attr_name = None
- _enable_turn_on_off_backwards_compatibility = False
def __init__(
self,
diff --git a/homeassistant/components/tuya/entity.py b/homeassistant/components/tuya/entity.py
index 4d3710f7570..cc258560067 100644
--- a/homeassistant/components/tuya/entity.py
+++ b/homeassistant/components/tuya/entity.py
@@ -283,10 +283,15 @@ class TuyaEntity(Entity):
async_dispatcher_connect(
self.hass,
f"{TUYA_HA_SIGNAL_UPDATE_ENTITY}_{self.device.id}",
- self.async_write_ha_state,
+ self._handle_state_update,
)
)
+ async def _handle_state_update(
+ self, updated_status_properties: list[str] | None
+ ) -> None:
+ self.async_write_ha_state()
+
def _send_command(self, commands: list[dict[str, Any]]) -> None:
"""Send command to the device."""
LOGGER.debug("Sending commands for device %s: %s", self.device.id, commands)
diff --git a/homeassistant/components/tuya/fan.py b/homeassistant/components/tuya/fan.py
index 4a6de1cae09..ffab9efdde8 100644
--- a/homeassistant/components/tuya/fan.py
+++ b/homeassistant/components/tuya/fan.py
@@ -66,7 +66,6 @@ class TuyaFanEntity(TuyaEntity, FanEntity):
_speeds: EnumTypeData | None = None
_switch: DPCode | None = None
_attr_name = None
- _enable_turn_on_off_backwards_compatibility = False
def __init__(
self,
diff --git a/homeassistant/components/tuya/light.py b/homeassistant/components/tuya/light.py
index 060b1f4b7ef..d7dffc16b58 100644
--- a/homeassistant/components/tuya/light.py
+++ b/homeassistant/components/tuya/light.py
@@ -10,7 +10,7 @@ from tuya_sharing import CustomerDevice, Manager
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
- ATTR_COLOR_TEMP,
+ ATTR_COLOR_TEMP_KELVIN,
ATTR_HS_COLOR,
ColorMode,
LightEntity,
@@ -21,6 +21,7 @@ from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.util import color as color_util
from . import TuyaConfigEntry
from .const import TUYA_DISCOVERY_NEW, DPCode, DPType, WorkMode
@@ -49,6 +50,9 @@ DEFAULT_COLOR_TYPE_DATA_V2 = ColorTypeData(
v_type=IntegerTypeData(DPCode.COLOUR_DATA_HSV, min=1, scale=0, max=1000, step=1),
)
+MAX_MIREDS = 500 # 2000 K
+MIN_MIREDS = 153 # 6500 K
+
@dataclass(frozen=True)
class TuyaLightEntityDescription(LightEntityDescription):
@@ -457,6 +461,8 @@ class TuyaLightEntity(TuyaEntity, LightEntity):
_color_mode: DPCode | None = None
_color_temp: IntegerTypeData | None = None
_fixed_color_mode: ColorMode | None = None
+ _attr_min_color_temp_kelvin = 2000 # 500 Mireds
+ _attr_max_color_temp_kelvin = 6500 # 153 Mireds
def __init__(
self,
@@ -532,7 +538,7 @@ class TuyaLightEntity(TuyaEntity, LightEntity):
"""Turn on or control the light."""
commands = [{"code": self.entity_description.key, "value": True}]
- if self._color_temp and ATTR_COLOR_TEMP in kwargs:
+ if self._color_temp and ATTR_COLOR_TEMP_KELVIN in kwargs:
if self._color_mode_dpcode:
commands += [
{
@@ -546,9 +552,11 @@ class TuyaLightEntity(TuyaEntity, LightEntity):
"code": self._color_temp.dpcode,
"value": round(
self._color_temp.remap_value_from(
- kwargs[ATTR_COLOR_TEMP],
- self.min_mireds,
- self.max_mireds,
+ color_util.color_temperature_kelvin_to_mired(
+ kwargs[ATTR_COLOR_TEMP_KELVIN]
+ ),
+ MIN_MIREDS,
+ MAX_MIREDS,
reverse=True,
)
),
@@ -560,7 +568,7 @@ class TuyaLightEntity(TuyaEntity, LightEntity):
or (
ATTR_BRIGHTNESS in kwargs
and self.color_mode == ColorMode.HS
- and ATTR_COLOR_TEMP not in kwargs
+ and ATTR_COLOR_TEMP_KELVIN not in kwargs
)
):
if self._color_mode_dpcode:
@@ -688,8 +696,8 @@ class TuyaLightEntity(TuyaEntity, LightEntity):
return round(brightness)
@property
- def color_temp(self) -> int | None:
- """Return the color_temp of the light."""
+ def color_temp_kelvin(self) -> int | None:
+ """Return the color temperature value in Kelvin."""
if not self._color_temp:
return None
@@ -697,9 +705,9 @@ class TuyaLightEntity(TuyaEntity, LightEntity):
if temperature is None:
return None
- return round(
+ return color_util.color_temperature_mired_to_kelvin(
self._color_temp.remap_value_to(
- temperature, self.min_mireds, self.max_mireds, reverse=True
+ temperature, MIN_MIREDS, MAX_MIREDS, reverse=True
)
)
diff --git a/homeassistant/components/tuya/manifest.json b/homeassistant/components/tuya/manifest.json
index 305a74160de..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": [
@@ -43,5 +43,5 @@
"integration_type": "hub",
"iot_class": "cloud_push",
"loggers": ["tuya_iot"],
- "requirements": ["tuya-device-sharing-sdk==0.1.9"]
+ "requirements": ["tuya-device-sharing-sdk==0.2.1"]
}
diff --git a/homeassistant/components/tuya/number.py b/homeassistant/components/tuya/number.py
index d2e381d9982..8d5b5dbfa19 100644
--- a/homeassistant/components/tuya/number.py
+++ b/homeassistant/components/tuya/number.py
@@ -292,6 +292,17 @@ NUMBERS: dict[str, tuple[NumberEntityDescription, ...]] = {
device_class=NumberDeviceClass.TEMPERATURE,
),
),
+ # CO2 Detector
+ # https://developer.tuya.com/en/docs/iot/categoryco2bj?id=Kaiuz3wes7yuy
+ "co2bj": (
+ NumberEntityDescription(
+ key=DPCode.ALARM_TIME,
+ translation_key="alarm_duration",
+ native_unit_of_measurement=UnitOfTime.SECONDS,
+ device_class=NumberDeviceClass.DURATION,
+ entity_category=EntityCategory.CONFIG,
+ ),
+ ),
}
diff --git a/homeassistant/components/tuya/select.py b/homeassistant/components/tuya/select.py
index abc5e4c496b..831d3cb3e0c 100644
--- a/homeassistant/components/tuya/select.py
+++ b/homeassistant/components/tuya/select.py
@@ -307,6 +307,15 @@ SELECTS: dict[str, tuple[SelectEntityDescription, ...]] = {
entity_category=EntityCategory.CONFIG,
),
),
+ # CO2 Detector
+ # https://developer.tuya.com/en/docs/iot/categoryco2bj?id=Kaiuz3wes7yuy
+ "co2bj": (
+ SelectEntityDescription(
+ key=DPCode.ALARM_VOLUME,
+ translation_key="volume",
+ entity_category=EntityCategory.CONFIG,
+ ),
+ ),
}
# Socket (duplicate of `kg`)
diff --git a/homeassistant/components/tuya/sensor.py b/homeassistant/components/tuya/sensor.py
index b9677037b7e..f766c744998 100644
--- a/homeassistant/components/tuya/sensor.py
+++ b/homeassistant/components/tuya/sensor.py
@@ -214,6 +214,12 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = {
device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS,
state_class=SensorStateClass.MEASUREMENT,
),
+ TuyaSensorEntityDescription(
+ key=DPCode.PM25_VALUE,
+ translation_key="pm25",
+ device_class=SensorDeviceClass.PM25,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
*BATTERY_SENSORS,
),
# Two-way temperature and humidity switch
@@ -254,6 +260,31 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = {
entity_registry_enabled_default=False,
),
),
+ # Single Phase power meter
+ # Note: Undocumented
+ "aqcz": (
+ TuyaSensorEntityDescription(
+ key=DPCode.CUR_CURRENT,
+ translation_key="current",
+ device_class=SensorDeviceClass.CURRENT,
+ state_class=SensorStateClass.MEASUREMENT,
+ entity_registry_enabled_default=False,
+ ),
+ TuyaSensorEntityDescription(
+ key=DPCode.CUR_POWER,
+ translation_key="power",
+ device_class=SensorDeviceClass.POWER,
+ state_class=SensorStateClass.MEASUREMENT,
+ entity_registry_enabled_default=False,
+ ),
+ TuyaSensorEntityDescription(
+ key=DPCode.CUR_VOLTAGE,
+ translation_key="voltage",
+ device_class=SensorDeviceClass.VOLTAGE,
+ state_class=SensorStateClass.MEASUREMENT,
+ entity_registry_enabled_default=False,
+ ),
+ ),
# CO Detector
# https://developer.tuya.com/en/docs/iot/categorycobj?id=Kaiuz3u1j6q1v
"cobj": (
diff --git a/homeassistant/components/tuya/siren.py b/homeassistant/components/tuya/siren.py
index 334dced134d..6f7dfe4c96c 100644
--- a/homeassistant/components/tuya/siren.py
+++ b/homeassistant/components/tuya/siren.py
@@ -11,6 +11,7 @@ from homeassistant.components.siren import (
SirenEntityDescription,
SirenEntityFeature,
)
+from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
@@ -43,6 +44,14 @@ SIRENS: dict[str, tuple[SirenEntityDescription, ...]] = {
key=DPCode.SIREN_SWITCH,
),
),
+ # CO2 Detector
+ # https://developer.tuya.com/en/docs/iot/categoryco2bj?id=Kaiuz3wes7yuy
+ "co2bj": (
+ SirenEntityDescription(
+ key=DPCode.ALARM_SWITCH,
+ entity_category=EntityCategory.CONFIG,
+ ),
+ ),
}
diff --git a/homeassistant/components/tuya/strings.json b/homeassistant/components/tuya/strings.json
index 0f005821cbb..8ec61cc8aa5 100644
--- a/homeassistant/components/tuya/strings.json
+++ b/homeassistant/components/tuya/strings.json
@@ -119,6 +119,9 @@
}
},
"number": {
+ "alarm_duration": {
+ "name": "Alarm duration"
+ },
"temperature": {
"name": "[%key:component::sensor::entity_component::temperature::name%]"
},
diff --git a/homeassistant/components/tuya/switch.py b/homeassistant/components/tuya/switch.py
index 77432c5b9a5..2b5e6fec4a6 100644
--- a/homeassistant/components/tuya/switch.py
+++ b/homeassistant/components/tuya/switch.py
@@ -528,6 +528,13 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = {
translation_key="switch",
),
),
+ # Hejhome whitelabel Fingerbot
+ "znjxs": (
+ SwitchEntityDescription(
+ key=DPCode.SWITCH,
+ translation_key="switch",
+ ),
+ ),
# IoT Switch?
# Note: Undocumented
"tdq": (
diff --git a/homeassistant/components/tuya/vacuum.py b/homeassistant/components/tuya/vacuum.py
index 2e0a154e670..738492102a1 100644
--- a/homeassistant/components/tuya/vacuum.py
+++ b/homeassistant/components/tuya/vacuum.py
@@ -7,13 +7,10 @@ from typing import Any
from tuya_sharing import CustomerDevice, Manager
from homeassistant.components.vacuum import (
- STATE_CLEANING,
- STATE_DOCKED,
- STATE_RETURNING,
StateVacuumEntity,
+ VacuumActivity,
VacuumEntityFeature,
)
-from homeassistant.const import STATE_IDLE, STATE_PAUSED
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
@@ -24,29 +21,29 @@ from .entity import EnumTypeData, IntegerTypeData, TuyaEntity
TUYA_MODE_RETURN_HOME = "chargego"
TUYA_STATUS_TO_HA = {
- "charge_done": STATE_DOCKED,
- "chargecompleted": STATE_DOCKED,
- "chargego": STATE_DOCKED,
- "charging": STATE_DOCKED,
- "cleaning": STATE_CLEANING,
- "docking": STATE_RETURNING,
- "goto_charge": STATE_RETURNING,
- "goto_pos": STATE_CLEANING,
- "mop_clean": STATE_CLEANING,
- "part_clean": STATE_CLEANING,
- "paused": STATE_PAUSED,
- "pick_zone_clean": STATE_CLEANING,
- "pos_arrived": STATE_CLEANING,
- "pos_unarrive": STATE_CLEANING,
- "random": STATE_CLEANING,
- "sleep": STATE_IDLE,
- "smart_clean": STATE_CLEANING,
- "smart": STATE_CLEANING,
- "spot_clean": STATE_CLEANING,
- "standby": STATE_IDLE,
- "wall_clean": STATE_CLEANING,
- "wall_follow": STATE_CLEANING,
- "zone_clean": STATE_CLEANING,
+ "charge_done": VacuumActivity.DOCKED,
+ "chargecompleted": VacuumActivity.DOCKED,
+ "chargego": VacuumActivity.DOCKED,
+ "charging": VacuumActivity.DOCKED,
+ "cleaning": VacuumActivity.CLEANING,
+ "docking": VacuumActivity.RETURNING,
+ "goto_charge": VacuumActivity.RETURNING,
+ "goto_pos": VacuumActivity.CLEANING,
+ "mop_clean": VacuumActivity.CLEANING,
+ "part_clean": VacuumActivity.CLEANING,
+ "paused": VacuumActivity.PAUSED,
+ "pick_zone_clean": VacuumActivity.CLEANING,
+ "pos_arrived": VacuumActivity.CLEANING,
+ "pos_unarrive": VacuumActivity.CLEANING,
+ "random": VacuumActivity.CLEANING,
+ "sleep": VacuumActivity.IDLE,
+ "smart_clean": VacuumActivity.CLEANING,
+ "smart": VacuumActivity.CLEANING,
+ "spot_clean": VacuumActivity.CLEANING,
+ "standby": VacuumActivity.IDLE,
+ "wall_clean": VacuumActivity.CLEANING,
+ "wall_follow": VacuumActivity.CLEANING,
+ "zone_clean": VacuumActivity.CLEANING,
}
@@ -137,12 +134,12 @@ class TuyaVacuumEntity(TuyaEntity, StateVacuumEntity):
return self.device.status.get(DPCode.SUCTION)
@property
- def state(self) -> str | None:
+ def activity(self) -> VacuumActivity | None:
"""Return Tuya vacuum device state."""
if self.device.status.get(DPCode.PAUSE) and not (
self.device.status.get(DPCode.STATUS)
):
- return STATE_PAUSED
+ return VacuumActivity.PAUSED
if not (status := self.device.status.get(DPCode.STATUS)):
return None
return TUYA_STATUS_TO_HA.get(status)
diff --git a/homeassistant/components/twentemilieu/__init__.py b/homeassistant/components/twentemilieu/__init__.py
index b6728b96536..1359e707601 100644
--- a/homeassistant/components/twentemilieu/__init__.py
+++ b/homeassistant/components/twentemilieu/__init__.py
@@ -2,65 +2,35 @@
from __future__ import annotations
-from datetime import date, timedelta
-
-from twentemilieu import TwenteMilieu, WasteType
import voluptuous as vol
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ID, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
-from homeassistant.helpers.aiohttp_client import async_get_clientsession
-from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
-from .const import CONF_HOUSE_LETTER, CONF_HOUSE_NUMBER, CONF_POST_CODE, DOMAIN, LOGGER
-
-SCAN_INTERVAL = timedelta(seconds=3600)
+from .coordinator import TwenteMilieuConfigEntry, TwenteMilieuDataUpdateCoordinator
SERVICE_UPDATE = "update"
SERVICE_SCHEMA = vol.Schema({vol.Optional(CONF_ID): cv.string})
PLATFORMS = [Platform.CALENDAR, Platform.SENSOR]
-type TwenteMilieuDataUpdateCoordinator = DataUpdateCoordinator[
- dict[WasteType, list[date]]
-]
-type TwenteMilieuConfigEntry = ConfigEntry[TwenteMilieuDataUpdateCoordinator]
-
-async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_setup_entry(
+ hass: HomeAssistant, entry: TwenteMilieuConfigEntry
+) -> bool:
"""Set up Twente Milieu from a config entry."""
- session = async_get_clientsession(hass)
- twentemilieu = TwenteMilieu(
- post_code=entry.data[CONF_POST_CODE],
- house_number=entry.data[CONF_HOUSE_NUMBER],
- house_letter=entry.data[CONF_HOUSE_LETTER],
- session=session,
- )
-
- coordinator: TwenteMilieuDataUpdateCoordinator = DataUpdateCoordinator(
- hass,
- LOGGER,
- config_entry=entry,
- name=DOMAIN,
- update_interval=SCAN_INTERVAL,
- update_method=twentemilieu.update,
- )
+ coordinator = TwenteMilieuDataUpdateCoordinator(hass, entry)
await coordinator.async_config_entry_first_refresh()
- # For backwards compat, set unique ID
- if entry.unique_id is None:
- hass.config_entries.async_update_entry(
- entry, unique_id=str(entry.data[CONF_ID])
- )
-
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: TwenteMilieuConfigEntry
+) -> bool:
"""Unload Twente Milieu config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
diff --git a/homeassistant/components/twentemilieu/calendar.py b/homeassistant/components/twentemilieu/calendar.py
index 8e7452823b7..d163ae4e564 100644
--- a/homeassistant/components/twentemilieu/calendar.py
+++ b/homeassistant/components/twentemilieu/calendar.py
@@ -10,8 +10,8 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
import homeassistant.util.dt as dt_util
-from . import TwenteMilieuConfigEntry
from .const import WASTE_TYPE_TO_DESCRIPTION
+from .coordinator import TwenteMilieuConfigEntry
from .entity import TwenteMilieuEntity
diff --git a/homeassistant/components/twentemilieu/coordinator.py b/homeassistant/components/twentemilieu/coordinator.py
new file mode 100644
index 00000000000..d2cf5a887ef
--- /dev/null
+++ b/homeassistant/components/twentemilieu/coordinator.py
@@ -0,0 +1,49 @@
+"""Data update coordinator for Twente Milieu."""
+
+from __future__ import annotations
+
+from datetime import date
+
+from twentemilieu import TwenteMilieu, WasteType
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
+
+from .const import (
+ CONF_HOUSE_LETTER,
+ CONF_HOUSE_NUMBER,
+ CONF_POST_CODE,
+ DOMAIN,
+ LOGGER,
+ SCAN_INTERVAL,
+)
+
+type TwenteMilieuConfigEntry = ConfigEntry[TwenteMilieuDataUpdateCoordinator]
+
+
+class TwenteMilieuDataUpdateCoordinator(
+ DataUpdateCoordinator[dict[WasteType, list[date]]]
+):
+ """Class to manage fetching Twente Milieu data."""
+
+ def __init__(self, hass: HomeAssistant, entry: TwenteMilieuConfigEntry) -> None:
+ """Initialize Twente Milieu data update coordinator."""
+ self.twentemilieu = TwenteMilieu(
+ post_code=entry.data[CONF_POST_CODE],
+ house_number=entry.data[CONF_HOUSE_NUMBER],
+ house_letter=entry.data[CONF_HOUSE_LETTER],
+ session=async_get_clientsession(hass),
+ )
+ super().__init__(
+ hass,
+ LOGGER,
+ name=DOMAIN,
+ update_interval=SCAN_INTERVAL,
+ config_entry=entry,
+ )
+
+ async def _async_update_data(self) -> dict[WasteType, list[date]]:
+ """Fetch Twente Milieu data."""
+ return await self.twentemilieu.update()
diff --git a/homeassistant/components/twentemilieu/diagnostics.py b/homeassistant/components/twentemilieu/diagnostics.py
index 9de3f9bfaff..cb3b411c530 100644
--- a/homeassistant/components/twentemilieu/diagnostics.py
+++ b/homeassistant/components/twentemilieu/diagnostics.py
@@ -4,12 +4,13 @@ from __future__ import annotations
from typing import Any
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
+from .coordinator import TwenteMilieuConfigEntry
+
async def async_get_config_entry_diagnostics(
- hass: HomeAssistant, entry: ConfigEntry
+ hass: HomeAssistant, entry: TwenteMilieuConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
return {
diff --git a/homeassistant/components/twentemilieu/entity.py b/homeassistant/components/twentemilieu/entity.py
index 896a8e32de9..660dd16288c 100644
--- a/homeassistant/components/twentemilieu/entity.py
+++ b/homeassistant/components/twentemilieu/entity.py
@@ -2,14 +2,13 @@
from __future__ import annotations
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ID
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.update_coordinator import CoordinatorEntity
-from . import TwenteMilieuDataUpdateCoordinator
from .const import DOMAIN
+from .coordinator import TwenteMilieuConfigEntry, TwenteMilieuDataUpdateCoordinator
class TwenteMilieuEntity(CoordinatorEntity[TwenteMilieuDataUpdateCoordinator], Entity):
@@ -17,7 +16,7 @@ class TwenteMilieuEntity(CoordinatorEntity[TwenteMilieuDataUpdateCoordinator], E
_attr_has_entity_name = True
- def __init__(self, entry: ConfigEntry) -> None:
+ def __init__(self, entry: TwenteMilieuConfigEntry) -> None:
"""Initialize the Twente Milieu entity."""
super().__init__(coordinator=entry.runtime_data)
self._attr_device_info = DeviceInfo(
diff --git a/homeassistant/components/twentemilieu/manifest.json b/homeassistant/components/twentemilieu/manifest.json
index aef70aa6a10..b1cb98dbca6 100644
--- a/homeassistant/components/twentemilieu/manifest.json
+++ b/homeassistant/components/twentemilieu/manifest.json
@@ -7,6 +7,6 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["twentemilieu"],
- "quality_scale": "platinum",
- "requirements": ["twentemilieu==2.0.1"]
+ "quality_scale": "silver",
+ "requirements": ["twentemilieu==2.2.1"]
}
diff --git a/homeassistant/components/twentemilieu/quality_scale.yaml b/homeassistant/components/twentemilieu/quality_scale.yaml
new file mode 100644
index 00000000000..42ff152cb4d
--- /dev/null
+++ b/homeassistant/components/twentemilieu/quality_scale.yaml
@@ -0,0 +1,109 @@
+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
+ log-when-unavailable: done
+ entity-unavailable: done
+ action-exceptions:
+ status: exempt
+ comment: |
+ This integration does not provide additional actions.
+ reauthentication-flow:
+ status: exempt
+ comment: |
+ This integration does not require authentication.
+ parallel-updates:
+ status: exempt
+ comment: |
+ This integration only polls data using a coordinator.
+ Since the integration is read-only and poll-only (only provide sensor
+ data), there is no need to implement parallel updates.
+ test-coverage: done
+ integration-owner: done
+ docs-installation-parameters: done
+ docs-configuration-parameters:
+ status: exempt
+ comment: |
+ This integration does not have an options flow.
+
+ # Gold
+ entity-translations:
+ status: todo
+ comment: |
+ The calendar entity name isn't translated yet.
+ entity-device-class: done
+ devices: done
+ entity-category: done
+ entity-disabled-by-default: done
+ discovery:
+ status: exempt
+ comment: |
+ This integration cannot be discovered, it is a connecting to a service
+ provider, which uses the users home address to get the data.
+ stale-devices:
+ status: exempt
+ comment: |
+ This integration has a fixed single device which represents the service.
+ diagnostics: done
+ exception-translations:
+ status: todo
+ comment: |
+ The coordinator raises, and currently, doesn't provide a translation for it.
+ icon-translations: done
+ reconfiguration-flow: todo
+ dynamic-devices:
+ status: exempt
+ comment: |
+ This integration has a fixed single device which represents the service.
+ discovery-update-info:
+ status: exempt
+ comment: |
+ This integration cannot be discovered, it is a connecting to a service
+ provider, which uses the users home address to get the data.
+ 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:
+ status: exempt
+ comment: |
+ This is an service, which doesn't integrate with any devices.
+ 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/twentemilieu/sensor.py b/homeassistant/components/twentemilieu/sensor.py
index 2d2e3de0f0e..4605ede1f87 100644
--- a/homeassistant/components/twentemilieu/sensor.py
+++ b/homeassistant/components/twentemilieu/sensor.py
@@ -12,12 +12,12 @@ from homeassistant.components.sensor import (
SensorEntity,
SensorEntityDescription,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ID
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN
+from .coordinator import TwenteMilieuConfigEntry
from .entity import TwenteMilieuEntity
@@ -64,7 +64,7 @@ SENSORS: tuple[TwenteMilieuSensorDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
- entry: ConfigEntry,
+ entry: TwenteMilieuConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Twente Milieu sensor based on a config entry."""
@@ -80,7 +80,7 @@ class TwenteMilieuSensor(TwenteMilieuEntity, SensorEntity):
def __init__(
self,
- entry: ConfigEntry,
+ entry: TwenteMilieuConfigEntry,
description: TwenteMilieuSensorDescription,
) -> None:
"""Initialize the Twente Milieu entity."""
diff --git a/homeassistant/components/twentemilieu/strings.json b/homeassistant/components/twentemilieu/strings.json
index 7797167ea0b..5c40df1b0c2 100644
--- a/homeassistant/components/twentemilieu/strings.json
+++ b/homeassistant/components/twentemilieu/strings.json
@@ -7,6 +7,11 @@
"post_code": "Postal code",
"house_number": "House number",
"house_letter": "House letter/additional"
+ },
+ "data_description": {
+ "post_code": "The postal code of the address, for example 7500AA",
+ "house_number": "The house number of the address",
+ "house_letter": "The house letter or additional information of the address"
}
}
},
diff --git a/homeassistant/components/twilio_call/manifest.json b/homeassistant/components/twilio_call/manifest.json
index 88f09efdeed..f4389e1c7d7 100644
--- a/homeassistant/components/twilio_call/manifest.json
+++ b/homeassistant/components/twilio_call/manifest.json
@@ -5,5 +5,6 @@
"dependencies": ["twilio"],
"documentation": "https://www.home-assistant.io/integrations/twilio_call",
"iot_class": "cloud_push",
- "loggers": ["twilio"]
+ "loggers": ["twilio"],
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/twilio_sms/manifest.json b/homeassistant/components/twilio_sms/manifest.json
index 8736d58c0da..eed5a1113c6 100644
--- a/homeassistant/components/twilio_sms/manifest.json
+++ b/homeassistant/components/twilio_sms/manifest.json
@@ -4,5 +4,6 @@
"codeowners": [],
"dependencies": ["twilio"],
"documentation": "https://www.home-assistant.io/integrations/twilio_sms",
- "iot_class": "cloud_push"
+ "iot_class": "cloud_push",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/twinkly/__init__.py b/homeassistant/components/twinkly/__init__.py
index b09e58ff12f..cd29ffaf423 100644
--- a/homeassistant/components/twinkly/__init__.py
+++ b/homeassistant/components/twinkly/__init__.py
@@ -1,52 +1,83 @@
"""The twinkly component."""
+import logging
+
from aiohttp import ClientError
from ttls.client import Twinkly
from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import ATTR_SW_VERSION, CONF_HOST, Platform
+from homeassistant.const import CONF_HOST, Platform
from homeassistant.core import HomeAssistant
-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
-from .const import ATTR_VERSION, DATA_CLIENT, DATA_DEVICE_INFO, DOMAIN
+from .const import DOMAIN
+from .coordinator import TwinklyCoordinator
-PLATFORMS = [Platform.LIGHT]
+PLATFORMS = [Platform.LIGHT, Platform.SELECT]
+
+_LOGGER = logging.getLogger(__name__)
-async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+type TwinklyConfigEntry = ConfigEntry[TwinklyCoordinator]
+
+
+async def async_setup_entry(hass: HomeAssistant, entry: TwinklyConfigEntry) -> bool:
"""Set up entries from config flow."""
- hass.data.setdefault(DOMAIN, {})
-
# We setup the client here so if at some point we add any other entity for this device,
# we will be able to properly share the connection.
host = entry.data[CONF_HOST]
- hass.data[DOMAIN].setdefault(entry.entry_id, {})
-
client = Twinkly(host, async_get_clientsession(hass))
- try:
- device_info = await client.get_details()
- software_version = await client.get_firmware_version()
- except (TimeoutError, ClientError) as exception:
- raise ConfigEntryNotReady from exception
+ coordinator = TwinklyCoordinator(hass, client)
+
+ await coordinator.async_config_entry_first_refresh()
+
+ entry.runtime_data = coordinator
- hass.data[DOMAIN][entry.entry_id] = {
- DATA_CLIENT: client,
- DATA_DEVICE_INFO: device_info,
- ATTR_SW_VERSION: software_version.get(ATTR_VERSION),
- }
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: TwinklyConfigEntry) -> bool:
"""Remove a twinkly entry."""
- unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
- if unload_ok:
- hass.data[DOMAIN].pop(entry.entry_id)
+ return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
- return unload_ok
+
+async def async_migrate_entry(hass: HomeAssistant, entry: TwinklyConfigEntry) -> bool:
+ """Migrate old entry."""
+ if entry.minor_version == 1:
+ client = Twinkly(entry.data[CONF_HOST], async_get_clientsession(hass))
+ try:
+ device_info = await client.get_details()
+ except (TimeoutError, ClientError) as exception:
+ _LOGGER.error("Error while migrating: %s", exception)
+ return False
+ identifier = entry.unique_id
+ assert identifier is not None
+ entity_registry = er.async_get(hass)
+ entity_id = entity_registry.async_get_entity_id("light", DOMAIN, identifier)
+ if entity_id:
+ entity_entry = entity_registry.async_get(entity_id)
+ assert entity_entry is not None
+ entity_registry.async_update_entity(
+ entity_entry.entity_id, new_unique_id=device_info["mac"]
+ )
+ device_registry = dr.async_get(hass)
+ device_entry = device_registry.async_get_device(
+ identifiers={(DOMAIN, identifier)}
+ )
+ if device_entry:
+ device_registry.async_update_device(
+ device_entry.id, new_identifiers={(DOMAIN, device_info["mac"])}
+ )
+ hass.config_entries.async_update_entry(
+ entry,
+ unique_id=device_info["mac"],
+ minor_version=2,
+ )
+
+ return True
diff --git a/homeassistant/components/twinkly/config_flow.py b/homeassistant/components/twinkly/config_flow.py
index 68c455dc619..53ba8f084c3 100644
--- a/homeassistant/components/twinkly/config_flow.py
+++ b/homeassistant/components/twinkly/config_flow.py
@@ -23,6 +23,7 @@ class TwinklyConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle twinkly config flow."""
VERSION = 1
+ MINOR_VERSION = 2
def __init__(self) -> None:
"""Initialize the config flow."""
@@ -45,7 +46,9 @@ class TwinklyConfigFlow(ConfigFlow, domain=DOMAIN):
except (TimeoutError, ClientError):
errors[CONF_HOST] = "cannot_connect"
else:
- await self.async_set_unique_id(device_info[DEV_ID])
+ await self.async_set_unique_id(
+ device_info["mac"], raise_on_progress=False
+ )
self._abort_if_unique_id_configured()
return self._create_entry_from_device(device_info, host)
@@ -62,7 +65,7 @@ class TwinklyConfigFlow(ConfigFlow, domain=DOMAIN):
device_info = await Twinkly(
discovery_info.ip, async_get_clientsession(self.hass)
).get_details()
- await self.async_set_unique_id(device_info[DEV_ID])
+ await self.async_set_unique_id(device_info["mac"])
self._abort_if_unique_id_configured(updates={CONF_HOST: discovery_info.ip})
self._discovered_device = (device_info, discovery_info.ip)
@@ -77,6 +80,9 @@ class TwinklyConfigFlow(ConfigFlow, domain=DOMAIN):
return self._create_entry_from_device(device_info, host)
self._set_confirm_only()
+ self.context["title_placeholders"] = {
+ "name": device_info[DEV_NAME],
+ }
placeholders = {
"model": device_info[DEV_MODEL],
"name": device_info[DEV_NAME],
diff --git a/homeassistant/components/twinkly/const.py b/homeassistant/components/twinkly/const.py
index f33024ed156..488b213b895 100644
--- a/homeassistant/components/twinkly/const.py
+++ b/homeassistant/components/twinkly/const.py
@@ -15,8 +15,5 @@ DEV_LED_PROFILE = "led_profile"
DEV_PROFILE_RGB = "RGB"
DEV_PROFILE_RGBW = "RGBW"
-DATA_CLIENT = "client"
-DATA_DEVICE_INFO = "device_info"
-
# Minimum version required to support effects
MIN_EFFECT_VERSION = "2.7.1"
diff --git a/homeassistant/components/twinkly/coordinator.py b/homeassistant/components/twinkly/coordinator.py
new file mode 100644
index 00000000000..627fb0b39ba
--- /dev/null
+++ b/homeassistant/components/twinkly/coordinator.py
@@ -0,0 +1,106 @@
+"""Coordinator for Twinkly."""
+
+from dataclasses import dataclass
+from datetime import timedelta
+import logging
+from typing import Any
+
+from aiohttp import ClientError
+from awesomeversion import AwesomeVersion
+from ttls.client import Twinkly, TwinklyError
+
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers import device_registry as dr
+from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
+
+from .const import DEV_NAME, DOMAIN, MIN_EFFECT_VERSION
+
+_LOGGER = logging.getLogger(__name__)
+
+
+@dataclass
+class TwinklyData:
+ """Class for Twinkly data."""
+
+ device_info: dict[str, Any]
+ brightness: int
+ is_on: bool
+ movies: dict[int, str]
+ current_movie: int | None
+ current_mode: str | None
+
+
+class TwinklyCoordinator(DataUpdateCoordinator[TwinklyData]):
+ """Class to manage fetching Twinkly data from API."""
+
+ software_version: str
+ supports_effects: bool
+ device_name: str
+
+ def __init__(self, hass: HomeAssistant, client: Twinkly) -> None:
+ """Initialize global Twinkly data updater."""
+ super().__init__(
+ hass,
+ _LOGGER,
+ name=DOMAIN,
+ update_interval=timedelta(seconds=30),
+ )
+ self.client = client
+
+ async def _async_setup(self) -> None:
+ """Set up the Twinkly data."""
+ try:
+ software_version = await self.client.get_firmware_version()
+ self.device_name = (await self.client.get_details())[DEV_NAME]
+ except (TimeoutError, ClientError) as exception:
+ raise UpdateFailed from exception
+ self.software_version = software_version["version"]
+ self.supports_effects = AwesomeVersion(self.software_version) >= AwesomeVersion(
+ MIN_EFFECT_VERSION
+ )
+
+ async def _async_update_data(self) -> TwinklyData:
+ """Fetch data from Twinkly."""
+ movies: list[dict[str, Any]] = []
+ current_movie: dict[str, Any] = {}
+ try:
+ 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:
+ raise UpdateFailed from exception
+ if self.supports_effects:
+ try:
+ current_movie = await self.client.get_current_movie()
+ except (TwinklyError, TimeoutError, ClientError) as exception:
+ _LOGGER.debug("Error fetching current movie: %s", exception)
+ brightness = (
+ int(brightness["value"]) if brightness["mode"] == "enabled" else 100
+ )
+ brightness = int(round(brightness * 2.55)) if is_on else 0
+ if self.device_name != device_info[DEV_NAME]:
+ self._async_update_device_info(device_info[DEV_NAME])
+ return TwinklyData(
+ device_info,
+ brightness,
+ 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:
+ """Update the device info."""
+ device_registry = dr.async_get(self.hass)
+ device = device_registry.async_get_device(
+ identifiers={(DOMAIN, self.data.device_info["mac"])},
+ )
+ if device:
+ device_registry.async_update_device(
+ device.id,
+ name=name,
+ )
diff --git a/homeassistant/components/twinkly/diagnostics.py b/homeassistant/components/twinkly/diagnostics.py
index e188e92ecd5..d732ce14929 100644
--- a/homeassistant/components/twinkly/diagnostics.py
+++ b/homeassistant/components/twinkly/diagnostics.py
@@ -6,18 +6,18 @@ from typing import Any
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_SW_VERSION, CONF_HOST, CONF_IP_ADDRESS, CONF_MAC
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
-from .const import DATA_DEVICE_INFO, DOMAIN
+from . import TwinklyConfigEntry
+from .const import DOMAIN
TO_REDACT = [CONF_HOST, CONF_IP_ADDRESS, CONF_MAC]
async def async_get_config_entry_diagnostics(
- hass: HomeAssistant, entry: ConfigEntry
+ hass: HomeAssistant, entry: TwinklyConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a Twinkly config entry."""
attributes = None
@@ -34,8 +34,8 @@ async def async_get_config_entry_diagnostics(
return async_redact_data(
{
"entry": entry.as_dict(),
- "device_info": hass.data[DOMAIN][entry.entry_id][DATA_DEVICE_INFO],
- ATTR_SW_VERSION: hass.data[DOMAIN][entry.entry_id][ATTR_SW_VERSION],
+ "device_info": entry.runtime_data.data.device_info,
+ ATTR_SW_VERSION: entry.runtime_data.software_version,
"attributes": attributes,
},
TO_REDACT,
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 6f6dffe63d2..de55aa5f217 100644
--- a/homeassistant/components/twinkly/light.py
+++ b/homeassistant/components/twinkly/light.py
@@ -5,10 +5,6 @@ from __future__ import annotations
import logging
from typing import Any
-from aiohttp import ClientError
-from awesomeversion import AwesomeVersion
-from ttls.client import Twinkly
-
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
ATTR_EFFECT,
@@ -18,67 +14,38 @@ from homeassistant.components.light import (
LightEntity,
LightEntityFeature,
)
-from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import (
- ATTR_SW_VERSION,
- CONF_HOST,
- CONF_ID,
- CONF_MODEL,
- CONF_NAME,
-)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers import device_registry as dr
-from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from .const import (
- DATA_CLIENT,
- DATA_DEVICE_INFO,
- DEV_LED_PROFILE,
- DEV_MODEL,
- DEV_NAME,
- DEV_PROFILE_RGB,
- DEV_PROFILE_RGBW,
- DOMAIN,
- MIN_EFFECT_VERSION,
-)
+from . import TwinklyConfigEntry, TwinklyCoordinator
+from .const import DEV_LED_PROFILE, DEV_PROFILE_RGB, DEV_PROFILE_RGBW
+from .entity import TwinklyEntity
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ config_entry: TwinklyConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Setups an entity from a config entry (UI config flow)."""
-
- client = hass.data[DOMAIN][config_entry.entry_id][DATA_CLIENT]
- device_info = hass.data[DOMAIN][config_entry.entry_id][DATA_DEVICE_INFO]
- software_version = hass.data[DOMAIN][config_entry.entry_id][ATTR_SW_VERSION]
-
- entity = TwinklyLight(config_entry, client, device_info, software_version)
+ entity = TwinklyLight(config_entry.runtime_data)
async_add_entities([entity], update_before_add=True)
-class TwinklyLight(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"
- def __init__(
- self,
- conf: ConfigEntry,
- client: Twinkly,
- device_info,
- software_version: str | None = None,
- ) -> None:
+ def __init__(self, coordinator: TwinklyCoordinator) -> None:
"""Initialize a TwinklyLight entity."""
- self._attr_unique_id: str = conf.data[CONF_ID]
- self._conf = conf
+ super().__init__(coordinator)
+ device_info = coordinator.data.device_info
+ self._attr_unique_id = device_info["mac"]
if device_info.get(DEV_LED_PROFILE) == DEV_PROFILE_RGBW:
self._attr_supported_color_modes = {ColorMode.RGBW}
@@ -91,64 +58,27 @@ class TwinklyLight(LightEntity):
else:
self._attr_supported_color_modes = {ColorMode.BRIGHTNESS}
self._attr_color_mode = ColorMode.BRIGHTNESS
-
- # Those are saved in the config entry in order to have meaningful values even
- # if the device is currently offline.
- # They are expected to be updated using the device_info.
- self._name = conf.data[CONF_NAME] or "Twinkly light"
- self._model = conf.data[CONF_MODEL]
-
- self._client = client
-
- # Set default state before any update
- self._attr_is_on = False
- self._attr_available = False
- self._current_movie: dict[Any, Any] = {}
- self._movies: list[Any] = []
- self._software_version = software_version
- # We guess that most devices are "new" and support effects
- self._attr_supported_features = LightEntityFeature.EFFECT
-
- @property
- def device_info(self) -> DeviceInfo | None:
- """Get device specific attributes."""
- return DeviceInfo(
- identifiers={(DOMAIN, self._attr_unique_id)},
- manufacturer="LEDWORKS",
- model=self._model,
- name=self._name,
- sw_version=self._software_version,
- )
+ self.client = coordinator.client
+ if coordinator.supports_effects:
+ self._attr_supported_features = LightEntityFeature.EFFECT
+ self._update_attr()
@property
def effect(self) -> str | None:
"""Return the current effect."""
- if "name" in self._current_movie:
- return f"{self._current_movie['id']} {self._current_movie['name']}"
+ if (current_movie_id := self.coordinator.data.current_movie) is not None:
+ return (
+ f"{current_movie_id} {self.coordinator.data.movies[current_movie_id]}"
+ )
return None
@property
def effect_list(self) -> list[str]:
"""Return the list of saved effects."""
- return [f"{movie['id']} {movie['name']}" for movie in self._movies]
-
- async def async_added_to_hass(self) -> None:
- """Device is added to hass."""
- if self._software_version:
- if AwesomeVersion(self._software_version) < AwesomeVersion(
- MIN_EFFECT_VERSION
- ):
- self._attr_supported_features = (
- self.supported_features & ~LightEntityFeature.EFFECT
- )
- device_registry = dr.async_get(self.hass)
- device_entry = device_registry.async_get_device(
- {(DOMAIN, self._attr_unique_id)}, set()
- )
- if device_entry:
- device_registry.async_update_device(
- device_entry.id, sw_version=self._software_version
- )
+ return [
+ f"{identifier} {name}"
+ for identifier, name in self.coordinator.data.movies.items()
+ ]
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn device on."""
@@ -158,29 +88,29 @@ class TwinklyLight(LightEntity):
# If brightness is 0, the twinkly will only "disable" the brightness,
# which means that it will be 100%.
if brightness == 0:
- await self._client.turn_off()
+ await self.client.turn_off()
return
- await self._client.set_brightness(brightness)
+ await self.client.set_brightness(brightness)
if (
ATTR_RGBW_COLOR in kwargs
and kwargs[ATTR_RGBW_COLOR] != self._attr_rgbw_color
):
- await self._client.interview()
+ await self.client.interview()
if LightEntityFeature.EFFECT & self.supported_features:
# Static color only supports rgb
- await self._client.set_static_colour(
+ await self.client.set_static_colour(
(
kwargs[ATTR_RGBW_COLOR][0],
kwargs[ATTR_RGBW_COLOR][1],
kwargs[ATTR_RGBW_COLOR][2],
)
)
- await self._client.set_mode("color")
- self._client.default_mode = "color"
+ await self.client.set_mode("color")
+ self.client.default_mode = "color"
else:
- await self._client.set_cycle_colours(
+ await self.client.set_cycle_colours(
(
kwargs[ATTR_RGBW_COLOR][3],
kwargs[ATTR_RGBW_COLOR][0],
@@ -188,20 +118,20 @@ class TwinklyLight(LightEntity):
kwargs[ATTR_RGBW_COLOR][2],
)
)
- await self._client.set_mode("movie")
- self._client.default_mode = "movie"
+ await self.client.set_mode("movie")
+ self.client.default_mode = "movie"
self._attr_rgbw_color = kwargs[ATTR_RGBW_COLOR]
if ATTR_RGB_COLOR in kwargs and kwargs[ATTR_RGB_COLOR] != self._attr_rgb_color:
- await self._client.interview()
+ await self.client.interview()
if LightEntityFeature.EFFECT & self.supported_features:
- await self._client.set_static_colour(kwargs[ATTR_RGB_COLOR])
- await self._client.set_mode("color")
- self._client.default_mode = "color"
+ await self.client.set_static_colour(kwargs[ATTR_RGB_COLOR])
+ await self.client.set_mode("color")
+ self.client.default_mode = "color"
else:
- await self._client.set_cycle_colours(kwargs[ATTR_RGB_COLOR])
- await self._client.set_mode("movie")
- self._client.default_mode = "movie"
+ await self.client.set_cycle_colours(kwargs[ATTR_RGB_COLOR])
+ await self.client.set_mode("movie")
+ self.client.default_mode = "movie"
self._attr_rgb_color = kwargs[ATTR_RGB_COLOR]
@@ -210,100 +140,29 @@ class TwinklyLight(LightEntity):
and LightEntityFeature.EFFECT & self.supported_features
):
movie_id = kwargs[ATTR_EFFECT].split(" ")[0]
- if "id" not in self._current_movie or int(movie_id) != int(
- self._current_movie["id"]
+ if (
+ self.coordinator.data.current_movie is None
+ or int(movie_id) != self.coordinator.data.current_movie
):
- await self._client.interview()
- await self._client.set_current_movie(int(movie_id))
- await self._client.set_mode("movie")
- self._client.default_mode = "movie"
+ await self.client.interview()
+ await self.client.set_current_movie(int(movie_id))
+ await self.client.set_mode("movie")
+ self.client.default_mode = "movie"
if not self._attr_is_on:
- await self._client.turn_on()
+ await self.client.turn_on()
+ await self.coordinator.async_refresh()
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn device off."""
- await self._client.turn_off()
+ await self.client.turn_off()
+ await self.coordinator.async_refresh()
- async def async_update(self) -> None:
- """Asynchronously updates the device properties."""
- _LOGGER.debug("Updating '%s'", self._client.host)
+ def _update_attr(self) -> None:
+ """Update the entity attributes."""
+ self._attr_is_on = self.coordinator.data.is_on
+ self._attr_brightness = self.coordinator.data.brightness
- try:
- self._attr_is_on = await self._client.is_on()
-
- brightness = await self._client.get_brightness()
- brightness_value = (
- int(brightness["value"]) if brightness["mode"] == "enabled" else 100
- )
-
- self._attr_brightness = (
- int(round(brightness_value * 2.55)) if self._attr_is_on else 0
- )
-
- device_info = await self._client.get_details()
-
- if (
- DEV_NAME in device_info
- and DEV_MODEL in device_info
- and (
- device_info[DEV_NAME] != self._name
- or device_info[DEV_MODEL] != self._model
- )
- ):
- self._name = device_info[DEV_NAME]
- self._model = device_info[DEV_MODEL]
-
- # If the name has changed, persist it in conf entry,
- # so we will be able to restore this new name if hass
- # is started while the LED string is offline.
- self.hass.config_entries.async_update_entry(
- self._conf,
- data={
- CONF_HOST: self._client.host, # this cannot change
- CONF_ID: self._attr_unique_id, # this cannot change
- CONF_NAME: self._name,
- CONF_MODEL: self._model,
- },
- )
-
- device_registry = dr.async_get(self.hass)
- device_entry = device_registry.async_get_device(
- {(DOMAIN, self._attr_unique_id)}
- )
- if device_entry:
- device_registry.async_update_device(
- device_entry.id, name=self._name, model=self._model
- )
-
- if LightEntityFeature.EFFECT & self.supported_features:
- await self.async_update_movies()
- await self.async_update_current_movie()
-
- if not self._attr_available:
- _LOGGER.warning("Twinkly '%s' is now available", self._client.host)
-
- # We don't use the echo API to track the availability since
- # we already have to pull the device to get its state.
- self._attr_available = True
- except (TimeoutError, ClientError):
- # We log this as "info" as it's pretty common that the Christmas
- # light are not reachable in July
- if self._attr_available:
- _LOGGER.warning(
- "Twinkly '%s' is not reachable (client error)", self._client.host
- )
- self._attr_available = False
-
- async def async_update_movies(self) -> None:
- """Update the list of movies (effects)."""
- movies = await self._client.get_saved_movies()
- _LOGGER.debug("Movies: %s", movies)
- if movies and "movies" in movies:
- self._movies = movies["movies"]
-
- async def async_update_current_movie(self) -> None:
- """Update the current active movie."""
- current_movie = await self._client.get_current_movie()
- _LOGGER.debug("Current movie: %s", current_movie)
- if current_movie and "id" in current_movie:
- self._current_movie = current_movie
+ def _handle_coordinator_update(self) -> None:
+ """Handle updated data from the coordinator."""
+ self._update_attr()
+ super()._handle_coordinator_update()
diff --git a/homeassistant/components/twinkly/select.py b/homeassistant/components/twinkly/select.py
new file mode 100644
index 00000000000..2542d325b47
--- /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/twinkly/strings.json b/homeassistant/components/twinkly/strings.json
index 88bc67abbbd..bbc3d67373d 100644
--- a/homeassistant/components/twinkly/strings.json
+++ b/homeassistant/components/twinkly/strings.json
@@ -1,5 +1,6 @@
{
"config": {
+ "flow_title": "{name}",
"step": {
"user": {
"data": {
@@ -17,7 +18,7 @@
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
},
"abort": {
- "device_exists": "[%key:common::config_flow::abort::already_configured_device%]"
+ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
}
}
}
diff --git a/homeassistant/components/twitch/__init__.py b/homeassistant/components/twitch/__init__.py
index 6979a016447..22a1782f594 100644
--- a/homeassistant/components/twitch/__init__.py
+++ b/homeassistant/components/twitch/__init__.py
@@ -7,7 +7,6 @@ from typing import cast
from aiohttp.client_exceptions import ClientError, ClientResponseError
from twitchAPI.twitch import Twitch
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
@@ -17,11 +16,11 @@ from homeassistant.helpers.config_entry_oauth2_flow import (
async_get_config_entry_implementation,
)
-from .const import DOMAIN, OAUTH_SCOPES, PLATFORMS
-from .coordinator import TwitchCoordinator
+from .const import OAUTH_SCOPES, PLATFORMS
+from .coordinator import TwitchConfigEntry, TwitchCoordinator
-async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_setup_entry(hass: HomeAssistant, entry: TwitchConfigEntry) -> bool:
"""Set up Twitch from a config entry."""
implementation = cast(
LocalOAuth2Implementation,
@@ -47,18 +46,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
client.auto_refresh_auth = False
await client.set_user_authentication(access_token, scope=OAUTH_SCOPES)
- coordinator = TwitchCoordinator(hass, client, session)
-
+ coordinator = TwitchCoordinator(hass, client, session, 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: TwitchConfigEntry) -> bool:
"""Unload Twitch config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
diff --git a/homeassistant/components/twitch/coordinator.py b/homeassistant/components/twitch/coordinator.py
index c34eeaa5325..c61e80bd2b8 100644
--- a/homeassistant/components/twitch/coordinator.py
+++ b/homeassistant/components/twitch/coordinator.py
@@ -15,6 +15,8 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda
from .const import CONF_CHANNELS, DOMAIN, LOGGER, OAUTH_SCOPES
+type TwitchConfigEntry = ConfigEntry[TwitchCoordinator]
+
def chunk_list(lst: list, chunk_size: int) -> list[list]:
"""Split a list into chunks of chunk_size."""
@@ -44,12 +46,16 @@ class TwitchUpdate:
class TwitchCoordinator(DataUpdateCoordinator[dict[str, TwitchUpdate]]):
"""Class to manage fetching Twitch data."""
- config_entry: ConfigEntry
+ config_entry: TwitchConfigEntry
users: list[TwitchUser]
current_user: TwitchUser
def __init__(
- self, hass: HomeAssistant, twitch: Twitch, session: OAuth2Session
+ self,
+ hass: HomeAssistant,
+ twitch: Twitch,
+ session: OAuth2Session,
+ entry: TwitchConfigEntry,
) -> None:
"""Initialize the coordinator."""
self.twitch = twitch
@@ -58,6 +64,7 @@ class TwitchCoordinator(DataUpdateCoordinator[dict[str, TwitchUpdate]]):
LOGGER,
name=DOMAIN,
update_interval=timedelta(minutes=5),
+ config_entry=entry,
)
self.session = session
diff --git a/homeassistant/components/twitch/sensor.py b/homeassistant/components/twitch/sensor.py
index bd5fc509989..b407eae0319 100644
--- a/homeassistant/components/twitch/sensor.py
+++ b/homeassistant/components/twitch/sensor.py
@@ -4,16 +4,13 @@ from __future__ import annotations
from typing import Any
-from homeassistant.components.sensor import SensorEntity
-from homeassistant.config_entries import ConfigEntry
+from homeassistant.components.sensor import SensorDeviceClass, SensorEntity
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 . import TwitchCoordinator
-from .const import DOMAIN
-from .coordinator import TwitchUpdate
+from .coordinator import TwitchConfigEntry, TwitchCoordinator, TwitchUpdate
ATTR_GAME = "game"
ATTR_TITLE = "title"
@@ -34,11 +31,11 @@ PARALLEL_UPDATES = 1
async def async_setup_entry(
hass: HomeAssistant,
- entry: ConfigEntry,
+ entry: TwitchConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Initialize entries."""
- coordinator = hass.data[DOMAIN][entry.entry_id]
+ coordinator = entry.runtime_data
async_add_entities(
TwitchSensor(coordinator, channel_id) for channel_id in coordinator.data
@@ -49,6 +46,8 @@ class TwitchSensor(CoordinatorEntity[TwitchCoordinator], SensorEntity):
"""Representation of a Twitch channel."""
_attr_translation_key = "channel"
+ _attr_device_class = SensorDeviceClass.ENUM
+ _attr_options = [STATE_OFFLINE, STATE_STREAMING]
def __init__(self, coordinator: TwitchCoordinator, channel_id: str) -> None:
"""Initialize the sensor."""
@@ -82,8 +81,8 @@ class TwitchSensor(CoordinatorEntity[TwitchCoordinator], SensorEntity):
ATTR_TITLE: channel.title,
ATTR_STARTED_AT: channel.started_at,
ATTR_VIEWERS: channel.viewers,
+ ATTR_SUBSCRIPTION: False,
}
- resp[ATTR_SUBSCRIPTION] = False
if channel.subscribed is not None:
resp[ATTR_SUBSCRIPTION] = channel.subscribed
resp[ATTR_SUBSCRIPTION_GIFTED] = channel.subscription_gifted
diff --git a/homeassistant/components/twitch/strings.json b/homeassistant/components/twitch/strings.json
index bbe46526c36..7271b81e924 100644
--- a/homeassistant/components/twitch/strings.json
+++ b/homeassistant/components/twitch/strings.json
@@ -16,5 +16,47 @@
"oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]",
"oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]"
}
+ },
+ "entity": {
+ "sensor": {
+ "channel": {
+ "state": {
+ "streaming": "Streaming",
+ "offline": "Offline"
+ },
+ "state_attributes": {
+ "followers": {
+ "name": "Followers"
+ },
+ "game": {
+ "name": "Game"
+ },
+ "title": {
+ "name": "Title"
+ },
+ "started_at": {
+ "name": "Started at"
+ },
+ "viewers": {
+ "name": "Viewers"
+ },
+ "subscribed": {
+ "name": "Subscribed"
+ },
+ "subscription_is_gifted": {
+ "name": "Subscription is gifted"
+ },
+ "subscription_tier": {
+ "name": "Subscription tier"
+ },
+ "following": {
+ "name": "Following"
+ },
+ "following_since": {
+ "name": "Following since"
+ }
+ }
+ }
+ }
}
}
diff --git a/homeassistant/components/twitter/manifest.json b/homeassistant/components/twitter/manifest.json
index 44e8712b029..af4dff4486d 100644
--- a/homeassistant/components/twitter/manifest.json
+++ b/homeassistant/components/twitter/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/twitter",
"iot_class": "cloud_push",
"loggers": ["TwitterAPI"],
+ "quality_scale": "legacy",
"requirements": ["TwitterAPI==2.7.12"]
}
diff --git a/homeassistant/components/ubus/manifest.json b/homeassistant/components/ubus/manifest.json
index 902b7c9bb82..6053199b4ce 100644
--- a/homeassistant/components/ubus/manifest.json
+++ b/homeassistant/components/ubus/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/ubus",
"iot_class": "local_polling",
"loggers": ["openwrt"],
+ "quality_scale": "legacy",
"requirements": ["openwrt-ubus-rpc==0.0.2"]
}
diff --git a/homeassistant/components/uk_transport/manifest.json b/homeassistant/components/uk_transport/manifest.json
index f3511e71bfa..d855a04ee29 100644
--- a/homeassistant/components/uk_transport/manifest.json
+++ b/homeassistant/components/uk_transport/manifest.json
@@ -3,5 +3,6 @@
"name": "UK Transport",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/uk_transport",
- "iot_class": "cloud_polling"
+ "iot_class": "cloud_polling",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/unifi/const.py b/homeassistant/components/unifi/const.py
index 2b16895a9a8..bbd03b070a4 100644
--- a/homeassistant/components/unifi/const.py
+++ b/homeassistant/components/unifi/const.py
@@ -50,17 +50,16 @@ DPI_SWITCH = "dpi"
OUTLET_SWITCH = "outlet"
DEVICE_STATES = {
- DeviceState.DISCONNECTED: "Disconnected",
- DeviceState.CONNECTED: "Connected",
- DeviceState.PENDING: "Pending",
- DeviceState.FIRMWARE_MISMATCH: "Firmware Mismatch",
- DeviceState.UPGRADING: "Upgrading",
- DeviceState.PROVISIONING: "Provisioning",
- DeviceState.HEARTBEAT_MISSED: "Heartbeat Missed",
- DeviceState.ADOPTING: "Adopting",
- DeviceState.DELETING: "Deleting",
- DeviceState.INFORM_ERROR: "Inform Error",
- DeviceState.ADOPTION_FALIED: "Adoption Failed",
- DeviceState.ISOLATED: "Isolated",
- DeviceState.UNKNOWN: "Unknown",
+ DeviceState.DISCONNECTED: "disconnected",
+ DeviceState.CONNECTED: "connected",
+ DeviceState.PENDING: "pending",
+ DeviceState.FIRMWARE_MISMATCH: "firmware_mismatch",
+ DeviceState.UPGRADING: "upgrading",
+ DeviceState.PROVISIONING: "provisioning",
+ DeviceState.HEARTBEAT_MISSED: "heartbeat_missed",
+ DeviceState.ADOPTING: "adopting",
+ DeviceState.DELETING: "deleting",
+ DeviceState.INFORM_ERROR: "inform_error",
+ DeviceState.ADOPTION_FALIED: "adoption_failed",
+ DeviceState.ISOLATED: "isolated",
}
diff --git a/homeassistant/components/unifi/manifest.json b/homeassistant/components/unifi/manifest.json
index 6f92dec5361..ce573592153 100644
--- a/homeassistant/components/unifi/manifest.json
+++ b/homeassistant/components/unifi/manifest.json
@@ -7,8 +7,7 @@
"integration_type": "hub",
"iot_class": "local_push",
"loggers": ["aiounifi"],
- "quality_scale": "platinum",
- "requirements": ["aiounifi==80"],
+ "requirements": ["aiounifi==81"],
"ssdp": [
{
"manufacturer": "Ubiquiti Networks",
diff --git a/homeassistant/components/unifi/sensor.py b/homeassistant/components/unifi/sensor.py
index 74d49db6e4e..194a8575174 100644
--- a/homeassistant/components/unifi/sensor.py
+++ b/homeassistant/components/unifi/sensor.py
@@ -205,9 +205,9 @@ def async_client_is_connected_fn(hub: UnifiHub, obj_id: str) -> bool:
@callback
-def async_device_state_value_fn(hub: UnifiHub, device: Device) -> str:
+def async_device_state_value_fn(hub: UnifiHub, device: Device) -> str | None:
"""Retrieve the state of the device."""
- return DEVICE_STATES[device.state]
+ return DEVICE_STATES.get(device.state)
@callback
diff --git a/homeassistant/components/unifi/strings.json b/homeassistant/components/unifi/strings.json
index 1c7317c4267..8f4f2b420a5 100644
--- a/homeassistant/components/unifi/strings.json
+++ b/homeassistant/components/unifi/strings.json
@@ -33,6 +33,26 @@
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
}
},
+ "entity": {
+ "sensor": {
+ "device_state": {
+ "state": {
+ "disconnected": "[%key:common::state::disconnected%]",
+ "connected": "[%key:common::state::connected%]",
+ "pending": "Pending",
+ "firmware_mismatch": "Firmware mismatch",
+ "upgrading": "Upgrading",
+ "provisioning": "Provisioning",
+ "heartbeat_missed": "Heartbeat missed",
+ "adopting": "Adopting",
+ "deleting": "Deleting",
+ "inform_error": "Inform error",
+ "adoption_failed": "Adoption failed",
+ "isolated": "Isolated"
+ }
+ }
+ }
+ },
"options": {
"abort": {
"integration_not_setup": "UniFi integration is not set up"
@@ -91,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_direct/device_tracker.py b/homeassistant/components/unifi_direct/device_tracker.py
index 144cbd4dec7..d5e2e926114 100644
--- a/homeassistant/components/unifi_direct/device_tracker.py
+++ b/homeassistant/components/unifi_direct/device_tracker.py
@@ -67,11 +67,11 @@ class UnifiDeviceScanner(DeviceScanner):
"""Update the client info from AP."""
try:
self.clients = self.ap.get_clients()
- except UniFiAPConnectionException:
- _LOGGER.error("Failed to connect to accesspoint")
+ except UniFiAPConnectionException as e:
+ _LOGGER.error("Failed to connect to accesspoint: %s", str(e))
return False
- except UniFiAPDataException:
- _LOGGER.error("Failed to get proper response from accesspoint")
+ except UniFiAPDataException as e:
+ _LOGGER.error("Failed to get proper response from accesspoint: %s", str(e))
return False
return True
diff --git a/homeassistant/components/unifi_direct/manifest.json b/homeassistant/components/unifi_direct/manifest.json
index 8ca8ef27bb2..aa696985dbe 100644
--- a/homeassistant/components/unifi_direct/manifest.json
+++ b/homeassistant/components/unifi_direct/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/unifi_direct",
"iot_class": "local_polling",
"loggers": ["unifi_ap"],
- "requirements": ["unifi_ap==0.0.1"]
+ "quality_scale": "legacy",
+ "requirements": ["unifi_ap==0.0.2"]
}
diff --git a/homeassistant/components/unifiled/manifest.json b/homeassistant/components/unifiled/manifest.json
index c75efb2053b..a2179c76fd9 100644
--- a/homeassistant/components/unifiled/manifest.json
+++ b/homeassistant/components/unifiled/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/unifiled",
"iot_class": "local_polling",
"loggers": ["unifiled"],
+ "quality_scale": "legacy",
"requirements": ["unifiled==0.11"]
}
diff --git a/homeassistant/components/unifiprotect/__init__.py b/homeassistant/components/unifiprotect/__init__.py
index 394a7f43329..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, VideoProxyView
+from .views import (
+ SnapshotProxyView,
+ ThumbnailProxyView,
+ VideoEventProxyView,
+ VideoProxyView,
+)
_LOGGER = logging.getLogger(__name__)
@@ -173,7 +178,9 @@ 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))
async def _async_options_updated(hass: HomeAssistant, entry: UFPConfigEntry) -> None:
diff --git a/homeassistant/components/unifiprotect/camera.py b/homeassistant/components/unifiprotect/camera.py
index a40939be917..0b1c03b8dd6 100644
--- a/homeassistant/components/unifiprotect/camera.py
+++ b/homeassistant/components/unifiprotect/camera.py
@@ -90,7 +90,7 @@ def _get_camera_channels(
is_default = False
# no RTSP enabled use first channel with no stream
- if is_default:
+ if is_default and not camera.is_third_party_camera:
_create_rtsp_repair(hass, entry, data, camera)
yield camera, camera.channels[0], True
else:
diff --git a/homeassistant/components/unifiprotect/const.py b/homeassistant/components/unifiprotect/const.py
index ad251ba6153..d041b713125 100644
--- a/homeassistant/components/unifiprotect/const.py
+++ b/homeassistant/components/unifiprotect/const.py
@@ -1,5 +1,7 @@
"""Constant definitions for UniFi Protect Integration."""
+from typing import Final
+
from uiprotect.data import ModelType, Version
from homeassistant.const import Platform
@@ -39,6 +41,7 @@ DEFAULT_VERIFY_SSL = False
DEFAULT_MAX_MEDIA = 1000
DEVICES_THAT_ADOPT = {
+ ModelType.AIPORT,
ModelType.CAMERA,
ModelType.LIGHT,
ModelType.VIEWPORT,
@@ -75,3 +78,15 @@ PLATFORMS = [
DISPATCH_ADD = "add_device"
DISPATCH_ADOPT = "adopt_device"
DISPATCH_CHANNELS = "new_camera_channels"
+
+EVENT_TYPE_FINGERPRINT_IDENTIFIED: Final = "identified"
+EVENT_TYPE_FINGERPRINT_NOT_IDENTIFIED: Final = "not_identified"
+EVENT_TYPE_NFC_SCANNED: Final = "scanned"
+EVENT_TYPE_DOORBELL_RING: Final = "ring"
+
+KEYRINGS_ULP_ID: Final = "ulp_id"
+KEYRINGS_USER_STATUS: Final = "user_status"
+KEYRINGS_USER_FULL_NAME: Final = "full_name"
+KEYRINGS_KEY_TYPE: Final = "key_type"
+KEYRINGS_KEY_TYPE_ID_FINGERPRINT: Final = "fingerprint_id"
+KEYRINGS_KEY_TYPE_ID_NFC: Final = "nfc_id"
diff --git a/homeassistant/components/unifiprotect/data.py b/homeassistant/components/unifiprotect/data.py
index 4ad8892ca01..baecc7f8323 100644
--- a/homeassistant/components/unifiprotect/data.py
+++ b/homeassistant/components/unifiprotect/data.py
@@ -349,6 +349,7 @@ def async_ufp_instance_for_config_entry_ids(
entry.runtime_data.api
for entry_id in config_entry_ids
if (entry := hass.config_entries.async_get_entry(entry_id))
+ and entry.domain == DOMAIN
and hasattr(entry, "runtime_data")
),
None,
diff --git a/homeassistant/components/unifiprotect/entity.py b/homeassistant/components/unifiprotect/entity.py
index 1d68b18f1de..335bc1e933d 100644
--- a/homeassistant/components/unifiprotect/entity.py
+++ b/homeassistant/components/unifiprotect/entity.py
@@ -119,6 +119,7 @@ def _async_device_entities(
_ALL_MODEL_TYPES = (
+ ModelType.AIPORT,
ModelType.CAMERA,
ModelType.LIGHT,
ModelType.SENSOR,
diff --git a/homeassistant/components/unifiprotect/event.py b/homeassistant/components/unifiprotect/event.py
index 8bbe568242b..c8bce183e34 100644
--- a/homeassistant/components/unifiprotect/event.py
+++ b/homeassistant/components/unifiprotect/event.py
@@ -4,8 +4,6 @@ from __future__ import annotations
import dataclasses
-from uiprotect.data import Camera, EventType, ProtectAdoptableDeviceModel
-
from homeassistant.components.event import (
EventDeviceClass,
EventEntity,
@@ -14,31 +12,51 @@ from homeassistant.components.event import (
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from .const import ATTR_EVENT_ID
-from .data import ProtectData, ProtectDeviceType, UFPConfigEntry
+from . import Bootstrap
+from .const import (
+ ATTR_EVENT_ID,
+ EVENT_TYPE_DOORBELL_RING,
+ EVENT_TYPE_FINGERPRINT_IDENTIFIED,
+ EVENT_TYPE_FINGERPRINT_NOT_IDENTIFIED,
+ EVENT_TYPE_NFC_SCANNED,
+ KEYRINGS_KEY_TYPE_ID_NFC,
+ KEYRINGS_ULP_ID,
+ KEYRINGS_USER_FULL_NAME,
+ KEYRINGS_USER_STATUS,
+)
+from .data import (
+ Camera,
+ EventType,
+ ProtectAdoptableDeviceModel,
+ ProtectData,
+ ProtectDeviceType,
+ UFPConfigEntry,
+)
from .entity import EventEntityMixin, ProtectDeviceEntity, ProtectEventMixin
+def _add_ulp_user_infos(
+ bootstrap: Bootstrap, event_data: dict[str, str], ulp_id: str
+) -> None:
+ """Add ULP user information to the event data."""
+ if ulp_usr := bootstrap.ulp_users.by_ulp_id(ulp_id):
+ event_data.update(
+ {
+ KEYRINGS_ULP_ID: ulp_usr.ulp_id,
+ KEYRINGS_USER_FULL_NAME: ulp_usr.full_name,
+ KEYRINGS_USER_STATUS: ulp_usr.status,
+ }
+ )
+
+
@dataclasses.dataclass(frozen=True, kw_only=True)
class ProtectEventEntityDescription(ProtectEventMixin, EventEntityDescription):
"""Describes UniFi Protect event entity."""
-
-EVENT_DESCRIPTIONS: tuple[ProtectEventEntityDescription, ...] = (
- ProtectEventEntityDescription(
- key="doorbell",
- translation_key="doorbell",
- name="Doorbell",
- device_class=EventDeviceClass.DOORBELL,
- icon="mdi:doorbell-video",
- ufp_required_field="feature_flags.is_doorbell",
- ufp_event_obj="last_ring_event",
- event_types=[EventType.RING],
- ),
-)
+ entity_class: type[ProtectDeviceEntity]
-class ProtectDeviceEventEntity(EventEntityMixin, ProtectDeviceEntity, EventEntity):
+class ProtectDeviceRingEventEntity(EventEntityMixin, ProtectDeviceEntity, EventEntity):
"""A UniFi Protect event entity."""
entity_description: ProtectEventEntityDescription
@@ -57,26 +75,146 @@ class ProtectDeviceEventEntity(EventEntityMixin, ProtectDeviceEntity, EventEntit
if (
event
and not self._event_already_ended(prev_event, prev_event_end)
- and (event_types := description.event_types)
- and (event_type := event.type) in event_types
+ and event.type is EventType.RING
):
- self._trigger_event(event_type, {ATTR_EVENT_ID: event.id})
+ self._trigger_event(EVENT_TYPE_DOORBELL_RING, {ATTR_EVENT_ID: event.id})
self.async_write_ha_state()
+class ProtectDeviceNFCEventEntity(EventEntityMixin, ProtectDeviceEntity, EventEntity):
+ """A UniFi Protect NFC event entity."""
+
+ entity_description: ProtectEventEntityDescription
+
+ @callback
+ def _async_update_device_from_protect(self, device: ProtectDeviceType) -> None:
+ description = self.entity_description
+
+ prev_event = self._event
+ prev_event_end = self._event_end
+ super()._async_update_device_from_protect(device)
+ if event := description.get_event_obj(device):
+ self._event = event
+ self._event_end = event.end if event else None
+
+ if (
+ event
+ and not self._event_already_ended(prev_event, prev_event_end)
+ and event.type is EventType.NFC_CARD_SCANNED
+ ):
+ event_data = {
+ ATTR_EVENT_ID: event.id,
+ KEYRINGS_USER_FULL_NAME: "",
+ KEYRINGS_ULP_ID: "",
+ KEYRINGS_USER_STATUS: "",
+ KEYRINGS_KEY_TYPE_ID_NFC: "",
+ }
+
+ if event.metadata and event.metadata.nfc and event.metadata.nfc.nfc_id:
+ nfc_id = event.metadata.nfc.nfc_id
+ event_data[KEYRINGS_KEY_TYPE_ID_NFC] = nfc_id
+ keyring = self.data.api.bootstrap.keyrings.by_registry_id(nfc_id)
+ if keyring and keyring.ulp_user:
+ _add_ulp_user_infos(
+ self.data.api.bootstrap, event_data, keyring.ulp_user
+ )
+
+ self._trigger_event(EVENT_TYPE_NFC_SCANNED, event_data)
+ self.async_write_ha_state()
+
+
+class ProtectDeviceFingerprintEventEntity(
+ EventEntityMixin, ProtectDeviceEntity, EventEntity
+):
+ """A UniFi Protect fingerprint event entity."""
+
+ entity_description: ProtectEventEntityDescription
+
+ @callback
+ def _async_update_device_from_protect(self, device: ProtectDeviceType) -> None:
+ description = self.entity_description
+
+ prev_event = self._event
+ prev_event_end = self._event_end
+ super()._async_update_device_from_protect(device)
+ if event := description.get_event_obj(device):
+ self._event = event
+ self._event_end = event.end if event else None
+
+ if (
+ event
+ and not self._event_already_ended(prev_event, prev_event_end)
+ and event.type is EventType.FINGERPRINT_IDENTIFIED
+ ):
+ event_data = {
+ ATTR_EVENT_ID: event.id,
+ KEYRINGS_USER_FULL_NAME: "",
+ KEYRINGS_ULP_ID: "",
+ }
+ event_identified = EVENT_TYPE_FINGERPRINT_NOT_IDENTIFIED
+ if (
+ event.metadata
+ and event.metadata.fingerprint
+ and event.metadata.fingerprint.ulp_id
+ ):
+ event_identified = EVENT_TYPE_FINGERPRINT_IDENTIFIED
+ ulp_id = event.metadata.fingerprint.ulp_id
+ if ulp_id:
+ event_data[KEYRINGS_ULP_ID] = ulp_id
+ _add_ulp_user_infos(self.data.api.bootstrap, event_data, ulp_id)
+
+ self._trigger_event(event_identified, event_data)
+ self.async_write_ha_state()
+
+
+EVENT_DESCRIPTIONS: tuple[ProtectEventEntityDescription, ...] = (
+ ProtectEventEntityDescription(
+ key="doorbell",
+ translation_key="doorbell",
+ device_class=EventDeviceClass.DOORBELL,
+ icon="mdi:doorbell-video",
+ ufp_required_field="feature_flags.is_doorbell",
+ ufp_event_obj="last_ring_event",
+ event_types=[EVENT_TYPE_DOORBELL_RING],
+ entity_class=ProtectDeviceRingEventEntity,
+ ),
+ ProtectEventEntityDescription(
+ key="nfc",
+ translation_key="nfc",
+ device_class=EventDeviceClass.DOORBELL,
+ icon="mdi:nfc",
+ ufp_required_field="feature_flags.support_nfc",
+ ufp_event_obj="last_nfc_card_scanned_event",
+ event_types=[EVENT_TYPE_NFC_SCANNED],
+ entity_class=ProtectDeviceNFCEventEntity,
+ ),
+ ProtectEventEntityDescription(
+ key="fingerprint",
+ translation_key="fingerprint",
+ device_class=EventDeviceClass.DOORBELL,
+ icon="mdi:fingerprint",
+ ufp_required_field="feature_flags.has_fingerprint_sensor",
+ ufp_event_obj="last_fingerprint_identified_event",
+ event_types=[
+ EVENT_TYPE_FINGERPRINT_IDENTIFIED,
+ EVENT_TYPE_FINGERPRINT_NOT_IDENTIFIED,
+ ],
+ entity_class=ProtectDeviceFingerprintEventEntity,
+ ),
+)
+
+
@callback
def _async_event_entities(
data: ProtectData,
ufp_device: ProtectAdoptableDeviceModel | None = None,
) -> list[ProtectDeviceEntity]:
- entities: list[ProtectDeviceEntity] = []
- for device in data.get_cameras() if ufp_device is None else [ufp_device]:
- entities.extend(
- ProtectDeviceEventEntity(data, device, description)
- for description in EVENT_DESCRIPTIONS
- if description.has_required(device)
- )
- return entities
+ return [
+ description.entity_class(data, device, description)
+ for device in (data.get_cameras() if ufp_device is None else [ufp_device])
+ for description in EVENT_DESCRIPTIONS
+ if description.has_required(device)
+ ]
async def async_setup_entry(
diff --git a/homeassistant/components/unifiprotect/icons.json b/homeassistant/components/unifiprotect/icons.json
index 5e80e3095b3..b5e8277d82a 100644
--- a/homeassistant/components/unifiprotect/icons.json
+++ b/homeassistant/components/unifiprotect/icons.json
@@ -11,6 +11,9 @@
},
"remove_privacy_zone": {
"service": "mdi:eye-minus"
+ },
+ "get_user_keyring_info": {
+ "service": "mdi:key-chain"
}
}
}
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 85867b5c87c..018a600f037 100644
--- a/homeassistant/components/unifiprotect/manifest.json
+++ b/homeassistant/components/unifiprotect/manifest.json
@@ -1,7 +1,7 @@
{
"domain": "unifiprotect",
"name": "UniFi Protect",
- "codeowners": [],
+ "codeowners": ["@RaHehl"],
"config_flow": true,
"dependencies": ["http", "repairs"],
"dhcp": [
@@ -40,7 +40,7 @@
"integration_type": "hub",
"iot_class": "local_push",
"loggers": ["uiprotect", "unifi_discovery"],
- "requirements": ["uiprotect==6.4.0", "unifi-discovery==1.2.0"],
+ "requirements": ["uiprotect==7.4.1", "unifi-discovery==1.2.0"],
"ssdp": [
{
"manufacturer": "Ubiquiti Networks",
diff --git a/homeassistant/components/unifiprotect/number.py b/homeassistant/components/unifiprotect/number.py
index f6aacf81161..767128337ba 100644
--- a/homeassistant/components/unifiprotect/number.py
+++ b/homeassistant/components/unifiprotect/number.py
@@ -124,7 +124,7 @@ CAMERA_NUMBERS: tuple[ProtectNumberEntityDescription, ...] = (
name="Infrared custom lux trigger",
icon="mdi:white-balance-sunny",
entity_category=EntityCategory.CONFIG,
- ufp_min=1,
+ ufp_min=0,
ufp_max=30,
ufp_step=1,
ufp_required_field="feature_flags.has_led_ir",
diff --git a/homeassistant/components/unifiprotect/sensor.py b/homeassistant/components/unifiprotect/sensor.py
index a91a94aa629..09187e023a1 100644
--- a/homeassistant/components/unifiprotect/sensor.py
+++ b/homeassistant/components/unifiprotect/sensor.py
@@ -245,7 +245,7 @@ CAMERA_SENSORS: tuple[ProtectSensorEntityDescription, ...] = (
name="Recording mode",
icon="mdi:video-outline",
entity_category=EntityCategory.DIAGNOSTIC,
- ufp_value="recording_settings.mode",
+ ufp_value="recording_settings.mode.value",
ufp_perm=PermRequired.NO_WRITE,
),
ProtectSensorEntityDescription(
@@ -254,7 +254,7 @@ CAMERA_SENSORS: tuple[ProtectSensorEntityDescription, ...] = (
icon="mdi:circle-opacity",
entity_category=EntityCategory.DIAGNOSTIC,
ufp_required_field="feature_flags.has_led_ir",
- ufp_value="isp_settings.ir_led_mode",
+ ufp_value="isp_settings.ir_led_mode.value",
ufp_perm=PermRequired.NO_WRITE,
),
ProtectSensorEntityDescription(
diff --git a/homeassistant/components/unifiprotect/services.py b/homeassistant/components/unifiprotect/services.py
index 119fe52756c..402aae2eeba 100644
--- a/homeassistant/components/unifiprotect/services.py
+++ b/homeassistant/components/unifiprotect/services.py
@@ -3,7 +3,6 @@
from __future__ import annotations
import asyncio
-import functools
from typing import Any, cast
from pydantic import ValidationError
@@ -14,7 +13,13 @@ import voluptuous as vol
from homeassistant.components.binary_sensor import BinarySensorDeviceClass
from homeassistant.const import ATTR_DEVICE_ID, ATTR_NAME, Platform
-from homeassistant.core import HomeAssistant, ServiceCall, callback
+from homeassistant.core import (
+ HomeAssistant,
+ ServiceCall,
+ ServiceResponse,
+ SupportsResponse,
+ callback,
+)
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import (
config_validation as cv,
@@ -22,9 +27,19 @@ from homeassistant.helpers import (
entity_registry as er,
)
from homeassistant.helpers.service import async_extract_referenced_entity_ids
+from homeassistant.util.json import JsonValueType
from homeassistant.util.read_only_dict import ReadOnlyDict
-from .const import ATTR_MESSAGE, DOMAIN
+from .const import (
+ ATTR_MESSAGE,
+ DOMAIN,
+ KEYRINGS_KEY_TYPE,
+ KEYRINGS_KEY_TYPE_ID_FINGERPRINT,
+ KEYRINGS_KEY_TYPE_ID_NFC,
+ KEYRINGS_ULP_ID,
+ KEYRINGS_USER_FULL_NAME,
+ KEYRINGS_USER_STATUS,
+)
from .data import async_ufp_instance_for_config_entry_ids
SERVICE_ADD_DOORBELL_TEXT = "add_doorbell_text"
@@ -32,12 +47,14 @@ SERVICE_REMOVE_DOORBELL_TEXT = "remove_doorbell_text"
SERVICE_SET_PRIVACY_ZONE = "set_privacy_zone"
SERVICE_REMOVE_PRIVACY_ZONE = "remove_privacy_zone"
SERVICE_SET_CHIME_PAIRED = "set_chime_paired_doorbells"
+SERVICE_GET_USER_KEYRING_INFO = "get_user_keyring_info"
ALL_GLOBAL_SERIVCES = [
SERVICE_ADD_DOORBELL_TEXT,
SERVICE_REMOVE_DOORBELL_TEXT,
SERVICE_SET_CHIME_PAIRED,
SERVICE_REMOVE_PRIVACY_ZONE,
+ SERVICE_GET_USER_KEYRING_INFO,
]
DOORBELL_TEXT_SCHEMA = vol.All(
@@ -70,6 +87,15 @@ REMOVE_PRIVACY_ZONE_SCHEMA = vol.All(
cv.has_at_least_one_key(ATTR_DEVICE_ID),
)
+GET_USER_KEYRING_INFO_SCHEMA = vol.All(
+ vol.Schema(
+ {
+ **cv.ENTITY_SERVICE_FIELDS,
+ },
+ ),
+ cv.has_at_least_one_key(ATTR_DEVICE_ID),
+)
+
@callback
def _async_get_ufp_instance(hass: HomeAssistant, device_id: str) -> ProtectApiClient:
@@ -88,9 +114,9 @@ def _async_get_ufp_instance(hass: HomeAssistant, device_id: str) -> ProtectApiCl
@callback
-def _async_get_ufp_camera(hass: HomeAssistant, call: ServiceCall) -> Camera:
- ref = async_extract_referenced_entity_ids(hass, call)
- entity_registry = er.async_get(hass)
+def _async_get_ufp_camera(call: ServiceCall) -> Camera:
+ ref = async_extract_referenced_entity_ids(call.hass, call)
+ entity_registry = er.async_get(call.hass)
entity_id = ref.indirectly_referenced.pop()
camera_entity = entity_registry.async_get(entity_id)
@@ -98,30 +124,27 @@ def _async_get_ufp_camera(hass: HomeAssistant, call: ServiceCall) -> Camera:
assert camera_entity.device_id is not None
camera_mac = _async_unique_id_to_mac(camera_entity.unique_id)
- instance = _async_get_ufp_instance(hass, camera_entity.device_id)
+ instance = _async_get_ufp_instance(call.hass, camera_entity.device_id)
return cast(Camera, instance.bootstrap.get_device_from_mac(camera_mac))
@callback
-def _async_get_protect_from_call(
- hass: HomeAssistant, call: ServiceCall
-) -> set[ProtectApiClient]:
+def _async_get_protect_from_call(call: ServiceCall) -> set[ProtectApiClient]:
return {
- _async_get_ufp_instance(hass, device_id)
+ _async_get_ufp_instance(call.hass, device_id)
for device_id in async_extract_referenced_entity_ids(
- hass, call
+ call.hass, call
).referenced_devices
}
async def _async_service_call_nvr(
- hass: HomeAssistant,
call: ServiceCall,
method: str,
*args: Any,
**kwargs: Any,
) -> None:
- instances = _async_get_protect_from_call(hass, call)
+ instances = _async_get_protect_from_call(call)
try:
await asyncio.gather(
*(getattr(i.bootstrap.nvr, method)(*args, **kwargs) for i in instances)
@@ -130,23 +153,23 @@ async def _async_service_call_nvr(
raise HomeAssistantError(str(err)) from err
-async def add_doorbell_text(hass: HomeAssistant, call: ServiceCall) -> None:
+async def add_doorbell_text(call: ServiceCall) -> None:
"""Add a custom doorbell text message."""
message: str = call.data[ATTR_MESSAGE]
- await _async_service_call_nvr(hass, call, "add_custom_doorbell_message", message)
+ await _async_service_call_nvr(call, "add_custom_doorbell_message", message)
-async def remove_doorbell_text(hass: HomeAssistant, call: ServiceCall) -> None:
+async def remove_doorbell_text(call: ServiceCall) -> None:
"""Remove a custom doorbell text message."""
message: str = call.data[ATTR_MESSAGE]
- await _async_service_call_nvr(hass, call, "remove_custom_doorbell_message", message)
+ await _async_service_call_nvr(call, "remove_custom_doorbell_message", message)
-async def remove_privacy_zone(hass: HomeAssistant, call: ServiceCall) -> None:
+async def remove_privacy_zone(call: ServiceCall) -> None:
"""Remove privacy zone from camera."""
name: str = call.data[ATTR_NAME]
- camera = _async_get_ufp_camera(hass, call)
+ camera = _async_get_ufp_camera(call)
remove_index: int | None = None
for index, zone in enumerate(camera.privacy_zones):
@@ -171,10 +194,10 @@ def _async_unique_id_to_mac(unique_id: str) -> str:
return unique_id.split("_")[0]
-async def set_chime_paired_doorbells(hass: HomeAssistant, call: ServiceCall) -> None:
+async def set_chime_paired_doorbells(call: ServiceCall) -> None:
"""Set paired doorbells on chime."""
- ref = async_extract_referenced_entity_ids(hass, call)
- entity_registry = er.async_get(hass)
+ ref = async_extract_referenced_entity_ids(call.hass, call)
+ entity_registry = er.async_get(call.hass)
entity_id = ref.indirectly_referenced.pop()
chime_button = entity_registry.async_get(entity_id)
@@ -182,13 +205,13 @@ async def set_chime_paired_doorbells(hass: HomeAssistant, call: ServiceCall) ->
assert chime_button.device_id is not None
chime_mac = _async_unique_id_to_mac(chime_button.unique_id)
- instance = _async_get_ufp_instance(hass, chime_button.device_id)
+ instance = _async_get_ufp_instance(call.hass, chime_button.device_id)
chime = instance.bootstrap.get_device_from_mac(chime_mac)
chime = cast(Chime, chime)
assert chime is not None
call.data = ReadOnlyDict(call.data.get("doorbells") or {})
- doorbell_refs = async_extract_referenced_entity_ids(hass, call)
+ doorbell_refs = async_extract_referenced_entity_ids(call.hass, call)
doorbell_ids: set[str] = set()
for camera_id in doorbell_refs.referenced | doorbell_refs.indirectly_referenced:
doorbell_sensor = entity_registry.async_get(camera_id)
@@ -209,31 +232,81 @@ async def set_chime_paired_doorbells(hass: HomeAssistant, call: ServiceCall) ->
await chime.save_device(data_before_changed)
+async def get_user_keyring_info(call: ServiceCall) -> ServiceResponse:
+ """Get the user keyring info."""
+ camera = _async_get_ufp_camera(call)
+ ulp_users = camera.api.bootstrap.ulp_users.as_list()
+ if not ulp_users:
+ raise HomeAssistantError("No users found, please check Protect permissions.")
+
+ user_keyrings: list[JsonValueType] = [
+ {
+ KEYRINGS_USER_FULL_NAME: user.full_name,
+ KEYRINGS_USER_STATUS: user.status,
+ KEYRINGS_ULP_ID: user.ulp_id,
+ "keys": [
+ {
+ KEYRINGS_KEY_TYPE: key.registry_type,
+ **(
+ {KEYRINGS_KEY_TYPE_ID_FINGERPRINT: key.registry_id}
+ if key.registry_type == "fingerprint"
+ else {}
+ ),
+ **(
+ {KEYRINGS_KEY_TYPE_ID_NFC: key.registry_id}
+ if key.registry_type == "nfc"
+ else {}
+ ),
+ }
+ for key in camera.api.bootstrap.keyrings.as_list()
+ if key.ulp_user == user.ulp_id
+ ],
+ }
+ for user in ulp_users
+ ]
+
+ response: ServiceResponse = {"users": user_keyrings}
+ return response
+
+
+SERVICES = [
+ (
+ SERVICE_ADD_DOORBELL_TEXT,
+ add_doorbell_text,
+ DOORBELL_TEXT_SCHEMA,
+ SupportsResponse.NONE,
+ ),
+ (
+ SERVICE_REMOVE_DOORBELL_TEXT,
+ remove_doorbell_text,
+ DOORBELL_TEXT_SCHEMA,
+ SupportsResponse.NONE,
+ ),
+ (
+ SERVICE_SET_CHIME_PAIRED,
+ set_chime_paired_doorbells,
+ CHIME_PAIRED_SCHEMA,
+ SupportsResponse.NONE,
+ ),
+ (
+ SERVICE_REMOVE_PRIVACY_ZONE,
+ remove_privacy_zone,
+ REMOVE_PRIVACY_ZONE_SCHEMA,
+ SupportsResponse.NONE,
+ ),
+ (
+ SERVICE_GET_USER_KEYRING_INFO,
+ get_user_keyring_info,
+ GET_USER_KEYRING_INFO_SCHEMA,
+ SupportsResponse.ONLY,
+ ),
+]
+
+
def async_setup_services(hass: HomeAssistant) -> None:
"""Set up the global UniFi Protect services."""
- services = [
- (
- SERVICE_ADD_DOORBELL_TEXT,
- functools.partial(add_doorbell_text, hass),
- DOORBELL_TEXT_SCHEMA,
- ),
- (
- SERVICE_REMOVE_DOORBELL_TEXT,
- functools.partial(remove_doorbell_text, hass),
- DOORBELL_TEXT_SCHEMA,
- ),
- (
- SERVICE_SET_CHIME_PAIRED,
- functools.partial(set_chime_paired_doorbells, hass),
- CHIME_PAIRED_SCHEMA,
- ),
- (
- SERVICE_REMOVE_PRIVACY_ZONE,
- functools.partial(remove_privacy_zone, hass),
- REMOVE_PRIVACY_ZONE_SCHEMA,
- ),
- ]
- for name, method, schema in services:
- if hass.services.has_service(DOMAIN, name):
- continue
- hass.services.async_register(DOMAIN, name, method, schema=schema)
+
+ for name, method, schema, supports_response in SERVICES:
+ hass.services.async_register(
+ DOMAIN, name, method, schema=schema, supports_response=supports_response
+ )
diff --git a/homeassistant/components/unifiprotect/services.yaml b/homeassistant/components/unifiprotect/services.yaml
index 192dfd0566f..b620c195fc2 100644
--- a/homeassistant/components/unifiprotect/services.yaml
+++ b/homeassistant/components/unifiprotect/services.yaml
@@ -53,3 +53,10 @@ remove_privacy_zone:
required: true
selector:
text:
+get_user_keyring_info:
+ fields:
+ device_id:
+ required: true
+ selector:
+ device:
+ integration: unifiprotect
diff --git a/homeassistant/components/unifiprotect/strings.json b/homeassistant/components/unifiprotect/strings.json
index 9238c825390..cde8c88d169 100644
--- a/homeassistant/components/unifiprotect/strings.json
+++ b/homeassistant/components/unifiprotect/strings.json
@@ -137,6 +137,7 @@
},
"event": {
"doorbell": {
+ "name": "Doorbell",
"state_attributes": {
"event_type": {
"state": {
@@ -144,6 +145,27 @@
}
}
}
+ },
+ "nfc": {
+ "name": "NFC",
+ "state_attributes": {
+ "event_type": {
+ "state": {
+ "scanned": "Scanned"
+ }
+ }
+ }
+ },
+ "fingerprint": {
+ "name": "Fingerprint",
+ "state_attributes": {
+ "event_type": {
+ "state": {
+ "identified": "Identified",
+ "not_identified": "Not identified"
+ }
+ }
+ }
}
}
},
@@ -182,7 +204,7 @@
"fields": {
"device_id": {
"name": "Chime",
- "description": "The chimes to link to the doorbells to."
+ "description": "The chimes to link to the doorbells."
},
"doorbells": {
"name": "Doorbells",
@@ -203,6 +225,16 @@
"description": "The name of the zone to remove."
}
}
+ },
+ "get_user_keyring_info": {
+ "name": "Retrieve Keyring Details for Users",
+ "description": "Fetch a detailed list of users with NFC and fingerprint associations for automations.",
+ "fields": {
+ "device_id": {
+ "name": "UniFi Protect NVR",
+ "description": "Any device from the UniFi Protect instance you want to retrieve keyring details. This is useful for systems with multiple Protect instances."
+ }
+ }
}
}
}
diff --git a/homeassistant/components/unifiprotect/views.py b/homeassistant/components/unifiprotect/views.py
index 00128492c67..cc2e1c6a5fc 100644
--- a/homeassistant/components/unifiprotect/views.py
+++ b/homeassistant/components/unifiprotect/views.py
@@ -5,7 +5,7 @@ from __future__ import annotations
from datetime import datetime
from http import HTTPStatus
import logging
-from typing import Any
+from typing import TYPE_CHECKING, Any
from urllib.parse import urlencode
from aiohttp import web
@@ -30,7 +30,9 @@ def async_generate_thumbnail_url(
) -> str:
"""Generate URL for event thumbnail."""
- url_format = ThumbnailProxyView.url or "{nvr_id}/{event_id}"
+ url_format = ThumbnailProxyView.url
+ if TYPE_CHECKING:
+ assert url_format is not None
url = url_format.format(nvr_id=nvr_id, event_id=event_id)
params = {}
@@ -42,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."""
@@ -50,7 +80,9 @@ def async_generate_event_video_url(event: Event) -> str:
if event.start is None or event.end is None:
raise ValueError("Event is ongoing")
- url_format = VideoProxyView.url or "{nvr_id}/{camera_id}/{start}/{end}"
+ url_format = VideoProxyView.url
+ if TYPE_CHECKING:
+ assert url_format is not None
return url_format.format(
nvr_id=event.api.bootstrap.nvr.id,
camera_id=event.camera_id,
@@ -59,6 +91,19 @@ def async_generate_event_video_url(event: Event) -> str:
)
+@callback
+def async_generate_proxy_event_video_url(
+ nvr_id: str,
+ event_id: str,
+) -> str:
+ """Generate proxy URL for event video."""
+
+ url_format = VideoEventProxyView.url
+ if TYPE_CHECKING:
+ assert url_format is not None
+ return url_format.format(nvr_id=nvr_id, event_id=event_id)
+
+
@callback
def _client_error(message: Any, code: HTTPStatus) -> web.Response:
_LOGGER.warning("Client error (%s): %s", code.value, message)
@@ -107,6 +152,27 @@ class ProtectProxyView(HomeAssistantView):
return data
return _404("Invalid NVR ID")
+ @callback
+ def _async_get_camera(self, data: ProtectData, camera_id: str) -> Camera | None:
+ if (camera := data.api.bootstrap.cameras.get(camera_id)) is not None:
+ return camera
+
+ entity_registry = er.async_get(self.hass)
+ device_registry = dr.async_get(self.hass)
+
+ if (entity := entity_registry.async_get(camera_id)) is None or (
+ device := device_registry.async_get(entity.device_id or "")
+ ) is None:
+ return None
+
+ macs = [c[1] for c in device.connections if c[0] == dr.CONNECTION_NETWORK_MAC]
+ for mac in macs:
+ if (ufp_device := data.api.bootstrap.get_device_from_mac(mac)) is not None:
+ if isinstance(ufp_device, Camera):
+ camera = ufp_device
+ break
+ return camera
+
class ThumbnailProxyView(ProtectProxyView):
"""View to proxy event thumbnails from UniFi Protect."""
@@ -150,33 +216,65 @@ 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."""
url = "/api/unifiprotect/video/{nvr_id}/{camera_id}/{start}/{end}"
name = "api:unifiprotect_thumbnail"
- @callback
- def _async_get_camera(self, data: ProtectData, camera_id: str) -> Camera | None:
- if (camera := data.api.bootstrap.cameras.get(camera_id)) is not None:
- return camera
-
- entity_registry = er.async_get(self.hass)
- device_registry = dr.async_get(self.hass)
-
- if (entity := entity_registry.async_get(camera_id)) is None or (
- device := device_registry.async_get(entity.device_id or "")
- ) is None:
- return None
-
- macs = [c[1] for c in device.connections if c[0] == dr.CONNECTION_NETWORK_MAC]
- for mac in macs:
- if (ufp_device := data.api.bootstrap.get_device_from_mac(mac)) is not None:
- if isinstance(ufp_device, Camera):
- camera = ufp_device
- break
- return camera
-
async def get(
self, request: web.Request, nvr_id: str, camera_id: str, start: str, end: str
) -> web.StreamResponse:
@@ -226,3 +324,56 @@ class VideoProxyView(ProtectProxyView):
if response.prepared:
await response.write_eof()
return response
+
+
+class VideoEventProxyView(ProtectProxyView):
+ """View to proxy video clips for events from UniFi Protect."""
+
+ url = "/api/unifiprotect/video/{nvr_id}/{event_id}"
+ name = "api:unifiprotect_videoEventView"
+
+ async def get(
+ self, request: web.Request, nvr_id: str, event_id: str
+ ) -> web.StreamResponse:
+ """Get Camera Video clip for an event."""
+
+ data = self._get_data_or_404(nvr_id)
+ if isinstance(data, web.Response):
+ return data
+
+ try:
+ event = await data.api.get_event(event_id)
+ except ClientError:
+ return _404(f"Invalid event ID: {event_id}")
+ if event.start is None or event.end is None:
+ return _400("Event is still ongoing")
+ camera = self._async_get_camera(data, str(event.camera_id))
+ if camera is None:
+ return _404(f"Invalid camera ID: {event.camera_id}")
+ if not camera.can_read_media(data.api.bootstrap.auth_user):
+ return _403(f"User cannot read media from camera: {camera.id}")
+
+ response = web.StreamResponse(
+ status=200,
+ reason="OK",
+ headers={
+ "Content-Type": "video/mp4",
+ },
+ )
+
+ async def iterator(total: int, chunk: bytes | None) -> None:
+ if not response.prepared:
+ response.content_length = total
+ await response.prepare(request)
+
+ if chunk is not None:
+ await response.write(chunk)
+
+ try:
+ await camera.get_video(event.start, event.end, iterator_callback=iterator)
+ except ClientError as err:
+ return _404(err)
+
+ if response.prepared:
+ await response.write_eof()
+ return response
diff --git a/homeassistant/components/upb/__init__.py b/homeassistant/components/upb/__init__.py
index ca4375d1232..c9f3a2df105 100644
--- a/homeassistant/components/upb/__init__.py
+++ b/homeassistant/components/upb/__init__.py
@@ -1,5 +1,7 @@
"""Support the UPB PIM."""
+import logging
+
import upb_lib
from homeassistant.config_entries import ConfigEntry
@@ -14,6 +16,7 @@ from .const import (
EVENT_UPB_SCENE_CHANGED,
)
+_LOGGER = logging.getLogger(__name__)
PLATFORMS = [Platform.LIGHT, Platform.SCENE]
@@ -63,3 +66,21 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
upb.disconnect()
hass.data[DOMAIN].pop(config_entry.entry_id)
return unload_ok
+
+
+async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+ """Migrate entry."""
+
+ _LOGGER.debug("Migrating from version %s", entry.version)
+
+ if entry.version == 1:
+ # 1 -> 2: Unique ID from integer to string
+ if entry.minor_version == 1:
+ minor_version = 2
+ hass.config_entries.async_update_entry(
+ entry, unique_id=str(entry.unique_id), minor_version=minor_version
+ )
+
+ _LOGGER.debug("Migration successful")
+
+ return True
diff --git a/homeassistant/components/upb/config_flow.py b/homeassistant/components/upb/config_flow.py
index d9f111049fd..788a0336d73 100644
--- a/homeassistant/components/upb/config_flow.py
+++ b/homeassistant/components/upb/config_flow.py
@@ -78,6 +78,7 @@ class UPBConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for UPB PIM."""
VERSION = 1
+ MINOR_VERSION = 2
async def async_step_user(
self, user_input: dict[str, Any] | None = None
@@ -98,7 +99,7 @@ class UPBConfigFlow(ConfigFlow, domain=DOMAIN):
errors["base"] = "unknown"
if "base" not in errors:
- await self.async_set_unique_id(network_id)
+ await self.async_set_unique_id(str(network_id))
self._abort_if_unique_id_configured()
return self.async_create_entry(
diff --git a/homeassistant/components/upb/manifest.json b/homeassistant/components/upb/manifest.json
index 6b49c859771..1e61747b3f1 100644
--- a/homeassistant/components/upb/manifest.json
+++ b/homeassistant/components/upb/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/upb",
"iot_class": "local_push",
"loggers": ["upb_lib"],
- "requirements": ["upb-lib==0.5.8"]
+ "requirements": ["upb-lib==0.5.9"]
}
diff --git a/homeassistant/components/upc_connect/manifest.json b/homeassistant/components/upc_connect/manifest.json
index 02b852ec3a6..1874e5db028 100644
--- a/homeassistant/components/upc_connect/manifest.json
+++ b/homeassistant/components/upc_connect/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/upc_connect",
"iot_class": "local_polling",
"loggers": ["connect_box"],
+ "quality_scale": "legacy",
"requirements": ["connect-box==0.3.1"]
}
diff --git a/homeassistant/components/update/__init__.py b/homeassistant/components/update/__init__.py
index 6f0b56b14e8..8ef9f44237f 100644
--- a/homeassistant/components/update/__init__.py
+++ b/homeassistant/components/update/__init__.py
@@ -136,7 +136,7 @@ async def async_install(entity: UpdateEntity, service_call: ServiceCall) -> None
# If version is specified, but not supported by the entity.
if (
version is not None
- and UpdateEntityFeature.SPECIFIC_VERSION not in entity.supported_features_compat
+ and UpdateEntityFeature.SPECIFIC_VERSION not in entity.supported_features
):
raise HomeAssistantError(
f"Installing a specific version is not supported for {entity.entity_id}"
@@ -145,7 +145,7 @@ async def async_install(entity: UpdateEntity, service_call: ServiceCall) -> None
# If backup is requested, but not supported by the entity.
if (
backup := service_call.data[ATTR_BACKUP]
- ) and UpdateEntityFeature.BACKUP not in entity.supported_features_compat:
+ ) and UpdateEntityFeature.BACKUP not in entity.supported_features:
raise HomeAssistantError(f"Backup is not supported for {entity.entity_id}")
# Update is already in progress.
@@ -279,7 +279,7 @@ class UpdateEntity(
return self._attr_entity_category
if hasattr(self, "entity_description"):
return self.entity_description.entity_category
- if UpdateEntityFeature.INSTALL in self.supported_features_compat:
+ if UpdateEntityFeature.INSTALL in self.supported_features:
return EntityCategory.CONFIG
return EntityCategory.DIAGNOSTIC
@@ -337,19 +337,6 @@ class UpdateEntity(
"""
return self._attr_title
- @property
- def supported_features_compat(self) -> UpdateEntityFeature:
- """Return the supported features as UpdateEntityFeature.
-
- Remove this compatibility shim in 2025.1 or later.
- """
- features = self.supported_features
- if type(features) is int: # noqa: E721
- new_features = UpdateEntityFeature(features)
- self._report_deprecated_supported_features_values(new_features)
- return new_features
- return features
-
@cached_property
def update_percentage(self) -> int | float | None:
"""Update installation progress.
@@ -451,7 +438,7 @@ class UpdateEntity(
# If entity supports progress, return the in_progress value.
# Otherwise, we use the internal progress value.
- if UpdateEntityFeature.PROGRESS in self.supported_features_compat:
+ if UpdateEntityFeature.PROGRESS in self.supported_features:
in_progress = self.in_progress
update_percentage = self.update_percentage if in_progress else None
if type(in_progress) is not bool and isinstance(in_progress, int):
@@ -494,7 +481,7 @@ class UpdateEntity(
Handles setting the in_progress state in case the entity doesn't
support it natively.
"""
- if UpdateEntityFeature.PROGRESS not in self.supported_features_compat:
+ if UpdateEntityFeature.PROGRESS not in self.supported_features:
self.__in_progress = True
self.async_write_ha_state()
@@ -539,7 +526,7 @@ async def websocket_release_notes(
)
return
- if UpdateEntityFeature.RELEASE_NOTES not in entity.supported_features_compat:
+ if UpdateEntityFeature.RELEASE_NOTES not in entity.supported_features:
connection.send_error(
msg["id"],
websocket_api.ERR_NOT_SUPPORTED,
diff --git a/homeassistant/components/update/strings.json b/homeassistant/components/update/strings.json
index eb6db257bb2..5194965cf69 100644
--- a/homeassistant/components/update/strings.json
+++ b/homeassistant/components/update/strings.json
@@ -56,7 +56,7 @@
"services": {
"install": {
"name": "Install update",
- "description": "Installs an update for this device or service.",
+ "description": "Installs an update for a device or service.",
"fields": {
"version": {
"name": "Version",
@@ -64,7 +64,7 @@
},
"backup": {
"name": "Backup",
- "description": "If supported by the integration, this creates a backup before starting the update ."
+ "description": "If supported by the integration, this creates a backup before starting the update."
}
}
},
diff --git a/homeassistant/components/upnp/manifest.json b/homeassistant/components/upnp/manifest.json
index b0b4fe35b39..08e0be2d712 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.41.0", "getmac==0.9.4"],
+ "requirements": ["async-upnp-client==0.42.0", "getmac==0.9.4"],
"ssdp": [
{
"st": "urn:schemas-upnp-org:device:InternetGatewayDevice:1"
diff --git a/homeassistant/components/uptimerobot/manifest.json b/homeassistant/components/uptimerobot/manifest.json
index 254409cff7e..67e57f46986 100644
--- a/homeassistant/components/uptimerobot/manifest.json
+++ b/homeassistant/components/uptimerobot/manifest.json
@@ -6,6 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/uptimerobot",
"iot_class": "cloud_polling",
"loggers": ["pyuptimerobot"],
- "quality_scale": "platinum",
"requirements": ["pyuptimerobot==22.2.0"]
}
diff --git a/homeassistant/components/usb/__init__.py b/homeassistant/components/usb/__init__.py
index 2da72d16ac6..4517501bf43 100644
--- a/homeassistant/components/usb/__init__.py
+++ b/homeassistant/components/usb/__init__.py
@@ -2,13 +2,13 @@
from __future__ import annotations
-from collections.abc import Coroutine
+from collections.abc import Coroutine, Sequence
import dataclasses
import fnmatch
import logging
import os
import sys
-from typing import TYPE_CHECKING, Any
+from typing import TYPE_CHECKING, Any, overload
from serial.tools.list_ports import comports
from serial.tools.list_ports_common import ListPortInfo
@@ -116,6 +116,7 @@ class UsbServiceInfo(BaseServiceInfo):
description: str | None
+@overload
def human_readable_device_name(
device: str,
serial_number: str | None,
@@ -123,11 +124,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:
@@ -360,7 +382,7 @@ 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 = [
usb_device_from_port(port)
diff --git a/homeassistant/components/usgs_earthquakes_feed/manifest.json b/homeassistant/components/usgs_earthquakes_feed/manifest.json
index ffb9412703f..ea68d00e2a9 100644
--- a/homeassistant/components/usgs_earthquakes_feed/manifest.json
+++ b/homeassistant/components/usgs_earthquakes_feed/manifest.json
@@ -6,5 +6,6 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["aio_geojson_usgs_earthquakes"],
+ "quality_scale": "legacy",
"requirements": ["aio-geojson-usgs-earthquakes==0.3"]
}
diff --git a/homeassistant/components/utility_meter/__init__.py b/homeassistant/components/utility_meter/__init__.py
index c6a8635f831..aac31e085a0 100644
--- a/homeassistant/components/utility_meter/__init__.py
+++ b/homeassistant/components/utility_meter/__init__.py
@@ -1,9 +1,9 @@
"""Support for tracking consumption over given periods of time."""
-from datetime import timedelta
+from datetime import datetime, timedelta
import logging
-from croniter import croniter
+from cronsim import CronSim, CronSimError
import voluptuous as vol
from homeassistant.components.select import DOMAIN as SELECT_DOMAIN
@@ -47,9 +47,12 @@ DEFAULT_OFFSET = timedelta(hours=0)
def validate_cron_pattern(pattern):
"""Check that the pattern is well-formed."""
- if croniter.is_valid(pattern):
- return pattern
- raise vol.Invalid("Invalid pattern")
+ try:
+ CronSim(pattern, datetime(2020, 1, 1)) # any date will do
+ except CronSimError as err:
+ _LOGGER.error("Invalid cron pattern %s: %s", pattern, err)
+ raise vol.Invalid("Invalid pattern") from err
+ return pattern
def period_or_cron(config):
diff --git a/homeassistant/components/utility_meter/manifest.json b/homeassistant/components/utility_meter/manifest.json
index 31a2d4e9584..5167c51469d 100644
--- a/homeassistant/components/utility_meter/manifest.json
+++ b/homeassistant/components/utility_meter/manifest.json
@@ -6,7 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/utility_meter",
"integration_type": "helper",
"iot_class": "local_push",
- "loggers": ["croniter"],
"quality_scale": "internal",
"requirements": ["cronsim==2.6"]
}
diff --git a/homeassistant/components/utility_meter/sensor.py b/homeassistant/components/utility_meter/sensor.py
index 19ef3c1f3a8..9c13aa1984a 100644
--- a/homeassistant/components/utility_meter/sensor.py
+++ b/homeassistant/components/utility_meter/sensor.py
@@ -27,6 +27,7 @@ from homeassistant.const import (
ATTR_UNIT_OF_MEASUREMENT,
CONF_NAME,
CONF_UNIQUE_ID,
+ EVENT_CORE_CONFIG_UPDATE,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
)
@@ -404,6 +405,10 @@ class UtilityMeterSensor(RestoreSensor):
self._tariff = tariff
self._tariff_entity = tariff_entity
self._next_reset = None
+ self._current_tz = None
+ self._config_scheduler()
+
+ def _config_scheduler(self):
self.scheduler = (
CronSim(
self._cron_pattern,
@@ -565,6 +570,7 @@ class UtilityMeterSensor(RestoreSensor):
self._next_reset,
)
)
+ self.async_write_ha_state()
async def _async_reset_meter(self, event):
"""Reset the utility meter status."""
@@ -601,6 +607,10 @@ class UtilityMeterSensor(RestoreSensor):
"""Handle entity which will be added."""
await super().async_added_to_hass()
+ # track current timezone in case it changes
+ # and we need to reconfigure the scheduler
+ self._current_tz = self.hass.config.time_zone
+
await self._program_reset()
self.async_on_remove(
@@ -655,6 +665,19 @@ class UtilityMeterSensor(RestoreSensor):
self.async_on_remove(async_at_started(self.hass, async_source_tracking))
+ async def async_track_time_zone(event):
+ """Reconfigure Scheduler after time zone changes."""
+
+ if self._current_tz != self.hass.config.time_zone:
+ self._current_tz = self.hass.config.time_zone
+
+ self._config_scheduler()
+ await self._program_reset()
+
+ self.async_on_remove(
+ self.hass.bus.async_listen(EVENT_CORE_CONFIG_UPDATE, async_track_time_zone)
+ )
+
async def async_will_remove_from_hass(self) -> None:
"""Run when entity will be removed from hass."""
if self._collecting:
diff --git a/homeassistant/components/utility_meter/strings.json b/homeassistant/components/utility_meter/strings.json
index fc1c727fb0a..4a8ae415a83 100644
--- a/homeassistant/components/utility_meter/strings.json
+++ b/homeassistant/components/utility_meter/strings.json
@@ -3,7 +3,7 @@
"config": {
"step": {
"user": {
- "title": "Add Utility Meter",
+ "title": "Create Utility Meter",
"description": "Create a sensor which tracks consumption of various utilities (e.g., energy, gas, water, heating) over a configured period of time, typically monthly. The utility meter sensor optionally supports splitting the consumption by tariffs, in that case one sensor for each tariff is created as well as a select entity to choose the current tariff.",
"data": {
"always_available": "Sensor always available",
@@ -25,6 +25,9 @@
"tariffs": "A list of supported tariffs, leave empty if only a single tariff is needed."
}
}
+ },
+ "error": {
+ "tariffs_not_unique": "Tariffs must be unique"
}
},
"options": {
diff --git a/homeassistant/components/uvc/manifest.json b/homeassistant/components/uvc/manifest.json
index c72b865b5ef..aeb9b6068ea 100644
--- a/homeassistant/components/uvc/manifest.json
+++ b/homeassistant/components/uvc/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/uvc",
"iot_class": "local_polling",
"loggers": ["uvcclient"],
+ "quality_scale": "legacy",
"requirements": ["uvcclient==0.12.1"]
}
diff --git a/homeassistant/components/vacuum/__init__.py b/homeassistant/components/vacuum/__init__.py
index a81dbeacee1..6fe2c3e2a5b 100644
--- a/homeassistant/components/vacuum/__init__.py
+++ b/homeassistant/components/vacuum/__init__.py
@@ -2,11 +2,12 @@
from __future__ import annotations
+import asyncio
from datetime import timedelta
from enum import IntFlag
from functools import partial
import logging
-from typing import Any
+from typing import TYPE_CHECKING, Any, final
from propcache import cached_property
import voluptuous as vol
@@ -18,11 +19,9 @@ from homeassistant.const import ( # noqa: F401 # STATE_PAUSED/IDLE are API
SERVICE_TOGGLE,
SERVICE_TURN_OFF,
SERVICE_TURN_ON,
- STATE_IDLE,
STATE_ON,
- STATE_PAUSED,
)
-from homeassistant.core import HomeAssistant
+from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.deprecation import (
DeprecatedConstantEnum,
@@ -32,12 +31,21 @@ from homeassistant.helpers.deprecation import (
)
from homeassistant.helpers.entity import Entity, EntityDescription
from homeassistant.helpers.entity_component import EntityComponent
+from homeassistant.helpers.entity_platform import EntityPlatform
+from homeassistant.helpers.frame import ReportBehavior, report_usage
from homeassistant.helpers.icon import icon_for_battery_level
from homeassistant.helpers.typing import ConfigType
from homeassistant.loader import bind_hass
from homeassistant.util.hass_dict import HassKey
-from .const import DOMAIN, STATE_CLEANING, STATE_DOCKED, STATE_ERROR, STATE_RETURNING
+from .const import ( # noqa: F401
+ _DEPRECATED_STATE_CLEANING,
+ _DEPRECATED_STATE_DOCKED,
+ _DEPRECATED_STATE_ERROR,
+ _DEPRECATED_STATE_RETURNING,
+ DOMAIN,
+ VacuumActivity,
+)
_LOGGER = logging.getLogger(__name__)
@@ -64,11 +72,13 @@ SERVICE_START = "start"
SERVICE_PAUSE = "pause"
SERVICE_STOP = "stop"
-
-STATES = [STATE_CLEANING, STATE_DOCKED, STATE_RETURNING, STATE_ERROR]
-
DEFAULT_NAME = "Vacuum cleaner robot"
+# These STATE_* constants are deprecated as of Home Assistant 2025.1.
+# Please use the VacuumActivity enum instead.
+_DEPRECATED_STATE_IDLE = DeprecatedConstantEnum(VacuumActivity.IDLE, "2026.1")
+_DEPRECATED_STATE_PAUSED = DeprecatedConstantEnum(VacuumActivity.PAUSED, "2026.1")
+
class VacuumEntityFeature(IntFlag):
"""Supported features of the vacuum entity."""
@@ -216,7 +226,7 @@ STATE_VACUUM_CACHED_PROPERTIES_WITH_ATTR_ = {
"battery_icon",
"fan_speed",
"fan_speed_list",
- "state",
+ "activity",
}
@@ -233,9 +243,58 @@ class StateVacuumEntity(
_attr_battery_level: int | None = None
_attr_fan_speed: str | None = None
_attr_fan_speed_list: list[str]
- _attr_state: str | None = None
+ _attr_activity: VacuumActivity | None = None
_attr_supported_features: VacuumEntityFeature = VacuumEntityFeature(0)
+ __vacuum_legacy_state: bool = False
+
+ def __init_subclass__(cls, **kwargs: Any) -> None:
+ """Post initialisation processing."""
+ super().__init_subclass__(**kwargs)
+ if any(method in cls.__dict__ for method in ("_attr_state", "state")):
+ # Integrations should use the 'activity' property instead of
+ # setting the state directly.
+ cls.__vacuum_legacy_state = True
+
+ def __setattr__(self, name: str, value: Any) -> None:
+ """Set attribute.
+
+ Deprecation warning if setting '_attr_state' directly
+ unless already reported.
+ """
+ if name == "_attr_state":
+ self._report_deprecated_activity_handling()
+ return super().__setattr__(name, value)
+
+ @callback
+ def add_to_platform_start(
+ self,
+ hass: HomeAssistant,
+ platform: EntityPlatform,
+ parallel_updates: asyncio.Semaphore | None,
+ ) -> None:
+ """Start adding an entity to a platform."""
+ super().add_to_platform_start(hass, platform, parallel_updates)
+ if self.__vacuum_legacy_state:
+ self._report_deprecated_activity_handling()
+
+ @callback
+ def _report_deprecated_activity_handling(self) -> None:
+ """Report on deprecated handling of vacuum state.
+
+ Integrations should implement activity instead of using state directly.
+ """
+ report_usage(
+ "is setting state directly."
+ f" Entity {self.entity_id} ({type(self)}) should implement the 'activity'"
+ " property and return its state using the VacuumActivity enum",
+ core_integration_behavior=ReportBehavior.ERROR,
+ custom_integration_behavior=ReportBehavior.LOG,
+ breaks_in_ha_version="2026.1",
+ integration_domain=self.platform.platform_name if self.platform else None,
+ exclude_integrations={DOMAIN},
+ )
+
@cached_property
def battery_level(self) -> int | None:
"""Return the battery level of the vacuum cleaner."""
@@ -244,7 +303,7 @@ class StateVacuumEntity(
@property
def battery_icon(self) -> str:
"""Return the battery icon for the vacuum cleaner."""
- charging = bool(self.state == STATE_DOCKED)
+ charging = bool(self.activity == VacuumActivity.DOCKED)
return icon_for_battery_level(
battery_level=self.battery_level, charging=charging
@@ -282,10 +341,28 @@ class StateVacuumEntity(
return data
- @cached_property
+ @final
+ @property
def state(self) -> str | None:
"""Return the state of the vacuum cleaner."""
- return self._attr_state
+ if (activity := self.activity) is not None:
+ return activity
+ if self._attr_state is not None:
+ # Backwards compatibility for integrations that set state directly
+ # Should be removed in 2026.1
+ if TYPE_CHECKING:
+ assert isinstance(self._attr_state, str)
+ return self._attr_state
+ return None
+
+ @cached_property
+ def activity(self) -> VacuumActivity | None:
+ """Return the current vacuum activity.
+
+ Integrations should overwrite this or use the '_attr_activity'
+ attribute to set the vacuum activity using the 'VacuumActivity' enum.
+ """
+ return self._attr_activity
@cached_property
def supported_features(self) -> VacuumEntityFeature:
diff --git a/homeassistant/components/vacuum/const.py b/homeassistant/components/vacuum/const.py
index af1558f8570..f153a11dcb9 100644
--- a/homeassistant/components/vacuum/const.py
+++ b/homeassistant/components/vacuum/const.py
@@ -1,10 +1,42 @@
"""Support for vacuum cleaner robots (botvacs)."""
+from __future__ import annotations
+
+from enum import StrEnum
+from functools import partial
+
+from homeassistant.helpers.deprecation import (
+ DeprecatedConstantEnum,
+ all_with_deprecated_constants,
+ check_if_deprecated_constant,
+ dir_with_deprecated_constants,
+)
+
DOMAIN = "vacuum"
-STATE_CLEANING = "cleaning"
-STATE_DOCKED = "docked"
-STATE_RETURNING = "returning"
-STATE_ERROR = "error"
-STATES = [STATE_CLEANING, STATE_DOCKED, STATE_RETURNING, STATE_ERROR]
+class VacuumActivity(StrEnum):
+ """Vacuum activity states."""
+
+ CLEANING = "cleaning"
+ DOCKED = "docked"
+ IDLE = "idle"
+ PAUSED = "paused"
+ RETURNING = "returning"
+ ERROR = "error"
+
+
+# These STATE_* constants are deprecated as of Home Assistant 2025.1.
+# Please use the VacuumActivity enum instead.
+_DEPRECATED_STATE_CLEANING = DeprecatedConstantEnum(VacuumActivity.CLEANING, "2026.1")
+_DEPRECATED_STATE_DOCKED = DeprecatedConstantEnum(VacuumActivity.DOCKED, "2026.1")
+_DEPRECATED_STATE_RETURNING = DeprecatedConstantEnum(VacuumActivity.RETURNING, "2026.1")
+_DEPRECATED_STATE_ERROR = DeprecatedConstantEnum(VacuumActivity.ERROR, "2026.1")
+
+
+# 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/vacuum/device_condition.py b/homeassistant/components/vacuum/device_condition.py
index f528b0918a1..4da64484bf7 100644
--- a/homeassistant/components/vacuum/device_condition.py
+++ b/homeassistant/components/vacuum/device_condition.py
@@ -20,7 +20,7 @@ from homeassistant.helpers import (
from homeassistant.helpers.config_validation import DEVICE_CONDITION_BASE_SCHEMA
from homeassistant.helpers.typing import ConfigType, TemplateVarsType
-from . import DOMAIN, STATE_CLEANING, STATE_DOCKED, STATE_RETURNING
+from . import DOMAIN, VacuumActivity
CONDITION_TYPES = {"is_cleaning", "is_docked"}
@@ -62,9 +62,9 @@ def async_condition_from_config(
) -> condition.ConditionCheckerType:
"""Create a function to test a device condition."""
if config[CONF_TYPE] == "is_docked":
- test_states = [STATE_DOCKED]
+ test_states = [VacuumActivity.DOCKED]
else:
- test_states = [STATE_CLEANING, STATE_RETURNING]
+ test_states = [VacuumActivity.CLEANING, VacuumActivity.RETURNING]
registry = er.async_get(hass)
entity_id = er.async_resolve_entity_id(registry, config[CONF_ENTITY_ID])
diff --git a/homeassistant/components/vacuum/device_trigger.py b/homeassistant/components/vacuum/device_trigger.py
index 45b0696f871..fe682ef21d3 100644
--- a/homeassistant/components/vacuum/device_trigger.py
+++ b/homeassistant/components/vacuum/device_trigger.py
@@ -19,7 +19,7 @@ from homeassistant.helpers import config_validation as cv, entity_registry as er
from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo
from homeassistant.helpers.typing import ConfigType
-from . import DOMAIN, STATE_CLEANING, STATE_DOCKED
+from . import DOMAIN, VacuumActivity
TRIGGER_TYPES = {"cleaning", "docked"}
@@ -77,9 +77,9 @@ async def async_attach_trigger(
) -> CALLBACK_TYPE:
"""Attach a trigger."""
if config[CONF_TYPE] == "cleaning":
- to_state = STATE_CLEANING
+ to_state = VacuumActivity.CLEANING
else:
- to_state = STATE_DOCKED
+ to_state = VacuumActivity.DOCKED
state_config = {
CONF_PLATFORM: "state",
diff --git a/homeassistant/components/vacuum/intent.py b/homeassistant/components/vacuum/intent.py
index 8952c13875d..48340252b6e 100644
--- a/homeassistant/components/vacuum/intent.py
+++ b/homeassistant/components/vacuum/intent.py
@@ -18,6 +18,7 @@ async def async_setup_intents(hass: HomeAssistant) -> None:
DOMAIN,
SERVICE_START,
description="Starts a vacuum",
+ required_domains={DOMAIN},
platforms={DOMAIN},
),
)
@@ -28,6 +29,7 @@ async def async_setup_intents(hass: HomeAssistant) -> None:
DOMAIN,
SERVICE_RETURN_TO_BASE,
description="Returns a vacuum to base",
+ required_domains={DOMAIN},
platforms={DOMAIN},
),
)
diff --git a/homeassistant/components/vacuum/reproduce_state.py b/homeassistant/components/vacuum/reproduce_state.py
index 762cd6f2e90..ef3fb329686 100644
--- a/homeassistant/components/vacuum/reproduce_state.py
+++ b/homeassistant/components/vacuum/reproduce_state.py
@@ -11,10 +11,8 @@ from homeassistant.const import (
ATTR_ENTITY_ID,
SERVICE_TURN_OFF,
SERVICE_TURN_ON,
- STATE_IDLE,
STATE_OFF,
STATE_ON,
- STATE_PAUSED,
)
from homeassistant.core import Context, HomeAssistant, State
@@ -26,20 +24,18 @@ from . import (
SERVICE_SET_FAN_SPEED,
SERVICE_START,
SERVICE_STOP,
- STATE_CLEANING,
- STATE_DOCKED,
- STATE_RETURNING,
+ VacuumActivity,
)
_LOGGER = logging.getLogger(__name__)
VALID_STATES_TOGGLE = {STATE_ON, STATE_OFF}
VALID_STATES_STATE = {
- STATE_CLEANING,
- STATE_DOCKED,
- STATE_IDLE,
- STATE_PAUSED,
- STATE_RETURNING,
+ VacuumActivity.CLEANING,
+ VacuumActivity.DOCKED,
+ VacuumActivity.IDLE,
+ VacuumActivity.PAUSED,
+ VacuumActivity.RETURNING,
}
@@ -75,13 +71,13 @@ async def _async_reproduce_state(
service = SERVICE_TURN_ON
elif state.state == STATE_OFF:
service = SERVICE_TURN_OFF
- elif state.state == STATE_CLEANING:
+ elif state.state == VacuumActivity.CLEANING:
service = SERVICE_START
- elif state.state in [STATE_DOCKED, STATE_RETURNING]:
+ elif state.state in [VacuumActivity.DOCKED, VacuumActivity.RETURNING]:
service = SERVICE_RETURN_TO_BASE
- elif state.state == STATE_IDLE:
+ elif state.state == VacuumActivity.IDLE:
service = SERVICE_STOP
- elif state.state == STATE_PAUSED:
+ elif state.state == VacuumActivity.PAUSED:
service = SERVICE_PAUSE
await hass.services.async_call(
diff --git a/homeassistant/components/vallox/fan.py b/homeassistant/components/vallox/fan.py
index 5fac46177cb..3a21ef060a7 100644
--- a/homeassistant/components/vallox/fan.py
+++ b/homeassistant/components/vallox/fan.py
@@ -83,7 +83,6 @@ class ValloxFanEntity(ValloxEntity, FanEntity):
| FanEntityFeature.TURN_OFF
| FanEntityFeature.TURN_ON
)
- _enable_turn_on_off_backwards_compatibility = False
def __init__(
self,
diff --git a/homeassistant/components/vasttrafik/manifest.json b/homeassistant/components/vasttrafik/manifest.json
index 336d06e182c..73b773720ad 100644
--- a/homeassistant/components/vasttrafik/manifest.json
+++ b/homeassistant/components/vasttrafik/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/vasttrafik",
"iot_class": "cloud_polling",
"loggers": ["vasttrafik"],
+ "quality_scale": "legacy",
"requirements": ["vtjp==0.2.1"]
}
diff --git a/homeassistant/components/velbus/__init__.py b/homeassistant/components/velbus/__init__.py
index ca8cfb0f2a7..ad1c35a124b 100644
--- a/homeassistant/components/velbus/__init__.py
+++ b/homeassistant/components/velbus/__init__.py
@@ -2,30 +2,25 @@
from __future__ import annotations
-from contextlib import suppress
+import asyncio
+from dataclasses import dataclass
import logging
import os
import shutil
from velbusaio.controller import Velbus
-import voluptuous as vol
+from velbusaio.exceptions import VelbusConnectionFailed
from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import CONF_ADDRESS, CONF_PORT, Platform
-from homeassistant.core import HomeAssistant, ServiceCall
-from homeassistant.exceptions import PlatformNotReady
+from homeassistant.const import CONF_PORT, Platform
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import ConfigEntryNotReady, PlatformNotReady
from homeassistant.helpers import config_validation as cv, device_registry as dr
from homeassistant.helpers.storage import STORAGE_DIR
+from homeassistant.helpers.typing import ConfigType
-from .const import (
- CONF_INTERFACE,
- CONF_MEMO_TEXT,
- DOMAIN,
- SERVICE_CLEAR_CACHE,
- SERVICE_SCAN,
- SERVICE_SET_MEMO_TEXT,
- SERVICE_SYNC,
-)
+from .const import DOMAIN
+from .services import setup_services
_LOGGER = logging.getLogger(__name__)
@@ -40,13 +35,25 @@ PLATFORMS = [
Platform.SWITCH,
]
+CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
-async def velbus_connect_task(
+type VelbusConfigEntry = ConfigEntry[VelbusData]
+
+
+@dataclass
+class VelbusData:
+ """Runtime data for the Velbus config entry."""
+
+ controller: Velbus
+ scan_task: asyncio.Task
+
+
+async def velbus_scan_task(
controller: Velbus, hass: HomeAssistant, entry_id: str
) -> None:
- """Task to offload the long running connect."""
+ """Task to offload the long running scan."""
try:
- await controller.connect()
+ await controller.start()
except ConnectionError as ex:
raise PlatformNotReady(
f"Connection error while connecting to Velbus {entry_id}: {ex}"
@@ -67,133 +74,41 @@ def _migrate_device_identifiers(hass: HomeAssistant, entry_id: str) -> None:
dev_reg.async_update_device(device.id, new_identifiers=new_identifier)
-async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
- """Establish connection with velbus."""
- hass.data.setdefault(DOMAIN, {})
+async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
+ """Set up the actions for the Velbus component."""
+ setup_services(hass)
+ return True
+
+async def async_setup_entry(hass: HomeAssistant, entry: VelbusConfigEntry) -> bool:
+ """Establish connection with velbus."""
controller = Velbus(
entry.data[CONF_PORT],
cache_dir=hass.config.path(STORAGE_DIR, f"velbuscache-{entry.entry_id}"),
)
- hass.data[DOMAIN][entry.entry_id] = {}
- hass.data[DOMAIN][entry.entry_id]["cntrl"] = controller
- hass.data[DOMAIN][entry.entry_id]["tsk"] = hass.async_create_task(
- velbus_connect_task(controller, hass, entry.entry_id)
- )
+ try:
+ await controller.connect()
+ except VelbusConnectionFailed as error:
+ raise ConfigEntryNotReady("Cannot connect to Velbus") from error
+
+ task = hass.async_create_task(velbus_scan_task(controller, hass, entry.entry_id))
+ entry.runtime_data = VelbusData(controller=controller, scan_task=task)
_migrate_device_identifiers(hass, entry.entry_id)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
- if hass.services.has_service(DOMAIN, SERVICE_SCAN):
- return True
-
- def check_entry_id(interface: str) -> str:
- 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
- raise vol.Invalid(
- "The interface provided is not defined as a port in a Velbus integration"
- )
-
- async def scan(call: ServiceCall) -> None:
- await hass.data[DOMAIN][call.data[CONF_INTERFACE]]["cntrl"].scan()
-
- hass.services.async_register(
- DOMAIN,
- SERVICE_SCAN,
- scan,
- vol.Schema({vol.Required(CONF_INTERFACE): vol.All(cv.string, check_entry_id)}),
- )
-
- async def syn_clock(call: ServiceCall) -> None:
- await hass.data[DOMAIN][call.data[CONF_INTERFACE]]["cntrl"].sync_clock()
-
- hass.services.async_register(
- DOMAIN,
- SERVICE_SYNC,
- syn_clock,
- vol.Schema({vol.Required(CONF_INTERFACE): vol.All(cv.string, check_entry_id)}),
- )
-
- async def set_memo_text(call: ServiceCall) -> None:
- """Handle Memo Text service call."""
- memo_text = call.data[CONF_MEMO_TEXT]
- await (
- hass.data[DOMAIN][call.data[CONF_INTERFACE]]["cntrl"]
- .get_module(call.data[CONF_ADDRESS])
- .set_memo_text(memo_text)
- )
-
- 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.string,
- }
- ),
- )
-
- async def clear_cache(call: ServiceCall) -> None:
- """Handle a clear cache service call."""
- # clear the cache
- 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",
- ),
- )
- else:
- await hass.async_add_executor_job(
- shutil.rmtree,
- hass.config.path(
- STORAGE_DIR, f"velbuscache-{call.data[CONF_INTERFACE]}/"
- ),
- )
- # call a scan to repopulate
- await scan(call)
-
- hass.services.async_register(
- 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)
- ),
- }
- ),
- )
-
return True
-async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_unload_entry(hass: HomeAssistant, entry: VelbusConfigEntry) -> bool:
"""Unload (close) the velbus connection."""
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
- await hass.data[DOMAIN][entry.entry_id]["cntrl"].stop()
- hass.data[DOMAIN].pop(entry.entry_id)
- if not hass.data[DOMAIN]:
- hass.data.pop(DOMAIN)
- hass.services.async_remove(DOMAIN, SERVICE_SCAN)
- hass.services.async_remove(DOMAIN, SERVICE_SYNC)
- hass.services.async_remove(DOMAIN, SERVICE_SET_MEMO_TEXT)
- hass.services.async_remove(DOMAIN, SERVICE_CLEAR_CACHE)
+ await entry.runtime_data.controller.stop()
return unload_ok
-async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
+async def async_remove_entry(hass: HomeAssistant, entry: VelbusConfigEntry) -> None:
"""Remove the velbus entry, so we also have to cleanup the cache dir."""
await hass.async_add_executor_job(
shutil.rmtree,
@@ -201,7 +116,9 @@ async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
)
-async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
+async def async_migrate_entry(
+ hass: HomeAssistant, config_entry: VelbusConfigEntry
+) -> bool:
"""Migrate old entry."""
_LOGGER.debug("Migrating from version %s", config_entry.version)
cache_path = hass.config.path(STORAGE_DIR, f"velbuscache-{config_entry.entry_id}/")
diff --git a/homeassistant/components/velbus/binary_sensor.py b/homeassistant/components/velbus/binary_sensor.py
index 5f363c1a035..88dc994efe8 100644
--- a/homeassistant/components/velbus/binary_sensor.py
+++ b/homeassistant/components/velbus/binary_sensor.py
@@ -3,24 +3,25 @@
from velbusaio.channels import Button as VelbusButton
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 VelbusConfigEntry
from .entity import VelbusEntity
+PARALLEL_UPDATES = 0
+
async def async_setup_entry(
hass: HomeAssistant,
- entry: ConfigEntry,
+ entry: VelbusConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Velbus switch based on config_entry."""
- await hass.data[DOMAIN][entry.entry_id]["tsk"]
- cntrl = hass.data[DOMAIN][entry.entry_id]["cntrl"]
+ await entry.runtime_data.scan_task
async_add_entities(
- VelbusBinarySensor(channel) for channel in cntrl.get_all("binary_sensor")
+ VelbusBinarySensor(channel)
+ for channel in entry.runtime_data.controller.get_all_binary_sensor()
)
diff --git a/homeassistant/components/velbus/button.py b/homeassistant/components/velbus/button.py
index bd5b81d67a0..fc943159123 100644
--- a/homeassistant/components/velbus/button.py
+++ b/homeassistant/components/velbus/button.py
@@ -8,24 +8,27 @@ from velbusaio.channels import (
)
from homeassistant.components.button import ButtonEntity
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from .const import DOMAIN
+from . import VelbusConfigEntry
from .entity import VelbusEntity, api_call
+PARALLEL_UPDATES = 0
+
async def async_setup_entry(
hass: HomeAssistant,
- entry: ConfigEntry,
+ entry: VelbusConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Velbus switch based on config_entry."""
- await hass.data[DOMAIN][entry.entry_id]["tsk"]
- cntrl = hass.data[DOMAIN][entry.entry_id]["cntrl"]
- async_add_entities(VelbusButton(channel) for channel in cntrl.get_all("button"))
+ await entry.runtime_data.scan_task
+ async_add_entities(
+ VelbusButton(channel)
+ for channel in entry.runtime_data.controller.get_all_button()
+ )
class VelbusButton(VelbusEntity, ButtonEntity):
diff --git a/homeassistant/components/velbus/climate.py b/homeassistant/components/velbus/climate.py
index ed47d8b0a91..b2f3077ecee 100644
--- a/homeassistant/components/velbus/climate.py
+++ b/homeassistant/components/velbus/climate.py
@@ -11,25 +11,29 @@ 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 ServiceValidationError
from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from . import VelbusConfigEntry
from .const import DOMAIN, PRESET_MODES
from .entity import VelbusEntity, api_call
+PARALLEL_UPDATES = 0
+
async def async_setup_entry(
hass: HomeAssistant,
- entry: ConfigEntry,
+ entry: VelbusConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Velbus switch based on config_entry."""
- await hass.data[DOMAIN][entry.entry_id]["tsk"]
- cntrl = hass.data[DOMAIN][entry.entry_id]["cntrl"]
- async_add_entities(VelbusClimate(channel) for channel in cntrl.get_all("climate"))
+ await entry.runtime_data.scan_task
+ async_add_entities(
+ VelbusClimate(channel)
+ for channel in entry.runtime_data.controller.get_all_climate()
+ )
class VelbusClimate(VelbusEntity, ClimateEntity):
@@ -42,7 +46,6 @@ class VelbusClimate(VelbusEntity, ClimateEntity):
_attr_temperature_unit = UnitOfTemperature.CELSIUS
_attr_hvac_modes = [HVACMode.HEAT, HVACMode.COOL]
_attr_preset_modes = list(PRESET_MODES)
- _enable_turn_on_off_backwards_compatibility = False
@property
def target_temperature(self) -> float | None:
diff --git a/homeassistant/components/velbus/config_flow.py b/homeassistant/components/velbus/config_flow.py
index 0b47dfe6498..26e2fafabbc 100644
--- a/homeassistant/components/velbus/config_flow.py
+++ b/homeassistant/components/velbus/config_flow.py
@@ -35,7 +35,7 @@ class VelbusConfigFlow(ConfigFlow, domain=DOMAIN):
"""Try to connect to the velbus with the port specified."""
try:
controller = velbusaio.controller.Velbus(prt)
- await controller.connect(True)
+ await controller.connect()
await controller.stop()
except VelbusConnectionFailed:
self._errors[CONF_PORT] = "cannot_connect"
diff --git a/homeassistant/components/velbus/cover.py b/homeassistant/components/velbus/cover.py
index 8b9d927f3d7..2ddea37f2d6 100644
--- a/homeassistant/components/velbus/cover.py
+++ b/homeassistant/components/velbus/cover.py
@@ -11,23 +11,26 @@ 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 VelbusConfigEntry
from .entity import VelbusEntity, api_call
+PARALLEL_UPDATES = 0
+
async def async_setup_entry(
hass: HomeAssistant,
- entry: ConfigEntry,
+ entry: VelbusConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Velbus switch based on config_entry."""
- await hass.data[DOMAIN][entry.entry_id]["tsk"]
- cntrl = hass.data[DOMAIN][entry.entry_id]["cntrl"]
- async_add_entities(VelbusCover(channel) for channel in cntrl.get_all("cover"))
+ await entry.runtime_data.scan_task
+ async_add_entities(
+ VelbusCover(channel)
+ for channel in entry.runtime_data.controller.get_all_cover()
+ )
class VelbusCover(VelbusEntity, CoverEntity):
diff --git a/homeassistant/components/velbus/diagnostics.py b/homeassistant/components/velbus/diagnostics.py
index f7e29e2f57e..5001ac80ab3 100644
--- a/homeassistant/components/velbus/diagnostics.py
+++ b/homeassistant/components/velbus/diagnostics.py
@@ -7,42 +7,30 @@ from typing import Any
from velbusaio.channels import Channel as VelbusChannel
from velbusaio.module import Module as VelbusModule
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.device_registry import DeviceEntry
-from .const import DOMAIN
+from . import VelbusConfigEntry
async def async_get_config_entry_diagnostics(
- hass: HomeAssistant, entry: ConfigEntry
+ hass: HomeAssistant, entry: VelbusConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
- controller = hass.data[DOMAIN][entry.entry_id]["cntrl"]
+ 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: ConfigEntry, device: DeviceEntry
-) -> dict[str, Any]:
- """Return diagnostics for a device entry."""
- controller = hass.data[DOMAIN][entry.entry_id]["cntrl"]
- 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/light.py b/homeassistant/components/velbus/light.py
index 7145576be6a..1adf52a8198 100644
--- a/homeassistant/components/velbus/light.py
+++ b/homeassistant/components/velbus/light.py
@@ -20,28 +20,32 @@ from homeassistant.components.light import (
LightEntity,
LightEntityFeature,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from .const import DOMAIN
+from . import VelbusConfigEntry
from .entity import VelbusEntity, api_call
+PARALLEL_UPDATES = 0
+
async def async_setup_entry(
hass: HomeAssistant,
- entry: ConfigEntry,
+ entry: VelbusConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Velbus switch based on config_entry."""
- await hass.data[DOMAIN][entry.entry_id]["tsk"]
- cntrl = hass.data[DOMAIN][entry.entry_id]["cntrl"]
+ await entry.runtime_data.scan_task
entities: list[Entity] = [
- VelbusLight(channel) for channel in cntrl.get_all("light")
+ VelbusLight(channel)
+ for channel in entry.runtime_data.controller.get_all_light()
]
- entities.extend(VelbusButtonLight(channel) for channel in cntrl.get_all("led"))
+ entities.extend(
+ VelbusButtonLight(channel)
+ for channel in entry.runtime_data.controller.get_all_led()
+ )
async_add_entities(entities)
diff --git a/homeassistant/components/velbus/manifest.json b/homeassistant/components/velbus/manifest.json
index 5443afeef77..94c823888b7 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.10.0"],
+ "requirements": ["velbus-aio==2024.12.3"],
"usb": [
{
"vid": "10CF",
diff --git a/homeassistant/components/velbus/quality_scale.yaml b/homeassistant/components/velbus/quality_scale.yaml
new file mode 100644
index 00000000000..477b6768e71
--- /dev/null
+++ b/homeassistant/components/velbus/quality_scale.yaml
@@ -0,0 +1,79 @@
+rules:
+ # Bronze
+ action-setup: done
+ appropriate-polling:
+ status: exempt
+ comment: |
+ This integration does not poll.
+ brands: done
+ common-modules: done
+ config-flow-test-coverage: done
+ config-flow:
+ status: todo
+ comment: |
+ Dynamically build up the port parameter based on inputs provided by the user, do not fill-in a name parameter, build it up in the config flow
+ dependency-transparency: done
+ docs-actions: done
+ docs-high-level-description: done
+ docs-installation-instructions: done
+ docs-removal-instructions: done
+ entity-event-setup: todo
+ entity-unique-id: done
+ has-entity-name: todo
+ 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
+
+ # Silver
+ action-exceptions: todo
+ config-entry-unloading: done
+ docs-configuration-parameters: todo
+ docs-installation-parameters: todo
+ entity-unavailable: todo
+ integration-owner: done
+ log-when-unavailable: done
+ parallel-updates: done
+ reauthentication-flow:
+ status: exempt
+ comment: |
+ This integration does not require authentication.
+ test-coverage: todo
+ # Gold
+ devices: done
+ diagnostics: done
+ discovery-update-info: done
+ discovery: done
+ 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: todo
+ comment: |
+ Dynamic devices are discovered, but no entities are created for them
+ entity-category: done
+ entity-device-class: todo
+ entity-disabled-by-default: done
+ entity-translations: todo
+ exception-translations: todo
+ icon-translations: todo
+ reconfiguration-flow: todo
+ repair-issues:
+ status: exempt
+ comment: |
+ This integration doesn't have any cases where raising an issue is needed.
+ stale-devices: todo
+ # Platinum
+ async-dependency: done
+ inject-websession:
+ status: exempt
+ comment: |
+ This integration communicates via serial/usb/tcp and does not require a web session.
+ strict-typing: done
diff --git a/homeassistant/components/velbus/select.py b/homeassistant/components/velbus/select.py
index 7eecb85fc47..6c2dfe0a3b1 100644
--- a/homeassistant/components/velbus/select.py
+++ b/homeassistant/components/velbus/select.py
@@ -3,24 +3,27 @@
from velbusaio.channels import SelectedProgram
from homeassistant.components.select import SelectEntity
-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 . import VelbusConfigEntry
from .entity import VelbusEntity, api_call
+PARALLEL_UPDATES = 0
+
async def async_setup_entry(
hass: HomeAssistant,
- entry: ConfigEntry,
+ entry: VelbusConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Velbus select based on config_entry."""
- await hass.data[DOMAIN][entry.entry_id]["tsk"]
- cntrl = hass.data[DOMAIN][entry.entry_id]["cntrl"]
- async_add_entities(VelbusSelect(channel) for channel in cntrl.get_all("select"))
+ await entry.runtime_data.scan_task
+ async_add_entities(
+ VelbusSelect(channel)
+ for channel in entry.runtime_data.controller.get_all_select()
+ )
class VelbusSelect(VelbusEntity, SelectEntity):
diff --git a/homeassistant/components/velbus/sensor.py b/homeassistant/components/velbus/sensor.py
index b765eebcddc..77833da3ee1 100644
--- a/homeassistant/components/velbus/sensor.py
+++ b/homeassistant/components/velbus/sensor.py
@@ -9,24 +9,24 @@ from homeassistant.components.sensor import (
SensorEntity,
SensorStateClass,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from .const import DOMAIN
+from . import VelbusConfigEntry
from .entity import VelbusEntity
+PARALLEL_UPDATES = 0
+
async def async_setup_entry(
hass: HomeAssistant,
- entry: ConfigEntry,
+ entry: VelbusConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Velbus switch based on config_entry."""
- await hass.data[DOMAIN][entry.entry_id]["tsk"]
- cntrl = hass.data[DOMAIN][entry.entry_id]["cntrl"]
+ await entry.runtime_data.scan_task
entities = []
- for channel in cntrl.get_all("sensor"):
+ for channel in entry.runtime_data.controller.get_all_sensor():
entities.append(VelbusSensor(channel))
if channel.is_counter_channel():
entities.append(VelbusSensor(channel, True))
diff --git a/homeassistant/components/velbus/services.py b/homeassistant/components/velbus/services.py
new file mode 100644
index 00000000000..3f0b1bd6cdb
--- /dev/null
+++ b/homeassistant/components/velbus/services.py
@@ -0,0 +1,132 @@
+"""Support for Velbus devices."""
+
+from __future__ import annotations
+
+from contextlib import suppress
+import os
+import shutil
+from typing import TYPE_CHECKING
+
+import voluptuous as vol
+
+from homeassistant.const import CONF_ADDRESS
+from homeassistant.core import HomeAssistant, ServiceCall
+from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers.storage import STORAGE_DIR
+
+if TYPE_CHECKING:
+ from . import VelbusConfigEntry
+
+from .const import (
+ CONF_INTERFACE,
+ CONF_MEMO_TEXT,
+ DOMAIN,
+ SERVICE_CLEAR_CACHE,
+ SERVICE_SCAN,
+ SERVICE_SET_MEMO_TEXT,
+ SERVICE_SYNC,
+)
+
+
+def setup_services(hass: HomeAssistant) -> None:
+ """Register the velbus services."""
+
+ def check_entry_id(interface: str) -> str:
+ 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
+ raise vol.Invalid(
+ "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 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()
+
+ 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()
+
+ 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())
+
+ async def clear_cache(call: ServiceCall) -> None:
+ """Handle a clear cache service call."""
+ # clear the cache
+ 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",
+ ),
+ )
+ else:
+ await hass.async_add_executor_job(
+ shutil.rmtree,
+ hass.config.path(
+ STORAGE_DIR, f"velbuscache-{call.data[CONF_INTERFACE]}/"
+ ),
+ )
+ # call a scan to repopulate
+ await scan(call)
+
+ hass.services.async_register(
+ DOMAIN,
+ SERVICE_SCAN,
+ scan,
+ vol.Schema({vol.Required(CONF_INTERFACE): vol.All(cv.string, check_entry_id)}),
+ )
+
+ hass.services.async_register(
+ DOMAIN,
+ SERVICE_SYNC,
+ syn_clock,
+ vol.Schema({vol.Required(CONF_INTERFACE): vol.All(cv.string, check_entry_id)}),
+ )
+
+ 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,
+ }
+ ),
+ )
+
+ hass.services.async_register(
+ 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)
+ ),
+ }
+ ),
+ )
diff --git a/homeassistant/components/velbus/strings.json b/homeassistant/components/velbus/strings.json
index 55c7fda84ac..be1d992056e 100644
--- a/homeassistant/components/velbus/strings.json
+++ b/homeassistant/components/velbus/strings.json
@@ -35,7 +35,7 @@
},
"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%]",
@@ -53,13 +53,13 @@
},
"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%]",
@@ -67,11 +67,11 @@
},
"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."
}
}
}
diff --git a/homeassistant/components/velbus/switch.py b/homeassistant/components/velbus/switch.py
index 1e6014b8d90..8256e716d4f 100644
--- a/homeassistant/components/velbus/switch.py
+++ b/homeassistant/components/velbus/switch.py
@@ -5,23 +5,26 @@ from typing import Any
from velbusaio.channels import Relay as VelbusRelay
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 VelbusConfigEntry
from .entity import VelbusEntity, api_call
+PARALLEL_UPDATES = 0
+
async def async_setup_entry(
hass: HomeAssistant,
- entry: ConfigEntry,
+ entry: VelbusConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Velbus switch based on config_entry."""
- await hass.data[DOMAIN][entry.entry_id]["tsk"]
- cntrl = hass.data[DOMAIN][entry.entry_id]["cntrl"]
- async_add_entities(VelbusSwitch(channel) for channel in cntrl.get_all("switch"))
+ await entry.runtime_data.scan_task
+ async_add_entities(
+ VelbusSwitch(channel)
+ for channel in entry.runtime_data.controller.get_all_switch()
+ )
class VelbusSwitch(VelbusEntity, SwitchEntity):
diff --git a/homeassistant/components/velux/manifest.json b/homeassistant/components/velux/manifest.json
index c3576aca925..053b7fcc594 100644
--- a/homeassistant/components/velux/manifest.json
+++ b/homeassistant/components/velux/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/velux",
"iot_class": "local_polling",
"loggers": ["pyvlx"],
- "requirements": ["pyvlx==0.2.21"]
+ "requirements": ["pyvlx==0.2.26"]
}
diff --git a/homeassistant/components/venstar/climate.py b/homeassistant/components/venstar/climate.py
index 2865d64201e..c5323e1e9a8 100644
--- a/homeassistant/components/venstar/climate.py
+++ b/homeassistant/components/venstar/climate.py
@@ -110,7 +110,6 @@ class VenstarThermostat(VenstarEntity, ClimateEntity):
_attr_hvac_modes = [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF, HVACMode.AUTO]
_attr_precision = PRECISION_HALVES
_attr_name = None
- _enable_turn_on_off_backwards_compatibility = False
def __init__(
self,
diff --git a/homeassistant/components/vera/climate.py b/homeassistant/components/vera/climate.py
index 01fe26be6bc..eb2a5206f30 100644
--- a/homeassistant/components/vera/climate.py
+++ b/homeassistant/components/vera/climate.py
@@ -54,7 +54,6 @@ class VeraThermostat(VeraEntity[veraApi.VeraThermostat], ClimateEntity):
| ClimateEntityFeature.TURN_OFF
| ClimateEntityFeature.TURN_ON
)
- _enable_turn_on_off_backwards_compatibility = False
def __init__(
self, vera_device: veraApi.VeraThermostat, controller_data: ControllerData
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/manifest.json b/homeassistant/components/versasense/manifest.json
index 421a46bc2f6..1f1ee9e6b9c 100644
--- a/homeassistant/components/versasense/manifest.json
+++ b/homeassistant/components/versasense/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/versasense",
"iot_class": "local_polling",
"loggers": ["pyversasense"],
+ "quality_scale": "legacy",
"requirements": ["pyversasense==0.0.6"]
}
diff --git a/homeassistant/components/vesync/__init__.py b/homeassistant/components/vesync/__init__.py
index b6f263f3037..8e8b7744988 100644
--- a/homeassistant/components/vesync/__init__.py
+++ b/homeassistant/components/vesync/__init__.py
@@ -13,6 +13,7 @@ from .common import async_process_devices
from .const import (
DOMAIN,
SERVICE_UPDATE_DEVS,
+ VS_COORDINATOR,
VS_DISCOVERY,
VS_FANS,
VS_LIGHTS,
@@ -20,6 +21,7 @@ from .const import (
VS_SENSORS,
VS_SWITCHES,
)
+from .coordinator import VeSyncDataCoordinator
PLATFORMS = [Platform.FAN, Platform.LIGHT, Platform.SENSOR, Platform.SWITCH]
@@ -48,6 +50,11 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
hass.data[DOMAIN] = {}
hass.data[DOMAIN][VS_MANAGER] = manager
+ coordinator = VeSyncDataCoordinator(hass, manager)
+
+ # Store coordinator at domain level since only single integration instance is permitted.
+ hass.data[DOMAIN][VS_COORDINATOR] = coordinator
+
switches = hass.data[DOMAIN][VS_SWITCHES] = []
fans = hass.data[DOMAIN][VS_FANS] = []
lights = hass.data[DOMAIN][VS_LIGHTS] = []
@@ -135,7 +142,18 @@ 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."""
- unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
+ 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
+ )
if unload_ok:
hass.data.pop(DOMAIN)
diff --git a/homeassistant/components/vesync/common.py b/homeassistant/components/vesync/common.py
index 5f7b2a3a29e..5412b4f970c 100644
--- a/homeassistant/components/vesync/common.py
+++ b/homeassistant/components/vesync/common.py
@@ -2,14 +2,21 @@
import logging
+from pyvesync import VeSync
+from pyvesync.vesyncbasedevice import VeSyncBaseDevice
+
+from homeassistant.core import HomeAssistant
+
from .const import VS_FANS, VS_LIGHTS, VS_SENSORS, VS_SWITCHES
_LOGGER = logging.getLogger(__name__)
-async def async_process_devices(hass, manager):
+async def async_process_devices(
+ hass: HomeAssistant, manager: VeSync
+) -> dict[str, list[VeSyncBaseDevice]]:
"""Assign devices to proper component."""
- devices = {}
+ devices: dict[str, list[VeSyncBaseDevice]] = {}
devices[VS_SWITCHES] = []
devices[VS_FANS] = []
devices[VS_LIGHTS] = []
diff --git a/homeassistant/components/vesync/const.py b/homeassistant/components/vesync/const.py
index 48215819ce5..2a8c5722340 100644
--- a/homeassistant/components/vesync/const.py
+++ b/homeassistant/components/vesync/const.py
@@ -4,10 +4,25 @@ DOMAIN = "vesync"
VS_DISCOVERY = "vesync_discovery_{}"
SERVICE_UPDATE_DEVS = "update_devices"
+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_SWITCHES = "switches"
VS_FANS = "fans"
VS_LIGHTS = "lights"
VS_SENSORS = "sensors"
+VS_COORDINATOR = "coordinator"
VS_MANAGER = "manager"
DEV_TYPE_TO_HA = {
@@ -56,6 +71,7 @@ SKU_TO_BASE_DEVICE = {
"LAP-V201S-WEU": "Vital200S", # Alt ID Model Vital200S
"LAP-V201S-WUS": "Vital200S", # Alt ID Model Vital200S
"LAP-V201-AUSR": "Vital200S", # Alt ID Model Vital200S
+ "LAP-V201S-AEUR": "Vital200S", # Alt ID Model Vital200S
"LAP-V201S-AUSR": "Vital200S", # Alt ID Model Vital200S
"Vital100S": "Vital100S",
"LAP-V102S-WUS": "Vital100S", # Alt ID Model Vital100S
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 58a262e769f..c6d61feebef 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_DISCOVERY,
+ VS_FANS,
+)
+from .coordinator import VeSyncDataCoordinator
+from .entity import VeSyncBaseEntity
_LOGGER = logging.getLogger(__name__)
@@ -56,25 +66,31 @@ 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)
)
- _setup_entities(hass.data[DOMAIN][VS_FANS], async_add_entities)
+ _setup_entities(hass.data[DOMAIN][VS_FANS], async_add_entities, coordinator)
@callback
-def _setup_entities(devices, async_add_entities):
+def _setup_entities(
+ devices: list[VeSyncBaseDevice],
+ async_add_entities,
+ coordinator: VeSyncDataCoordinator,
+):
"""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))
+ if DEV_TYPE_TO_HA.get(SKU_TO_BASE_DEVICE.get(dev.device_type, "")) == "fan":
+ entities.append(VeSyncFanHA(dev, coordinator))
else:
_LOGGER.warning(
"%s - Unknown device type - %s", dev.device_name, dev.device_type
@@ -84,7 +100,7 @@ def _setup_entities(devices, async_add_entities):
async_add_entities(entities, update_before_add=True)
-class VeSyncFanHA(VeSyncDevice, FanEntity):
+class VeSyncFanHA(VeSyncBaseEntity, FanEntity):
"""Representation of a VeSync fan."""
_attr_supported_features = (
@@ -94,13 +110,20 @@ class VeSyncFanHA(VeSyncDevice, FanEntity):
| FanEntityFeature.TURN_ON
)
_attr_name = None
- _enable_turn_on_off_backwards_compatibility = False
+ _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."""
@@ -213,3 +236,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/icons.json b/homeassistant/components/vesync/icons.json
index cfdefb2ed09..e4769acc9a5 100644
--- a/homeassistant/components/vesync/icons.json
+++ b/homeassistant/components/vesync/icons.json
@@ -1,4 +1,20 @@
{
+ "entity": {
+ "fan": {
+ "vesync": {
+ "state_attributes": {
+ "preset_mode": {
+ "state": {
+ "auto": "mdi:fan-auto",
+ "sleep": "mdi:sleep",
+ "pet": "mdi:paw",
+ "turbo": "mdi:weather-tornado"
+ }
+ }
+ }
+ }
+ }
+ },
"services": {
"update_devices": {
"service": "mdi:update"
diff --git a/homeassistant/components/vesync/light.py b/homeassistant/components/vesync/light.py
index 6e449f63394..84324e0af6e 100644
--- a/homeassistant/components/vesync/light.py
+++ b/homeassistant/components/vesync/light.py
@@ -3,9 +3,11 @@
import logging
from typing import Any
+from pyvesync.vesyncbasedevice import VeSyncBaseDevice
+
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
- ATTR_COLOR_TEMP,
+ ATTR_COLOR_TEMP_KELVIN,
ColorMode,
LightEntity,
)
@@ -13,11 +15,15 @@ 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 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_DISCOVERY, VS_LIGHTS
+from .coordinator import VeSyncDataCoordinator
+from .entity import VeSyncBaseEntity
_LOGGER = logging.getLogger(__name__)
+MAX_MIREDS = 370 # 1,000,000 divided by 2700 Kelvin = 370 Mireds
+MIN_MIREDS = 153 # 1,000,000 divided by 6500 Kelvin = 153 Mireds
async def async_setup_entry(
@@ -27,27 +33,33 @@ 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)
)
- _setup_entities(hass.data[DOMAIN][VS_LIGHTS], async_add_entities)
+ _setup_entities(hass.data[DOMAIN][VS_LIGHTS], async_add_entities, coordinator)
@callback
-def _setup_entities(devices, async_add_entities):
+def _setup_entities(
+ devices: list[VeSyncBaseDevice],
+ async_add_entities,
+ coordinator: VeSyncDataCoordinator,
+):
"""Check if device is online and add entity."""
- entities = []
+ 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))
+ entities.append(VeSyncTunableWhiteLightHA(dev, coordinator))
else:
_LOGGER.debug(
"%s - Unknown device type - %s", dev.device_name, dev.device_type
@@ -57,11 +69,16 @@ def _setup_entities(devices, async_add_entities):
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."""
@@ -84,15 +101,16 @@ class VeSyncBaseLight(VeSyncDevice, LightEntity):
"""Turn the device on."""
attribute_adjustment_only = False
# set white temperature
- if self.color_mode == ColorMode.COLOR_TEMP and ATTR_COLOR_TEMP in kwargs:
+ if self.color_mode == ColorMode.COLOR_TEMP and ATTR_COLOR_TEMP_KELVIN in kwargs:
# get white temperature from HA data
- color_temp = int(kwargs[ATTR_COLOR_TEMP])
+ color_temp = color_util.color_temperature_kelvin_to_mired(
+ kwargs[ATTR_COLOR_TEMP_KELVIN]
+ )
# ensure value between min-max supported Mireds
- color_temp = max(self.min_mireds, min(color_temp, self.max_mireds))
+ color_temp = max(MIN_MIREDS, min(color_temp, MAX_MIREDS))
# convert Mireds to Percent value that api expects
color_temp = round(
- ((color_temp - self.min_mireds) / (self.max_mireds - self.min_mireds))
- * 100
+ ((color_temp - MIN_MIREDS) / (MAX_MIREDS - MIN_MIREDS)) * 100
)
# flip cold/warm to what pyvesync api expects
color_temp = 100 - color_temp
@@ -126,25 +144,29 @@ 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
- _attr_max_mireds = 370 # 1,000,000 divided by 2700 Kelvin = 370 Mireds
- _attr_min_mireds = 154 # 1,000,000 divided by 6500 Kelvin = 154 Mireds
+ _attr_min_color_temp_kelvin = 2700 # 370 Mireds
+ _attr_max_color_temp_kelvin = 6500 # 153 Mireds
_attr_supported_color_modes = {ColorMode.COLOR_TEMP}
@property
- def color_temp(self) -> int:
- """Get device white temperature."""
+ def color_temp_kelvin(self) -> int | None:
+ """Return the color temperature value in Kelvin."""
# get value from pyvesync library api,
result = self.device.color_temp_pct
try:
@@ -159,15 +181,16 @@ class VeSyncTunableWhiteLightHA(VeSyncBaseLight, LightEntity):
),
result,
)
- return 0
+ return None
# flip cold/warm
color_temp_value = 100 - color_temp_value
# ensure value between 0-100
color_temp_value = max(0, min(color_temp_value, 100))
# convert percent value to Mireds
color_temp_value = round(
- self.min_mireds
- + ((self.max_mireds - self.min_mireds) / 100 * color_temp_value)
+ MIN_MIREDS + ((MAX_MIREDS - MIN_MIREDS) / 100 * color_temp_value)
)
# ensure value between minimum and maximum Mireds
- return max(self.min_mireds, min(color_temp_value, self.max_mireds))
+ return color_util.color_temperature_mired_to_kelvin(
+ max(MIN_MIREDS, min(color_temp_value, MAX_MIREDS))
+ )
diff --git a/homeassistant/components/vesync/manifest.json b/homeassistant/components/vesync/manifest.json
index c5926cc224a..a706b2157ba 100644
--- a/homeassistant/components/vesync/manifest.json
+++ b/homeassistant/components/vesync/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/vesync",
"iot_class": "cloud_polling",
"loggers": ["pyvesync"],
- "requirements": ["pyvesync==2.1.12"]
+ "requirements": ["pyvesync==2.1.15"]
}
diff --git a/homeassistant/components/vesync/sensor.py b/homeassistant/components/vesync/sensor.py
index 79061ec0c4c..f283e3a3c0a 100644
--- a/homeassistant/components/vesync/sensor.py
+++ b/homeassistant/components/vesync/sensor.py
@@ -6,6 +6,7 @@ from collections.abc import Callable
from dataclasses import dataclass
import logging
+from pyvesync.vesyncbasedevice import VeSyncBaseDevice
from pyvesync.vesyncfan import VeSyncAirBypass
from pyvesync.vesyncoutlet import VeSyncOutlet
from pyvesync.vesyncswitch import VeSyncSwitch
@@ -30,7 +31,15 @@ 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 .const import (
+ DEV_TYPE_TO_HA,
+ DOMAIN,
+ SKU_TO_BASE_DEVICE,
+ VS_COORDINATOR,
+ VS_DISCOVERY,
+ VS_SENSORS,
+)
+from .coordinator import VeSyncDataCoordinator
from .entity import VeSyncBaseEntity
_LOGGER = logging.getLogger(__name__)
@@ -187,24 +196,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)
)
- _setup_entities(hass.data[DOMAIN][VS_SENSORS], async_add_entities)
+ _setup_entities(hass.data[DOMAIN][VS_SENSORS], async_add_entities, coordinator)
@callback
-def _setup_entities(devices, async_add_entities):
+def _setup_entities(
+ devices: list[VeSyncBaseDevice],
+ async_add_entities,
+ 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)
@@ -222,9 +238,10 @@ class VeSyncSensorEntity(VeSyncBaseEntity, SensorEntity):
self,
device: VeSyncAirBypass | VeSyncOutlet | VeSyncSwitch,
description: VeSyncSensorEntityDescription,
+ coordinator,
) -> 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 5ff0aa58722..b6e4e2fd957 100644
--- a/homeassistant/components/vesync/strings.json
+++ b/homeassistant/components/vesync/strings.json
@@ -42,6 +42,20 @@
"current_voltage": {
"name": "Current voltage"
}
+ },
+ "fan": {
+ "vesync": {
+ "state_attributes": {
+ "preset_mode": {
+ "state": {
+ "auto": "Auto",
+ "sleep": "Sleep",
+ "pet": "Pet",
+ "turbo": "Turbo"
+ }
+ }
+ }
+ }
}
},
"services": {
diff --git a/homeassistant/components/vesync/switch.py b/homeassistant/components/vesync/switch.py
index a162a648ad7..0b69ca3d44a 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_DISCOVERY, VS_SWITCHES
+from .coordinator import VeSyncDataCoordinator
+from .entity import VeSyncBaseEntity
_LOGGER = logging.getLogger(__name__)
@@ -22,27 +25,33 @@ 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)
)
- _setup_entities(hass.data[DOMAIN][VS_SWITCHES], async_add_entities)
+ _setup_entities(hass.data[DOMAIN][VS_SWITCHES], async_add_entities, coordinator)
@callback
-def _setup_entities(devices, async_add_entities):
+def _setup_entities(
+ devices: list[VeSyncBaseDevice],
+ async_add_entities,
+ coordinator: VeSyncDataCoordinator,
+):
"""Check if device is online and add entity."""
- entities = []
+ 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))
+ entities.append(VeSyncLightSwitch(dev, coordinator))
else:
_LOGGER.warning(
"%s - Unknown device type - %s", dev.device_name, dev.device_type
@@ -52,7 +61,7 @@ def _setup_entities(devices, async_add_entities):
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 +70,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/manifest.json b/homeassistant/components/viaggiatreno/manifest.json
index 904f9c0bebf..584742c8c59 100644
--- a/homeassistant/components/viaggiatreno/manifest.json
+++ b/homeassistant/components/viaggiatreno/manifest.json
@@ -3,5 +3,6 @@
"name": "Trenitalia ViaggiaTreno",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/viaggiatreno",
- "iot_class": "cloud_polling"
+ "iot_class": "cloud_polling",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/vicare/__init__.py b/homeassistant/components/vicare/__init__.py
index d6b9e4b923a..9c331f0e9ec 100644
--- a/homeassistant/components/vicare/__init__.py
+++ b/homeassistant/components/vicare/__init__.py
@@ -2,11 +2,9 @@
from __future__ import annotations
-from collections.abc import Mapping
from contextlib import suppress
import logging
import os
-from typing import Any
from PyViCare.PyViCare import PyViCare
from PyViCare.PyViCareDeviceConfig import PyViCareDeviceConfig
@@ -16,8 +14,6 @@ from PyViCare.PyViCareUtils import (
)
from homeassistant.components.climate import DOMAIN as DOMAIN_CLIMATE
-from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import CONF_CLIENT_ID, CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers import device_registry as dr, entity_registry as er
@@ -25,31 +21,28 @@ from homeassistant.helpers.storage import STORAGE_DIR
from .const import (
DEFAULT_CACHE_DURATION,
- DEVICE_LIST,
DOMAIN,
PLATFORMS,
UNSUPPORTED_DEVICES,
+ VICARE_TOKEN_FILENAME,
)
-from .types import ViCareDevice
-from .utils import get_device, get_device_serial
+from .types import ViCareConfigEntry, ViCareData, ViCareDevice
+from .utils import get_device, get_device_serial, login
_LOGGER = logging.getLogger(__name__)
-_TOKEN_FILENAME = "vicare_token.save"
-async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_setup_entry(hass: HomeAssistant, entry: ViCareConfigEntry) -> bool:
"""Set up from config entry."""
_LOGGER.debug("Setting up ViCare component")
-
- hass.data[DOMAIN] = {}
- hass.data[DOMAIN][entry.entry_id] = {}
-
try:
- await hass.async_add_executor_job(setup_vicare_api, hass, entry)
+ entry.runtime_data = await hass.async_add_executor_job(
+ setup_vicare_api, hass, entry
+ )
except (PyViCareInvalidConfigurationError, PyViCareInvalidCredentialsError) as err:
raise ConfigEntryAuthFailed("Authentication failed") from err
- for device in hass.data[DOMAIN][entry.entry_id][DEVICE_LIST]:
+ for device in entry.runtime_data.devices:
# Migration can be removed in 2025.4.0
await async_migrate_devices_and_entities(hass, entry, device)
@@ -58,28 +51,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
return True
-def vicare_login(
- hass: HomeAssistant,
- entry_data: Mapping[str, Any],
- cache_duration=DEFAULT_CACHE_DURATION,
-) -> PyViCare:
- """Login via PyVicare API."""
- vicare_api = PyViCare()
- vicare_api.setCacheDuration(cache_duration)
- vicare_api.initWithCredentials(
- entry_data[CONF_USERNAME],
- entry_data[CONF_PASSWORD],
- entry_data[CONF_CLIENT_ID],
- hass.config.path(STORAGE_DIR, _TOKEN_FILENAME),
- )
- return vicare_api
-
-
-def setup_vicare_api(hass: HomeAssistant, entry: ConfigEntry) -> None:
+def setup_vicare_api(hass: HomeAssistant, entry: ViCareConfigEntry) -> PyViCare:
"""Set up PyVicare API."""
- vicare_api = vicare_login(hass, entry.data)
+ client = login(hass, entry.data)
- device_config_list = get_supported_devices(vicare_api.devices)
+ device_config_list = get_supported_devices(client.devices)
+
+ # increase cache duration to fit rate limit to number of devices
if (number_of_devices := len(device_config_list)) > 1:
cache_duration = DEFAULT_CACHE_DURATION * number_of_devices
_LOGGER.debug(
@@ -87,36 +65,35 @@ def setup_vicare_api(hass: HomeAssistant, entry: ConfigEntry) -> None:
number_of_devices,
cache_duration,
)
- vicare_api = vicare_login(hass, entry.data, cache_duration)
- device_config_list = get_supported_devices(vicare_api.devices)
+ client = login(hass, entry.data, cache_duration)
+ device_config_list = get_supported_devices(client.devices)
for device in device_config_list:
_LOGGER.debug(
"Found device: %s (online: %s)", device.getModel(), str(device.isOnline())
)
- hass.data[DOMAIN][entry.entry_id][DEVICE_LIST] = [
+ devices = [
ViCareDevice(config=device_config, api=get_device(entry, device_config))
for device_config in device_config_list
]
+ return ViCareData(client=client, devices=devices)
-async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_unload_entry(hass: HomeAssistant, entry: ViCareConfigEntry) -> bool:
"""Unload ViCare config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
- if unload_ok:
- hass.data[DOMAIN].pop(entry.entry_id)
with suppress(FileNotFoundError):
await hass.async_add_executor_job(
- os.remove, hass.config.path(STORAGE_DIR, _TOKEN_FILENAME)
+ os.remove, hass.config.path(STORAGE_DIR, VICARE_TOKEN_FILENAME)
)
return unload_ok
async def async_migrate_devices_and_entities(
- hass: HomeAssistant, entry: ConfigEntry, device: ViCareDevice
+ hass: HomeAssistant, entry: ViCareConfigEntry, device: ViCareDevice
) -> None:
"""Migrate old entry."""
device_registry = dr.async_get(hass)
diff --git a/homeassistant/components/vicare/binary_sensor.py b/homeassistant/components/vicare/binary_sensor.py
index 55f0ab96ed0..ced02dae97e 100644
--- a/homeassistant/components/vicare/binary_sensor.py
+++ b/homeassistant/components/vicare/binary_sensor.py
@@ -24,13 +24,11 @@ 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 DEVICE_LIST, DOMAIN
from .entity import ViCareEntity
-from .types import ViCareDevice, ViCareRequiredKeysMixin
+from .types import ViCareConfigEntry, ViCareDevice, ViCareRequiredKeysMixin
from .utils import (
get_burners,
get_circuits,
@@ -152,16 +150,14 @@ def _build_entities(
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ config_entry: ViCareConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Create the ViCare binary sensor devices."""
- device_list = hass.data[DOMAIN][config_entry.entry_id][DEVICE_LIST]
-
async_add_entities(
await hass.async_add_executor_job(
_build_entities,
- device_list,
+ config_entry.runtime_data.devices,
)
)
diff --git a/homeassistant/components/vicare/button.py b/homeassistant/components/vicare/button.py
index 49d142c1edb..ad7d600eba3 100644
--- a/homeassistant/components/vicare/button.py
+++ b/homeassistant/components/vicare/button.py
@@ -16,14 +16,12 @@ from PyViCare.PyViCareUtils import (
import requests
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 DEVICE_LIST, DOMAIN
from .entity import ViCareEntity
-from .types import ViCareDevice, ViCareRequiredKeysMixinWithSet
+from .types import ViCareConfigEntry, ViCareDevice, ViCareRequiredKeysMixinWithSet
from .utils import get_device_serial, is_supported
_LOGGER = logging.getLogger(__name__)
@@ -67,16 +65,14 @@ def _build_entities(
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ config_entry: ViCareConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Create the ViCare button entities."""
- device_list = hass.data[DOMAIN][config_entry.entry_id][DEVICE_LIST]
-
async_add_entities(
await hass.async_add_executor_job(
_build_entities,
- device_list,
+ config_entry.runtime_data.devices,
)
)
diff --git a/homeassistant/components/vicare/climate.py b/homeassistant/components/vicare/climate.py
index 8a116038533..62231a4e2fe 100644
--- a/homeassistant/components/vicare/climate.py
+++ b/homeassistant/components/vicare/climate.py
@@ -24,7 +24,6 @@ from homeassistant.components.climate import (
HVACAction,
HVACMode,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
ATTR_TEMPERATURE,
PRECISION_TENTHS,
@@ -37,9 +36,9 @@ from homeassistant.helpers import entity_platform
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from .const import DEVICE_LIST, DOMAIN
+from .const import DOMAIN
from .entity import ViCareEntity
-from .types import HeatingProgram, ViCareDevice
+from .types import HeatingProgram, ViCareConfigEntry, ViCareDevice
from .utils import get_burners, get_circuits, get_compressors, get_device_serial
_LOGGER = logging.getLogger(__name__)
@@ -99,25 +98,22 @@ def _build_entities(
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ config_entry: ViCareConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the ViCare climate platform."""
platform = entity_platform.async_get_current_platform()
-
platform.async_register_entity_service(
SERVICE_SET_VICARE_MODE,
{vol.Required(SERVICE_SET_VICARE_MODE_ATTR_MODE): cv.string},
"set_vicare_mode",
)
- device_list = hass.data[DOMAIN][config_entry.entry_id][DEVICE_LIST]
-
async_add_entities(
await hass.async_add_executor_job(
_build_entities,
- device_list,
+ config_entry.runtime_data.devices,
)
)
@@ -140,7 +136,6 @@ class ViCareClimate(ViCareEntity, ClimateEntity):
_current_action: bool | None = None
_current_mode: str | None = None
_current_program: str | None = None
- _enable_turn_on_off_backwards_compatibility = False
def __init__(
self,
diff --git a/homeassistant/components/vicare/config_flow.py b/homeassistant/components/vicare/config_flow.py
index c711cc06074..6594e6ec9e4 100644
--- a/homeassistant/components/vicare/config_flow.py
+++ b/homeassistant/components/vicare/config_flow.py
@@ -18,7 +18,6 @@ from homeassistant.const import CONF_CLIENT_ID, CONF_PASSWORD, CONF_USERNAME
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.device_registry import format_mac
-from . import vicare_login
from .const import (
CONF_HEATING_TYPE,
DEFAULT_HEATING_TYPE,
@@ -26,6 +25,7 @@ from .const import (
VICARE_NAME,
HeatingType,
)
+from .utils import login
_LOGGER = logging.getLogger(__name__)
@@ -62,9 +62,7 @@ class ViCareConfigFlow(ConfigFlow, domain=DOMAIN):
if user_input is not None:
try:
- await self.hass.async_add_executor_job(
- vicare_login, self.hass, user_input
- )
+ await self.hass.async_add_executor_job(login, self.hass, user_input)
except (PyViCareInvalidConfigurationError, PyViCareInvalidCredentialsError):
errors["base"] = "invalid_auth"
else:
@@ -96,7 +94,7 @@ class ViCareConfigFlow(ConfigFlow, domain=DOMAIN):
}
try:
- await self.hass.async_add_executor_job(vicare_login, self.hass, data)
+ await self.hass.async_add_executor_job(login, self.hass, data)
except (PyViCareInvalidConfigurationError, PyViCareInvalidCredentialsError):
errors["base"] = "invalid_auth"
else:
diff --git a/homeassistant/components/vicare/const.py b/homeassistant/components/vicare/const.py
index 828a879927d..bcf41223d3f 100644
--- a/homeassistant/components/vicare/const.py
+++ b/homeassistant/components/vicare/const.py
@@ -25,8 +25,8 @@ UNSUPPORTED_DEVICES = [
"E3_RoomControl_One_522",
]
-DEVICE_LIST = "device_list"
VICARE_NAME = "ViCare"
+VICARE_TOKEN_FILENAME = "vicare_token.save"
CONF_CIRCUIT = "circuit"
CONF_HEATING_TYPE = "heating_type"
diff --git a/homeassistant/components/vicare/diagnostics.py b/homeassistant/components/vicare/diagnostics.py
index 9182e96509f..7695c304451 100644
--- a/homeassistant/components/vicare/diagnostics.py
+++ b/homeassistant/components/vicare/diagnostics.py
@@ -6,25 +6,24 @@ import json
from typing import Any
from homeassistant.components.diagnostics import async_redact_data
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_CLIENT_ID, CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
-from .const import DEVICE_LIST, DOMAIN
+from .types import ViCareConfigEntry
TO_REDACT = {CONF_CLIENT_ID, CONF_PASSWORD, CONF_USERNAME}
async def async_get_config_entry_diagnostics(
- hass: HomeAssistant, entry: ConfigEntry
+ hass: HomeAssistant, entry: ViCareConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
def dump_devices() -> list[dict[str, Any]]:
"""Dump devices."""
return [
- json.loads(device.config.dump_secure())
- for device in hass.data[DOMAIN][entry.entry_id][DEVICE_LIST]
+ json.loads(device.dump_secure())
+ for device in entry.runtime_data.client.devices
]
return {
diff --git a/homeassistant/components/vicare/fan.py b/homeassistant/components/vicare/fan.py
index b787de20773..69aa8396fea 100644
--- a/homeassistant/components/vicare/fan.py
+++ b/homeassistant/components/vicare/fan.py
@@ -19,7 +19,6 @@ from PyViCare.PyViCareVentilationDevice import (
from requests.exceptions import ConnectionError as RequestConnectionError
from homeassistant.components.fan import FanEntity, FanEntityFeature
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util.percentage import (
@@ -27,8 +26,8 @@ from homeassistant.util.percentage import (
percentage_to_ordered_list_item,
)
-from .const import DEVICE_LIST, DOMAIN
from .entity import ViCareEntity
+from .types import ViCareConfigEntry, ViCareDevice
from .utils import get_device_serial
_LOGGER = logging.getLogger(__name__)
@@ -90,39 +89,37 @@ ORDERED_NAMED_FAN_SPEEDS = [
]
+def _build_entities(
+ device_list: list[ViCareDevice],
+) -> list[ViCareFan]:
+ """Create ViCare climate entities for a device."""
+ return [
+ ViCareFan(get_device_serial(device.api), device.config, device.api)
+ for device in device_list
+ if isinstance(device.api, PyViCareVentilationDevice)
+ ]
+
+
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ config_entry: ViCareConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the ViCare fan platform."""
-
- device_list = hass.data[DOMAIN][config_entry.entry_id][DEVICE_LIST]
-
async_add_entities(
- [
- ViCareFan(get_device_serial(device.api), device.config, device.api)
- for device in device_list
- if isinstance(device.api, PyViCareVentilationDevice)
- ]
+ await hass.async_add_executor_job(
+ _build_entities,
+ config_entry.runtime_data.devices,
+ )
)
class ViCareFan(ViCareEntity, FanEntity):
"""Representation of the ViCare ventilation device."""
- _attr_preset_modes = list[str](
- [
- VentilationMode.PERMANENT,
- VentilationMode.VENTILATION,
- VentilationMode.SENSOR_DRIVEN,
- VentilationMode.SENSOR_OVERRIDE,
- ]
- )
_attr_speed_count = len(ORDERED_NAMED_FAN_SPEEDS)
- _attr_supported_features = FanEntityFeature.SET_SPEED | FanEntityFeature.PRESET_MODE
+ _attr_supported_features = FanEntityFeature.SET_SPEED
_attr_translation_key = "ventilation"
- _enable_turn_on_off_backwards_compatibility = False
def __init__(
self,
@@ -134,6 +131,15 @@ class ViCareFan(ViCareEntity, FanEntity):
super().__init__(
self._attr_translation_key, device_serial, device_config, device
)
+ # init presets
+ supported_modes = list[str](self._api.getAvailableModes())
+ self._attr_preset_modes = [
+ mode
+ for mode in VentilationMode
+ if VentilationMode.to_vicare_mode(mode) in supported_modes
+ ]
+ if len(self._attr_preset_modes) > 0:
+ self._attr_supported_features |= FanEntityFeature.PRESET_MODE
def update(self) -> None:
"""Update state of fan."""
@@ -161,6 +167,30 @@ class ViCareFan(ViCareEntity, FanEntity):
# Viessmann ventilation unit cannot be turned off
return True
+ @property
+ def icon(self) -> str | None:
+ """Return the icon to use in the frontend."""
+ if hasattr(self, "_attr_preset_mode"):
+ if self._attr_preset_mode == VentilationMode.VENTILATION:
+ return "mdi:fan-clock"
+ if self._attr_preset_mode in [
+ VentilationMode.SENSOR_DRIVEN,
+ VentilationMode.SENSOR_OVERRIDE,
+ ]:
+ return "mdi:fan-auto"
+ if self._attr_preset_mode == VentilationMode.PERMANENT:
+ if self._attr_percentage == 0:
+ return "mdi:fan-off"
+ if self._attr_percentage is not None:
+ level = 1 + ORDERED_NAMED_FAN_SPEEDS.index(
+ percentage_to_ordered_list_item(
+ ORDERED_NAMED_FAN_SPEEDS, self._attr_percentage
+ )
+ )
+ if level < 4: # fan-speed- only supports 1-3
+ return f"mdi:fan-speed-{level}"
+ return "mdi:fan"
+
def set_percentage(self, percentage: int) -> None:
"""Set the speed of the fan, as a percentage."""
if self._attr_preset_mode != str(VentilationMode.PERMANENT):
diff --git a/homeassistant/components/vicare/manifest.json b/homeassistant/components/vicare/manifest.json
index 8ce996ab81d..98ff6ce4c82 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.35.0"]
+ "requirements": ["PyViCare==2.39.1"]
}
diff --git a/homeassistant/components/vicare/number.py b/homeassistant/components/vicare/number.py
index f9af9636941..8ffaa727634 100644
--- a/homeassistant/components/vicare/number.py
+++ b/homeassistant/components/vicare/number.py
@@ -25,14 +25,17 @@ from homeassistant.components.number import (
NumberEntity,
NumberEntityDescription,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from .const import DEVICE_LIST, DOMAIN
from .entity import ViCareEntity
-from .types import HeatingProgram, ViCareDevice, ViCareRequiredKeysMixin
+from .types import (
+ HeatingProgram,
+ ViCareConfigEntry,
+ ViCareDevice,
+ ViCareRequiredKeysMixin,
+)
from .utils import get_circuits, get_device_serial, is_supported
_LOGGER = logging.getLogger(__name__)
@@ -370,16 +373,14 @@ def _build_entities(
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ config_entry: ViCareConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Create the ViCare number devices."""
- device_list = hass.data[DOMAIN][config_entry.entry_id][DEVICE_LIST]
-
async_add_entities(
await hass.async_add_executor_job(
_build_entities,
- device_list,
+ config_entry.runtime_data.devices,
)
)
diff --git a/homeassistant/components/vicare/quality_scale.yaml b/homeassistant/components/vicare/quality_scale.yaml
new file mode 100644
index 00000000000..55b7590a092
--- /dev/null
+++ b/homeassistant/components/vicare/quality_scale.yaml
@@ -0,0 +1,43 @@
+rules:
+ # Bronze
+ config-flow: done
+ test-before-configure: done
+ unique-config-entry:
+ status: todo
+ comment: Uniqueness is not checked yet.
+ 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 of this integration does not explicitly subscribe to events.
+ dependency-transparency: done
+ action-setup:
+ status: todo
+ comment: service registered in climate async_setup_entry.
+ common-modules:
+ status: done
+ 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: done
+ docs-actions: done
+ brands: done
+ # Silver
+ integration-owner: done
+ reauthentication-flow: done
+ config-entry-unloading: done
+ # Gold
+ devices: done
+ diagnostics: done
+ entity-category: done
+ dynamic-devices: done
+ entity-device-class: done
+ entity-translations: done
+ entity-disabled-by-default: done
+ repair-issues:
+ status: exempt
+ comment: This integration does not raise any repairable issues.
diff --git a/homeassistant/components/vicare/sensor.py b/homeassistant/components/vicare/sensor.py
index 57b7c0bec9a..3386c849f74 100644
--- a/homeassistant/components/vicare/sensor.py
+++ b/homeassistant/components/vicare/sensor.py
@@ -25,7 +25,6 @@ from homeassistant.components.sensor import (
SensorEntityDescription,
SensorStateClass,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
PERCENTAGE,
EntityCategory,
@@ -40,8 +39,6 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import (
- DEVICE_LIST,
- DOMAIN,
VICARE_CUBIC_METER,
VICARE_KW,
VICARE_KWH,
@@ -50,7 +47,7 @@ from .const import (
VICARE_WH,
)
from .entity import ViCareEntity
-from .types import ViCareDevice, ViCareRequiredKeysMixin
+from .types import ViCareConfigEntry, ViCareDevice, ViCareRequiredKeysMixin
from .utils import (
get_burners,
get_circuits,
@@ -968,16 +965,14 @@ def _build_entities(
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ config_entry: ViCareConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Create the ViCare sensor devices."""
- device_list = hass.data[DOMAIN][config_entry.entry_id][DEVICE_LIST]
-
async_add_entities(
await hass.async_add_executor_job(
_build_entities,
- device_list,
+ config_entry.runtime_data.devices,
),
# run update to have device_class set depending on unit_of_measurement
True,
diff --git a/homeassistant/components/vicare/strings.json b/homeassistant/components/vicare/strings.json
index 77e570da779..4934507e41c 100644
--- a/homeassistant/components/vicare/strings.json
+++ b/homeassistant/components/vicare/strings.json
@@ -9,6 +9,12 @@
"password": "[%key:common::config_flow::data::password%]",
"client_id": "Client ID",
"heating_type": "Heating type"
+ },
+ "data_description": {
+ "username": "The email address to login to your ViCare account.",
+ "password": "The password to login to your ViCare account.",
+ "client_id": "The ID of the API client created in the Viessmann developer portal.",
+ "heating_type": "Allows to overrule the device auto detection."
}
},
"reauth_confirm": {
@@ -16,6 +22,10 @@
"data": {
"password": "[%key:common::config_flow::data::password%]",
"client_id": "[%key:component::vicare::config::step::user::data::client_id%]"
+ },
+ "data_description": {
+ "password": "[%key:component::vicare::config::step::user::data_description::password%]",
+ "client_id": "[%key:component::vicare::config::step::user::data_description::client_id%]"
}
}
},
diff --git a/homeassistant/components/vicare/types.py b/homeassistant/components/vicare/types.py
index 98d1c0566ce..65ae2a53c3e 100644
--- a/homeassistant/components/vicare/types.py
+++ b/homeassistant/components/vicare/types.py
@@ -6,6 +6,7 @@ from dataclasses import dataclass
import enum
from typing import Any
+from PyViCare.PyViCare import PyViCare
from PyViCare.PyViCareDevice import Device as PyViCareDevice
from PyViCare.PyViCareDeviceConfig import PyViCareDeviceConfig
@@ -15,6 +16,7 @@ from homeassistant.components.climate import (
PRESET_HOME,
PRESET_SLEEP,
)
+from homeassistant.config_entries import ConfigEntry
class HeatingProgram(enum.StrEnum):
@@ -80,6 +82,17 @@ class ViCareDevice:
api: PyViCareDevice
+@dataclass(frozen=True)
+class ViCareData:
+ """ViCare data class."""
+
+ client: PyViCare
+ devices: list[ViCareDevice]
+
+
+type ViCareConfigEntry = ConfigEntry[ViCareData]
+
+
@dataclass(frozen=True)
class ViCareRequiredKeysMixin:
"""Mixin for required keys."""
diff --git a/homeassistant/components/vicare/utils.py b/homeassistant/components/vicare/utils.py
index 5156ea4a41e..120dad83113 100644
--- a/homeassistant/components/vicare/utils.py
+++ b/homeassistant/components/vicare/utils.py
@@ -1,7 +1,12 @@
"""ViCare helpers functions."""
-import logging
+from __future__ import annotations
+from collections.abc import Mapping
+import logging
+from typing import Any
+
+from PyViCare.PyViCare import PyViCare
from PyViCare.PyViCareDevice import Device as PyViCareDevice
from PyViCare.PyViCareDeviceConfig import PyViCareDeviceConfig
from PyViCare.PyViCareHeatingDevice import (
@@ -14,16 +19,41 @@ from PyViCare.PyViCareUtils import (
)
import requests
-from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import CONF_CLIENT_ID, CONF_PASSWORD, CONF_USERNAME
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.storage import STORAGE_DIR
-from .const import CONF_HEATING_TYPE, HEATING_TYPE_TO_CREATOR_METHOD, HeatingType
-from .types import ViCareRequiredKeysMixin
+from .const import (
+ CONF_HEATING_TYPE,
+ DEFAULT_CACHE_DURATION,
+ HEATING_TYPE_TO_CREATOR_METHOD,
+ VICARE_TOKEN_FILENAME,
+ HeatingType,
+)
+from .types import ViCareConfigEntry, ViCareRequiredKeysMixin
_LOGGER = logging.getLogger(__name__)
+def login(
+ hass: HomeAssistant,
+ entry_data: Mapping[str, Any],
+ cache_duration=DEFAULT_CACHE_DURATION,
+) -> PyViCare:
+ """Login via PyVicare API."""
+ vicare_api = PyViCare()
+ vicare_api.setCacheDuration(cache_duration)
+ vicare_api.initWithCredentials(
+ entry_data[CONF_USERNAME],
+ entry_data[CONF_PASSWORD],
+ entry_data[CONF_CLIENT_ID],
+ hass.config.path(STORAGE_DIR, VICARE_TOKEN_FILENAME),
+ )
+ return vicare_api
+
+
def get_device(
- entry: ConfigEntry, device_config: PyViCareDeviceConfig
+ entry: ViCareConfigEntry, device_config: PyViCareDeviceConfig
) -> PyViCareDevice:
"""Get device for device config."""
return getattr(
diff --git a/homeassistant/components/vicare/water_heater.py b/homeassistant/components/vicare/water_heater.py
index 5e241c9a3be..114ff620c3f 100644
--- a/homeassistant/components/vicare/water_heater.py
+++ b/homeassistant/components/vicare/water_heater.py
@@ -20,14 +20,12 @@ from homeassistant.components.water_heater import (
WaterHeaterEntity,
WaterHeaterEntityFeature,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_TEMPERATURE, PRECISION_TENTHS, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from .const import DEVICE_LIST, DOMAIN
from .entity import ViCareEntity
-from .types import ViCareDevice
+from .types import ViCareConfigEntry, ViCareDevice
from .utils import get_circuits, get_device_serial
_LOGGER = logging.getLogger(__name__)
@@ -81,16 +79,14 @@ def _build_entities(
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ config_entry: ViCareConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the ViCare water heater platform."""
- device_list = hass.data[DOMAIN][config_entry.entry_id][DEVICE_LIST]
-
async_add_entities(
await hass.async_add_executor_job(
_build_entities,
- device_list,
+ config_entry.runtime_data.devices,
)
)
diff --git a/homeassistant/components/vilfo/config_flow.py b/homeassistant/components/vilfo/config_flow.py
index a6cff506f79..cdba7f1b8c2 100644
--- a/homeassistant/components/vilfo/config_flow.py
+++ b/homeassistant/components/vilfo/config_flow.py
@@ -109,7 +109,7 @@ class DomainConfigFlow(ConfigFlow, domain=DOMAIN):
try:
info = await validate_input(self.hass, user_input)
except InvalidHost:
- errors[CONF_HOST] = "wrong_host"
+ errors["base"] = "invalid_host"
except CannotConnect:
errors["base"] = "cannot_connect"
except InvalidAuth:
diff --git a/homeassistant/components/vilfo/strings.json b/homeassistant/components/vilfo/strings.json
index f2c4c38780b..55c996d4a3d 100644
--- a/homeassistant/components/vilfo/strings.json
+++ b/homeassistant/components/vilfo/strings.json
@@ -14,6 +14,7 @@
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
+ "invalid_host": "[%key:common::config_flow::error::invalid_host%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"abort": {
diff --git a/homeassistant/components/vivotek/manifest.json b/homeassistant/components/vivotek/manifest.json
index 5a33ca09908..f0b622afcad 100644
--- a/homeassistant/components/vivotek/manifest.json
+++ b/homeassistant/components/vivotek/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/vivotek",
"iot_class": "local_polling",
"loggers": ["libpyvivotek"],
+ "quality_scale": "legacy",
"requirements": ["libpyvivotek==0.4.0"]
}
diff --git a/homeassistant/components/vizio/__init__.py b/homeassistant/components/vizio/__init__.py
index 09d6f3be090..4af42d76b62 100644
--- a/homeassistant/components/vizio/__init__.py
+++ b/homeassistant/components/vizio/__init__.py
@@ -4,55 +4,18 @@ from __future__ import annotations
from typing import Any
-import voluptuous as vol
-
from homeassistant.components.media_player import MediaPlayerDeviceClass
-from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry, ConfigEntryState
-from homeassistant.const import Platform
+from homeassistant.config_entries import ConfigEntry, ConfigEntryState
+from homeassistant.const import CONF_DEVICE_CLASS, Platform
from homeassistant.core import HomeAssistant
-from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.storage import Store
-from homeassistant.helpers.typing import ConfigType
-from .const import CONF_APPS, CONF_DEVICE_CLASS, DOMAIN, VIZIO_SCHEMA
+from .const import CONF_APPS, DOMAIN
from .coordinator import VizioAppsDataUpdateCoordinator
-
-def validate_apps(config: ConfigType) -> ConfigType:
- """Validate CONF_APPS is only used when CONF_DEVICE_CLASS is MediaPlayerDeviceClass.TV."""
- if (
- config.get(CONF_APPS) is not None
- and config[CONF_DEVICE_CLASS] != MediaPlayerDeviceClass.TV
- ):
- raise vol.Invalid(
- f"'{CONF_APPS}' can only be used if {CONF_DEVICE_CLASS}' is"
- f" '{MediaPlayerDeviceClass.TV}'"
- )
-
- return config
-
-
-CONFIG_SCHEMA = vol.Schema(
- {DOMAIN: vol.All(cv.ensure_list, [vol.All(VIZIO_SCHEMA, validate_apps)])},
- extra=vol.ALLOW_EXTRA,
-)
-
PLATFORMS = [Platform.MEDIA_PLAYER]
-async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
- """Component setup, run import config flow for each entry in config."""
- if DOMAIN in config:
- for entry in config[DOMAIN]:
- hass.async_create_task(
- hass.config_entries.flow.async_init(
- DOMAIN, context={"source": SOURCE_IMPORT}, data=entry
- )
- )
-
- return True
-
-
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Load the saved entities."""
diff --git a/homeassistant/components/vizio/config_flow.py b/homeassistant/components/vizio/config_flow.py
index 49f6a709565..d3921061d8e 100644
--- a/homeassistant/components/vizio/config_flow.py
+++ b/homeassistant/components/vizio/config_flow.py
@@ -14,8 +14,6 @@ import voluptuous as vol
from homeassistant.components import zeroconf
from homeassistant.components.media_player import MediaPlayerDeviceClass
from homeassistant.config_entries import (
- SOURCE_IGNORE,
- SOURCE_IMPORT,
SOURCE_ZEROCONF,
ConfigEntry,
ConfigFlow,
@@ -251,98 +249,13 @@ class VizioConfigFlow(ConfigFlow, domain=DOMAIN):
if not errors:
return await self._create_entry(user_input)
- elif self._must_show_form and self.context["source"] == SOURCE_IMPORT:
- # Import should always display the config form if CONF_ACCESS_TOKEN
- # wasn't included but is needed so that the user can choose to update
- # their configuration.yaml or to proceed with config flow pairing. We
- # will also provide contextual message to user explaining why
- _LOGGER.warning(
- (
- "Couldn't complete configuration.yaml import: '%s' key is "
- "missing. Either provide '%s' key in configuration.yaml or "
- "finish setup by completing configuration via frontend"
- ),
- CONF_ACCESS_TOKEN,
- CONF_ACCESS_TOKEN,
- )
- self._must_show_form = False
else:
self._data = copy.deepcopy(user_input)
return await self.async_step_pair_tv()
schema = self._user_schema or _get_config_schema()
-
- if errors and self.context["source"] == SOURCE_IMPORT:
- # Log an error message if import config flow fails since otherwise failure is silent
- _LOGGER.error(
- "Importing from configuration.yaml failed: %s",
- ", ".join(errors.values()),
- )
-
return self.async_show_form(step_id="user", data_schema=schema, errors=errors)
- async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult:
- """Import a config entry from configuration.yaml."""
- # Check if new config entry matches any existing config entries
- for entry in self._async_current_entries():
- # If source is ignore bypass host check and continue through loop
- if entry.source == SOURCE_IGNORE:
- continue
-
- if await self.hass.async_add_executor_job(
- _host_is_same, entry.data[CONF_HOST], import_data[CONF_HOST]
- ):
- updated_options: dict[str, Any] = {}
- updated_data: dict[str, Any] = {}
- remove_apps = False
-
- if entry.data[CONF_HOST] != import_data[CONF_HOST]:
- updated_data[CONF_HOST] = import_data[CONF_HOST]
-
- if entry.data[CONF_NAME] != import_data[CONF_NAME]:
- updated_data[CONF_NAME] = import_data[CONF_NAME]
-
- # Update entry.data[CONF_APPS] if import_config[CONF_APPS] differs, and
- # pop entry.data[CONF_APPS] if import_config[CONF_APPS] is not specified
- if entry.data.get(CONF_APPS) != import_data.get(CONF_APPS):
- if not import_data.get(CONF_APPS):
- remove_apps = True
- else:
- updated_options[CONF_APPS] = import_data[CONF_APPS]
-
- if entry.data.get(CONF_VOLUME_STEP) != import_data[CONF_VOLUME_STEP]:
- updated_options[CONF_VOLUME_STEP] = import_data[CONF_VOLUME_STEP]
-
- if updated_options or updated_data or remove_apps:
- new_data = entry.data.copy()
- new_options = entry.options.copy()
-
- if remove_apps:
- new_data.pop(CONF_APPS)
- new_options.pop(CONF_APPS)
-
- if updated_data:
- new_data.update(updated_data)
-
- # options are stored in entry options and data so update both
- if updated_options:
- new_data.update(updated_options)
- new_options.update(updated_options)
-
- self.hass.config_entries.async_update_entry(
- entry=entry, data=new_data, options=new_options
- )
- return self.async_abort(reason="updated_entry")
-
- return self.async_abort(reason="already_configured_device")
-
- self._must_show_form = True
- # Store config key/value pairs that are not configurable in user step so they
- # don't get lost on user step
- if import_data.get(CONF_APPS):
- self._apps = copy.deepcopy(import_data[CONF_APPS])
- return await self.async_step_user(user_input=import_data)
-
async def async_step_zeroconf(
self, discovery_info: zeroconf.ZeroconfServiceInfo
) -> ConfigFlowResult:
@@ -433,11 +346,6 @@ class VizioConfigFlow(ConfigFlow, domain=DOMAIN):
if pair_data:
self._data[CONF_ACCESS_TOKEN] = pair_data.auth_token
self._must_show_form = True
-
- if self.context["source"] == SOURCE_IMPORT:
- # If user is pairing via config import, show different message
- return await self.async_step_pairing_complete_import()
-
return await self.async_step_pairing_complete()
# If no data was retrieved, it's assumed that the pairing attempt was not
diff --git a/homeassistant/components/vizio/const.py b/homeassistant/components/vizio/const.py
index 4eb96256d2e..8451ae747de 100644
--- a/homeassistant/components/vizio/const.py
+++ b/homeassistant/components/vizio/const.py
@@ -10,14 +10,6 @@ from homeassistant.components.media_player import (
MediaPlayerDeviceClass,
MediaPlayerEntityFeature,
)
-from homeassistant.const import (
- CONF_ACCESS_TOKEN,
- CONF_DEVICE_CLASS,
- CONF_EXCLUDE,
- CONF_HOST,
- CONF_INCLUDE,
- CONF_NAME,
-)
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.typing import VolDictType
@@ -84,43 +76,3 @@ VIZIO_DEVICE_CLASSES = {
MediaPlayerDeviceClass.SPEAKER: VIZIO_DEVICE_CLASS_SPEAKER,
MediaPlayerDeviceClass.TV: VIZIO_DEVICE_CLASS_TV,
}
-
-VIZIO_SCHEMA = {
- vol.Required(CONF_HOST): cv.string,
- vol.Optional(CONF_ACCESS_TOKEN): cv.string,
- vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
- vol.Optional(CONF_DEVICE_CLASS, default=DEFAULT_DEVICE_CLASS): vol.All(
- cv.string,
- vol.Lower,
- vol.In([MediaPlayerDeviceClass.TV, MediaPlayerDeviceClass.SPEAKER]),
- ),
- vol.Optional(CONF_VOLUME_STEP, default=DEFAULT_VOLUME_STEP): vol.All(
- vol.Coerce(int), vol.Range(min=1, max=10)
- ),
- vol.Optional(CONF_APPS): vol.All(
- {
- vol.Exclusive(CONF_INCLUDE, "apps_filter"): vol.All(
- cv.ensure_list, [cv.string]
- ),
- vol.Exclusive(CONF_EXCLUDE, "apps_filter"): vol.All(
- cv.ensure_list, [cv.string]
- ),
- vol.Optional(CONF_ADDITIONAL_CONFIGS): vol.All(
- cv.ensure_list,
- [
- {
- vol.Required(CONF_NAME): cv.string,
- vol.Required(CONF_CONFIG): {
- vol.Required(CONF_APP_ID): cv.string,
- vol.Required(CONF_NAME_SPACE): vol.Coerce(int),
- vol.Optional(CONF_MESSAGE, default=None): vol.Or(
- cv.string, None
- ),
- },
- },
- ],
- ),
- },
- cv.has_at_least_one_key(CONF_INCLUDE, CONF_EXCLUDE, CONF_ADDITIONAL_CONFIGS),
- ),
-}
diff --git a/homeassistant/components/vizio/manifest.json b/homeassistant/components/vizio/manifest.json
index e6812ed58b1..91b2ff46495 100644
--- a/homeassistant/components/vizio/manifest.json
+++ b/homeassistant/components/vizio/manifest.json
@@ -7,7 +7,6 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["pyvizio"],
- "quality_scale": "platinum",
"requirements": ["pyvizio==0.1.61"],
"zeroconf": ["_viziocast._tcp.local."]
}
diff --git a/homeassistant/components/vlc/manifest.json b/homeassistant/components/vlc/manifest.json
index 7e4fb7b2a4f..a31fe49859c 100644
--- a/homeassistant/components/vlc/manifest.json
+++ b/homeassistant/components/vlc/manifest.json
@@ -4,5 +4,6 @@
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/vlc",
"iot_class": "local_polling",
+ "quality_scale": "legacy",
"requirements": ["python-vlc==3.0.18122"]
}
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 d2f408e355b..de794488040 100644
--- a/homeassistant/components/vodafone_station/coordinator.py
+++ b/homeassistant/components/vodafone_station/coordinator.py
@@ -2,6 +2,7 @@
from dataclasses import dataclass
from datetime import datetime, timedelta
+from json.decoder import JSONDecodeError
from typing import Any
from aiovodafone import VodafoneStationDevice, VodafoneStationSercommApi, exceptions
@@ -13,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()
@@ -58,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(
@@ -107,6 +108,7 @@ class VodafoneStationRouter(DataUpdateCoordinator[UpdateCoordinatorDataType]):
exceptions.CannotConnect,
exceptions.AlreadyLogged,
exceptions.GenericLoginError,
+ JSONDecodeError,
) as err:
raise UpdateFailed(f"Error fetching data: {err!r}") from err
except (ConfigEntryAuthFailed, UpdateFailed):
diff --git a/homeassistant/components/vodafone_station/manifest.json b/homeassistant/components/vodafone_station/manifest.json
index 29cb3c070ab..4acafc8df3a 100644
--- a/homeassistant/components/vodafone_station/manifest.json
+++ b/homeassistant/components/vodafone_station/manifest.json
@@ -7,6 +7,5 @@
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["aiovodafone"],
- "quality_scale": "silver",
"requirements": ["aiovodafone==0.6.1"]
}
diff --git a/homeassistant/components/vodafone_station/sensor.py b/homeassistant/components/vodafone_station/sensor.py
index 136aa94b43a..307fcaf0ea8 100644
--- a/homeassistant/components/vodafone_station/sensor.py
+++ b/homeassistant/components/vodafone_station/sensor.py
@@ -22,7 +22,7 @@ from .const import _LOGGER, DOMAIN, LINE_TYPES
from .coordinator import VodafoneStationRouter
NOT_AVAILABLE: list = ["", "N/A", "0.0.0.0"]
-UPTIME_DEVIATION = 45
+UPTIME_DEVIATION = 60
@dataclass(frozen=True, kw_only=True)
@@ -43,12 +43,10 @@ def _calculate_uptime(
) -> datetime:
"""Calculate device uptime."""
- assert isinstance(last_value, datetime)
-
delta_uptime = coordinator.api.convert_uptime(coordinator.data.sensors[key])
if (
- not last_value
+ not isinstance(last_value, datetime)
or abs((delta_uptime - last_value).total_seconds()) > UPTIME_DEVIATION
):
return delta_uptime
diff --git a/homeassistant/components/voicerss/manifest.json b/homeassistant/components/voicerss/manifest.json
index bfc61365dc0..1e7da9d220d 100644
--- a/homeassistant/components/voicerss/manifest.json
+++ b/homeassistant/components/voicerss/manifest.json
@@ -3,5 +3,6 @@
"name": "VoiceRSS",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/voicerss",
- "iot_class": "cloud_push"
+ "iot_class": "cloud_push",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/voip/manifest.json b/homeassistant/components/voip/manifest.json
index 964193fca53..ed7f11f8fbc 100644
--- a/homeassistant/components/voip/manifest.json
+++ b/homeassistant/components/voip/manifest.json
@@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/voip",
"iot_class": "local_push",
"quality_scale": "internal",
- "requirements": ["voip-utils==0.1.0"]
+ "requirements": ["voip-utils==0.2.2"]
}
diff --git a/homeassistant/components/volkszaehler/manifest.json b/homeassistant/components/volkszaehler/manifest.json
index e9070d0fa87..1427f330e77 100644
--- a/homeassistant/components/volkszaehler/manifest.json
+++ b/homeassistant/components/volkszaehler/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/volkszaehler",
"iot_class": "local_polling",
"loggers": ["volkszaehler"],
+ "quality_scale": "legacy",
"requirements": ["volkszaehler==0.4.0"]
}
diff --git a/homeassistant/components/vulcan/manifest.json b/homeassistant/components/vulcan/manifest.json
index 47ab7ec53cb..554a82e9c2c 100644
--- a/homeassistant/components/vulcan/manifest.json
+++ b/homeassistant/components/vulcan/manifest.json
@@ -5,6 +5,5 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/vulcan",
"iot_class": "cloud_polling",
- "quality_scale": "silver",
"requirements": ["vulcan-api==2.3.2"]
}
diff --git a/homeassistant/components/vulcan/strings.json b/homeassistant/components/vulcan/strings.json
index 814621b5403..61b5a954389 100644
--- a/homeassistant/components/vulcan/strings.json
+++ b/homeassistant/components/vulcan/strings.json
@@ -10,7 +10,7 @@
"unknown": "[%key:common::config_flow::error::unknown%]",
"invalid_token": "[%key:common::config_flow::error::invalid_access_token%]",
"expired_token": "Expired token - please generate a new token",
- "invalid_pin": "Invalid pin",
+ "invalid_pin": "Invalid PIN",
"invalid_symbol": "Invalid symbol",
"expired_credentials": "Expired credentials - please create new on Vulcan mobile app registration page",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
diff --git a/homeassistant/components/vultr/manifest.json b/homeassistant/components/vultr/manifest.json
index dc3cd3571eb..713485e7931 100644
--- a/homeassistant/components/vultr/manifest.json
+++ b/homeassistant/components/vultr/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/vultr",
"iot_class": "cloud_polling",
"loggers": ["vultr"],
+ "quality_scale": "legacy",
"requirements": ["vultr==0.1.2"]
}
diff --git a/homeassistant/components/w800rf32/manifest.json b/homeassistant/components/w800rf32/manifest.json
index 769eb96b3c0..4d5074e72c2 100644
--- a/homeassistant/components/w800rf32/manifest.json
+++ b/homeassistant/components/w800rf32/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/w800rf32",
"iot_class": "local_push",
"loggers": ["W800rf32"],
+ "quality_scale": "legacy",
"requirements": ["pyW800rf32==0.4"]
}
diff --git a/homeassistant/components/wake_on_lan/services.yaml b/homeassistant/components/wake_on_lan/services.yaml
index 48d3df5c4f9..e7c048daf64 100644
--- a/homeassistant/components/wake_on_lan/services.yaml
+++ b/homeassistant/components/wake_on_lan/services.yaml
@@ -15,3 +15,4 @@ send_magic_packet:
number:
min: 1
max: 65535
+ mode: "box"
diff --git a/homeassistant/components/water_heater/__init__.py b/homeassistant/components/water_heater/__init__.py
index 4bfe1ce4481..60be340a253 100644
--- a/homeassistant/components/water_heater/__init__.py
+++ b/homeassistant/components/water_heater/__init__.py
@@ -25,12 +25,7 @@ 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 (
- DeprecatedConstantEnum,
- all_with_deprecated_constants,
- check_if_deprecated_constant,
- dir_with_deprecated_constants,
-)
+from homeassistant.helpers.deprecation import deprecated_class
from homeassistant.helpers.entity import Entity, EntityDescription
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.temperature import display_temp as show_temp
@@ -62,7 +57,7 @@ STATE_GAS = "gas"
class WaterHeaterEntityFeature(IntFlag):
- """Supported features of the fan entity."""
+ """Supported features of the water heater entity."""
TARGET_TEMPERATURE = 1
OPERATION_MODE = 2
@@ -70,18 +65,6 @@ class WaterHeaterEntityFeature(IntFlag):
ON_OFF = 8
-# These SUPPORT_* constants are deprecated as of Home Assistant 2022.5.
-# Please use the WaterHeaterEntityFeature enum instead.
-_DEPRECATED_SUPPORT_TARGET_TEMPERATURE = DeprecatedConstantEnum(
- WaterHeaterEntityFeature.TARGET_TEMPERATURE, "2025.1"
-)
-_DEPRECATED_SUPPORT_OPERATION_MODE = DeprecatedConstantEnum(
- WaterHeaterEntityFeature.OPERATION_MODE, "2025.1"
-)
-_DEPRECATED_SUPPORT_AWAY_MODE = DeprecatedConstantEnum(
- WaterHeaterEntityFeature.AWAY_MODE, "2025.1"
-)
-
ATTR_MAX_TEMP = "max_temp"
ATTR_MIN_TEMP = "min_temp"
ATTR_AWAY_MODE = "away_mode"
@@ -147,10 +130,17 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
return await hass.data[DATA_COMPONENT].async_unload_entry(entry)
-class WaterHeaterEntityEntityDescription(EntityDescription, frozen_or_thawed=True):
+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."""
+
+
CACHED_PROPERTIES_WITH_ATTR_ = {
"temperature_unit",
"current_operation",
@@ -170,7 +160,7 @@ class WaterHeaterEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
{ATTR_OPERATION_LIST, ATTR_MIN_TEMP, ATTR_MAX_TEMP}
)
- entity_description: WaterHeaterEntityEntityDescription
+ entity_description: WaterHeaterEntityDescription
_attr_current_operation: str | None = None
_attr_current_temperature: float | None = None
_attr_is_away_mode_on: bool | None = None
@@ -212,7 +202,7 @@ class WaterHeaterEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
),
}
- if WaterHeaterEntityFeature.OPERATION_MODE in self.supported_features_compat:
+ if WaterHeaterEntityFeature.OPERATION_MODE in self.supported_features:
data[ATTR_OPERATION_LIST] = self.operation_list
return data
@@ -248,7 +238,7 @@ class WaterHeaterEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
),
}
- supported_features = self.supported_features_compat
+ supported_features = self.supported_features
if WaterHeaterEntityFeature.OPERATION_MODE in supported_features:
data[ATTR_OPERATION_MODE] = self.current_operation
@@ -397,19 +387,6 @@ class WaterHeaterEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
"""Return the list of supported features."""
return self._attr_supported_features
- @property
- def supported_features_compat(self) -> WaterHeaterEntityFeature:
- """Return the supported features as WaterHeaterEntityFeature.
-
- Remove this compatibility shim in 2025.1 or later.
- """
- features = self.supported_features
- if type(features) is int: # noqa: E721
- new_features = WaterHeaterEntityFeature(features)
- self._report_deprecated_supported_features_values(new_features)
- return new_features
- return features
-
async def async_service_away_mode(
entity: WaterHeaterEntity, service: ServiceCall
@@ -437,11 +414,3 @@ 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/strings.json b/homeassistant/components/water_heater/strings.json
index 741b277d84d..07e132a0b5b 100644
--- a/homeassistant/components/water_heater/strings.json
+++ b/homeassistant/components/water_heater/strings.json
@@ -1,4 +1,5 @@
{
+ "title": "Water heater",
"device_automation": {
"action_type": {
"turn_on": "[%key:common::device_automation::action_type::turn_on%]",
@@ -7,7 +8,7 @@
},
"entity_component": {
"_": {
- "name": "Water heater",
+ "name": "[%key:component::water_heater::title%]",
"state": {
"off": "[%key:common::state::off%]",
"eco": "Eco",
diff --git a/homeassistant/components/waterfurnace/manifest.json b/homeassistant/components/waterfurnace/manifest.json
index 9e01f7e6a05..2bf72acb047 100644
--- a/homeassistant/components/waterfurnace/manifest.json
+++ b/homeassistant/components/waterfurnace/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/waterfurnace",
"iot_class": "cloud_polling",
"loggers": ["waterfurnace"],
+ "quality_scale": "legacy",
"requirements": ["waterfurnace==1.1.0"]
}
diff --git a/homeassistant/components/watergate/__init__.py b/homeassistant/components/watergate/__init__.py
new file mode 100644
index 00000000000..fa761110339
--- /dev/null
+++ b/homeassistant/components/watergate/__init__.py
@@ -0,0 +1,131 @@
+"""The Watergate integration."""
+
+from __future__ import annotations
+
+from collections.abc import Awaitable, Callable
+from http import HTTPStatus
+import logging
+
+from watergate_local_api import WatergateLocalApiClient
+from watergate_local_api.models import WebhookEvent
+
+from homeassistant.components.http import HomeAssistantView
+from homeassistant.components.webhook import (
+ Request,
+ Response,
+ async_generate_url,
+ async_register,
+)
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import CONF_IP_ADDRESS, CONF_WEBHOOK_ID, Platform
+from homeassistant.core import HomeAssistant
+
+from .const import DOMAIN
+from .coordinator import WatergateDataCoordinator
+
+_LOGGER = logging.getLogger(__name__)
+
+WEBHOOK_TELEMETRY_TYPE = "telemetry"
+WEBHOOK_VALVE_TYPE = "valve"
+WEBHOOK_WIFI_CHANGED_TYPE = "wifi-changed"
+WEBHOOK_POWER_SUPPLY_CHANGED_TYPE = "power-supply-changed"
+
+PLATFORMS: list[Platform] = [
+ Platform.SENSOR,
+ Platform.VALVE,
+]
+
+type WatergateConfigEntry = ConfigEntry[WatergateDataCoordinator]
+
+
+async def async_setup_entry(hass: HomeAssistant, entry: WatergateConfigEntry) -> bool:
+ """Set up Watergate from a config entry."""
+ sonic_address = entry.data[CONF_IP_ADDRESS]
+ webhook_id = entry.data[CONF_WEBHOOK_ID]
+
+ _LOGGER.debug(
+ "Setting up watergate local api integration for device: IP: %s)",
+ sonic_address,
+ )
+
+ watergate_client = WatergateLocalApiClient(
+ sonic_address if sonic_address.startswith("http") else f"http://{sonic_address}"
+ )
+
+ coordinator = WatergateDataCoordinator(hass, watergate_client)
+ entry.runtime_data = coordinator
+
+ async_register(
+ hass, DOMAIN, "Watergate", webhook_id, get_webhook_handler(coordinator)
+ )
+
+ _LOGGER.debug("Registered webhook: %s", webhook_id)
+
+ await coordinator.async_config_entry_first_refresh()
+
+ await watergate_client.async_set_webhook_url(
+ async_generate_url(hass, webhook_id, allow_ip=True, prefer_external=False)
+ )
+
+ await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
+
+ return True
+
+
+async def async_unload_entry(hass: HomeAssistant, entry: WatergateConfigEntry) -> bool:
+ """Unload a config entry."""
+ webhook_id = entry.data[CONF_WEBHOOK_ID]
+ hass.components.webhook.async_unregister(webhook_id)
+ return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
+
+
+def get_webhook_handler(
+ coordinator: WatergateDataCoordinator,
+) -> Callable[[HomeAssistant, str, Request], Awaitable[Response | None]]:
+ """Return webhook handler."""
+
+ async def async_webhook_handler(
+ hass: HomeAssistant, webhook_id: str, request: Request
+ ) -> Response | None:
+ if not request.body_exists:
+ return HomeAssistantView.json(
+ result="No Body", status_code=HTTPStatus.BAD_REQUEST
+ )
+
+ body = await request.json()
+
+ _LOGGER.debug("Received webhook: %s", body)
+
+ data = WebhookEvent.parse_webhook_event(body)
+
+ body_type = body.get("type")
+
+ if not (coordinator_data := coordinator.data):
+ pass
+ elif body_type == WEBHOOK_VALVE_TYPE:
+ coordinator_data.state.valve_state = data.state
+ elif body_type == WEBHOOK_TELEMETRY_TYPE:
+ errors = data.errors or {}
+ coordinator_data.telemetry.flow = (
+ data.flow if "flow" not in errors else None
+ )
+ coordinator_data.telemetry.pressure = (
+ data.pressure if "pressure" not in errors else None
+ )
+ coordinator_data.telemetry.water_temperature = (
+ data.temperature if "temperature" not in errors else None
+ )
+ elif body_type == WEBHOOK_WIFI_CHANGED_TYPE:
+ coordinator_data.networking.ip = data.ip
+ coordinator_data.networking.gateway = data.gateway
+ coordinator_data.networking.subnet = data.subnet
+ coordinator_data.networking.ssid = data.ssid
+ coordinator_data.networking.rssi = data.rssi
+ elif body_type == WEBHOOK_POWER_SUPPLY_CHANGED_TYPE:
+ coordinator_data.state.power_supply = data.supply
+
+ coordinator.async_set_updated_data(coordinator_data)
+
+ return HomeAssistantView.json(result="OK", status_code=HTTPStatus.OK)
+
+ return async_webhook_handler
diff --git a/homeassistant/components/watergate/config_flow.py b/homeassistant/components/watergate/config_flow.py
new file mode 100644
index 00000000000..de8494053a3
--- /dev/null
+++ b/homeassistant/components/watergate/config_flow.py
@@ -0,0 +1,62 @@
+"""Config flow for Watergate."""
+
+import logging
+
+import voluptuous as vol
+from watergate_local_api.watergate_api import (
+ WatergateApiException,
+ WatergateLocalApiClient,
+)
+
+from homeassistant.components.webhook import async_generate_id as webhook_generate_id
+from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
+from homeassistant.const import CONF_IP_ADDRESS, CONF_WEBHOOK_ID
+
+from .const import DOMAIN
+
+_LOGGER = logging.getLogger(__name__)
+
+SONIC = "Sonic"
+WATERGATE_SCHEMA = vol.Schema(
+ {
+ vol.Required(CONF_IP_ADDRESS): str,
+ }
+)
+
+
+class WatergateConfigFlow(ConfigFlow, domain=DOMAIN):
+ """Watergate config flow."""
+
+ async def async_step_user(
+ self, user_input: dict[str, str] | None = None
+ ) -> ConfigFlowResult:
+ """Handle a flow initiated by the user."""
+ errors: dict[str, str] = {}
+ if user_input is not None:
+ watergate_client = WatergateLocalApiClient(
+ self.prepare_ip_address(user_input[CONF_IP_ADDRESS])
+ )
+ try:
+ state = await watergate_client.async_get_device_state()
+ except WatergateApiException as exception:
+ _LOGGER.error("Error connecting to Watergate device: %s", exception)
+ errors[CONF_IP_ADDRESS] = "cannot_connect"
+ else:
+ if state is None:
+ _LOGGER.error("Device state returned as None")
+ errors[CONF_IP_ADDRESS] = "cannot_connect"
+ else:
+ await self.async_set_unique_id(state.serial_number)
+ self._abort_if_unique_id_configured()
+ return self.async_create_entry(
+ data={**user_input, CONF_WEBHOOK_ID: webhook_generate_id()},
+ title=SONIC,
+ )
+
+ return self.async_show_form(
+ step_id="user", data_schema=WATERGATE_SCHEMA, errors=errors
+ )
+
+ def prepare_ip_address(self, ip_address: str) -> str:
+ """Prepare the IP address for the Watergate device."""
+ return ip_address if ip_address.startswith("http") else f"http://{ip_address}"
diff --git a/homeassistant/components/watergate/const.py b/homeassistant/components/watergate/const.py
new file mode 100644
index 00000000000..22a14330af9
--- /dev/null
+++ b/homeassistant/components/watergate/const.py
@@ -0,0 +1,5 @@
+"""Constants for the Watergate integration."""
+
+DOMAIN = "watergate"
+
+MANUFACTURER = "Watergate"
diff --git a/homeassistant/components/watergate/coordinator.py b/homeassistant/components/watergate/coordinator.py
new file mode 100644
index 00000000000..1d83b7a3ccb
--- /dev/null
+++ b/homeassistant/components/watergate/coordinator.py
@@ -0,0 +1,58 @@
+"""Coordinator for Watergate API."""
+
+from dataclasses import dataclass
+from datetime import timedelta
+import logging
+
+from watergate_local_api import WatergateApiException, WatergateLocalApiClient
+from watergate_local_api.models import DeviceState, NetworkingData, TelemetryData
+
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
+
+from .const import DOMAIN
+
+_LOGGER = logging.getLogger(__name__)
+
+
+@dataclass
+class WatergateAgregatedRequests:
+ """Class to hold aggregated requests."""
+
+ state: DeviceState
+ telemetry: TelemetryData
+ networking: NetworkingData
+
+
+class WatergateDataCoordinator(DataUpdateCoordinator[WatergateAgregatedRequests]):
+ """Class to manage fetching watergate data."""
+
+ def __init__(self, hass: HomeAssistant, api: WatergateLocalApiClient) -> None:
+ """Initialize."""
+ super().__init__(
+ hass,
+ _LOGGER,
+ name=DOMAIN,
+ update_interval=timedelta(minutes=2),
+ )
+ self.api = api
+
+ async def _async_update_data(self) -> WatergateAgregatedRequests:
+ try:
+ state = await self.api.async_get_device_state()
+ telemetry = await self.api.async_get_telemetry_data()
+ networking = await self.api.async_get_networking()
+ except WatergateApiException as exc:
+ raise UpdateFailed(f"Sonic device is unavailable: {exc}") from exc
+ return WatergateAgregatedRequests(state, telemetry, networking)
+
+ def async_set_updated_data(self, data: WatergateAgregatedRequests) -> None:
+ """Manually update data, notify listeners and DO NOT reset refresh interval."""
+
+ self.data = data
+ self.logger.debug(
+ "Manually updated %s data",
+ self.name,
+ )
+
+ self.async_update_listeners()
diff --git a/homeassistant/components/watergate/entity.py b/homeassistant/components/watergate/entity.py
new file mode 100644
index 00000000000..8f43643029f
--- /dev/null
+++ b/homeassistant/components/watergate/entity.py
@@ -0,0 +1,32 @@
+"""Watergate Base Entity Definition."""
+
+from homeassistant.helpers.device_registry import DeviceInfo
+from homeassistant.helpers.update_coordinator import CoordinatorEntity
+
+from .const import DOMAIN, MANUFACTURER
+from .coordinator import WatergateDataCoordinator
+
+
+class WatergateEntity(CoordinatorEntity[WatergateDataCoordinator]):
+ """Define a base Watergate entity."""
+
+ _attr_has_entity_name = True
+
+ def __init__(
+ self,
+ coordinator: WatergateDataCoordinator,
+ entity_name: str,
+ ) -> None:
+ """Initialize the entity."""
+ super().__init__(coordinator)
+ self._api_client = coordinator.api
+ self._attr_unique_id = f"{coordinator.data.state.serial_number}.{entity_name}"
+ self._attr_device_info = DeviceInfo(
+ identifiers={(DOMAIN, coordinator.data.state.serial_number)},
+ name="Sonic",
+ serial_number=coordinator.data.state.serial_number,
+ manufacturer=MANUFACTURER,
+ sw_version=(
+ coordinator.data.state.firmware_version if coordinator.data else None
+ ),
+ )
diff --git a/homeassistant/components/watergate/manifest.json b/homeassistant/components/watergate/manifest.json
new file mode 100644
index 00000000000..46a80e15671
--- /dev/null
+++ b/homeassistant/components/watergate/manifest.json
@@ -0,0 +1,11 @@
+{
+ "domain": "watergate",
+ "name": "Watergate",
+ "codeowners": ["@adam-the-hero"],
+ "config_flow": true,
+ "dependencies": ["http", "webhook"],
+ "documentation": "https://www.home-assistant.io/integrations/watergate",
+ "iot_class": "local_push",
+ "quality_scale": "bronze",
+ "requirements": ["watergate-local-api==2024.4.1"]
+}
diff --git a/homeassistant/components/watergate/quality_scale.yaml b/homeassistant/components/watergate/quality_scale.yaml
new file mode 100644
index 00000000000..b116eff970e
--- /dev/null
+++ b/homeassistant/components/watergate/quality_scale.yaml
@@ -0,0 +1,44 @@
+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
+ log-when-unavailable: todo
+ entity-unavailable: done
+ action-exceptions: done
+ reauthentication-flow:
+ status: exempt
+ comment: |
+ This integration does not require authentication.
+ parallel-updates: done
+ test-coverage: done
+ integration-owner: done
+ docs-installation-parameters: todo
+ docs-configuration-parameters: todo
diff --git a/homeassistant/components/watergate/sensor.py b/homeassistant/components/watergate/sensor.py
new file mode 100644
index 00000000000..638bf297415
--- /dev/null
+++ b/homeassistant/components/watergate/sensor.py
@@ -0,0 +1,214 @@
+"""Support for Watergate sensors."""
+
+from collections.abc import Callable
+from dataclasses import dataclass
+from datetime import datetime, timedelta
+from enum import StrEnum
+import logging
+
+from homeassistant.components.sensor import (
+ HomeAssistant,
+ SensorDeviceClass,
+ SensorEntity,
+ SensorEntityDescription,
+ SensorStateClass,
+)
+from homeassistant.const import (
+ SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
+ EntityCategory,
+ UnitOfPressure,
+ UnitOfTemperature,
+ UnitOfTime,
+ UnitOfVolume,
+ UnitOfVolumeFlowRate,
+)
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.typing import StateType
+from homeassistant.util import dt as dt_util
+
+from . import WatergateConfigEntry
+from .coordinator import WatergateAgregatedRequests, WatergateDataCoordinator
+from .entity import WatergateEntity
+
+_LOGGER = logging.getLogger(__name__)
+
+PARALLEL_UPDATES = 0
+
+
+class PowerSupplyMode(StrEnum):
+ """LED bar mode."""
+
+ BATTERY = "battery"
+ EXTERNAL = "external"
+ BATTERY_EXTERNAL = "battery_external"
+
+
+@dataclass(kw_only=True, frozen=True)
+class WatergateSensorEntityDescription(SensorEntityDescription):
+ """Description for Watergate sensor entities."""
+
+ value_fn: Callable[
+ [WatergateAgregatedRequests],
+ StateType | datetime | PowerSupplyMode,
+ ]
+
+
+DESCRIPTIONS: list[WatergateSensorEntityDescription] = [
+ WatergateSensorEntityDescription(
+ value_fn=lambda data: (
+ data.state.water_meter.volume
+ if data.state and data.state.water_meter
+ else None
+ ),
+ translation_key="water_meter_volume",
+ key="water_meter_volume",
+ native_unit_of_measurement=UnitOfVolume.LITERS,
+ device_class=SensorDeviceClass.WATER,
+ state_class=SensorStateClass.TOTAL_INCREASING,
+ ),
+ WatergateSensorEntityDescription(
+ value_fn=lambda data: (
+ data.state.water_meter.duration
+ if data.state and data.state.water_meter
+ else None
+ ),
+ translation_key="water_meter_duration",
+ key="water_meter_duration",
+ native_unit_of_measurement=UnitOfTime.MINUTES,
+ device_class=SensorDeviceClass.DURATION,
+ state_class=SensorStateClass.TOTAL_INCREASING,
+ ),
+ WatergateSensorEntityDescription(
+ value_fn=lambda data: data.networking.rssi if data.networking else None,
+ key="rssi",
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
+ device_class=SensorDeviceClass.SIGNAL_STRENGTH,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ WatergateSensorEntityDescription(
+ value_fn=lambda data: (
+ dt_util.as_utc(
+ dt_util.now() - timedelta(microseconds=data.networking.wifi_uptime)
+ )
+ if data.networking
+ else None
+ ),
+ translation_key="wifi_up_since",
+ key="wifi_up_since",
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ device_class=SensorDeviceClass.TIMESTAMP,
+ ),
+ WatergateSensorEntityDescription(
+ value_fn=lambda data: (
+ dt_util.as_utc(
+ dt_util.now() - timedelta(microseconds=data.networking.mqtt_uptime)
+ )
+ if data.networking
+ else None
+ ),
+ translation_key="mqtt_up_since",
+ key="mqtt_up_since",
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ device_class=SensorDeviceClass.TIMESTAMP,
+ ),
+ WatergateSensorEntityDescription(
+ value_fn=lambda data: (
+ data.telemetry.water_temperature if data.telemetry else None
+ ),
+ translation_key="water_temperature",
+ key="water_temperature",
+ native_unit_of_measurement=UnitOfTemperature.CELSIUS,
+ device_class=SensorDeviceClass.TEMPERATURE,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ WatergateSensorEntityDescription(
+ value_fn=lambda data: data.telemetry.pressure if data.telemetry else None,
+ translation_key="water_pressure",
+ key="water_pressure",
+ native_unit_of_measurement=UnitOfPressure.MBAR,
+ device_class=SensorDeviceClass.PRESSURE,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ WatergateSensorEntityDescription(
+ value_fn=lambda data: (
+ data.telemetry.flow / 1000
+ if data.telemetry and data.telemetry.flow is not None
+ else None
+ ),
+ key="water_flow_rate",
+ native_unit_of_measurement=UnitOfVolumeFlowRate.LITERS_PER_MINUTE,
+ device_class=SensorDeviceClass.VOLUME_FLOW_RATE,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ WatergateSensorEntityDescription(
+ value_fn=lambda data: (
+ dt_util.as_utc(dt_util.now() - timedelta(seconds=data.state.uptime))
+ if data.state
+ else None
+ ),
+ translation_key="up_since",
+ key="up_since",
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ device_class=SensorDeviceClass.TIMESTAMP,
+ ),
+ WatergateSensorEntityDescription(
+ value_fn=lambda data: (
+ PowerSupplyMode(data.state.power_supply.replace("+", "_"))
+ if data.state
+ else None
+ ),
+ translation_key="power_supply_mode",
+ key="power_supply_mode",
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ device_class=SensorDeviceClass.ENUM,
+ options=[member.value for member in PowerSupplyMode],
+ ),
+]
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ config_entry: WatergateConfigEntry,
+ async_add_entities: AddEntitiesCallback,
+) -> None:
+ """Set up all entries for Watergate Platform."""
+
+ coordinator = config_entry.runtime_data
+
+ async_add_entities(
+ SonicSensor(coordinator, description) for description in DESCRIPTIONS
+ )
+
+
+class SonicSensor(WatergateEntity, SensorEntity):
+ """Define a Sonic Sensor entity."""
+
+ entity_description: WatergateSensorEntityDescription
+
+ def __init__(
+ self,
+ coordinator: WatergateDataCoordinator,
+ entity_description: WatergateSensorEntityDescription,
+ ) -> None:
+ """Initialize the sensor."""
+ super().__init__(coordinator, entity_description.key)
+ self.entity_description = entity_description
+
+ @property
+ def available(self) -> bool:
+ """Return True if entity is available."""
+ return (
+ super().available
+ and self.entity_description.value_fn(self.coordinator.data) is not None
+ )
+
+ @property
+ def native_value(self) -> str | int | float | datetime | PowerSupplyMode | None:
+ """Return the state of the sensor."""
+ return self.entity_description.value_fn(self.coordinator.data)
diff --git a/homeassistant/components/watergate/strings.json b/homeassistant/components/watergate/strings.json
new file mode 100644
index 00000000000..c312525e420
--- /dev/null
+++ b/homeassistant/components/watergate/strings.json
@@ -0,0 +1,54 @@
+{
+ "config": {
+ "step": {
+ "user": {
+ "data": {
+ "ip_address": "[%key:common::config_flow::data::ip%]"
+ },
+ "title": "Configure Watergate device",
+ "data_description": {
+ "ip_address": "Provide an IP address of your Watergate device."
+ }
+ }
+ },
+ "error": {
+ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
+ },
+ "abort": {
+ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
+ }
+ },
+ "entity": {
+ "sensor": {
+ "water_meter_volume": {
+ "name": "Water meter volume"
+ },
+ "water_meter_duration": {
+ "name": "Water meter duration"
+ },
+ "wifi_up_since": {
+ "name": "Wi-Fi up since"
+ },
+ "mqtt_up_since": {
+ "name": "MQTT up since"
+ },
+ "water_temperature": {
+ "name": "Water temperature"
+ },
+ "water_pressure": {
+ "name": "Water pressure"
+ },
+ "up_since": {
+ "name": "Up since"
+ },
+ "power_supply_mode": {
+ "name": "Power supply mode",
+ "state": {
+ "battery": "Battery",
+ "external": "Mains",
+ "battery_external": "Battery and mains"
+ }
+ }
+ }
+ }
+}
diff --git a/homeassistant/components/watergate/valve.py b/homeassistant/components/watergate/valve.py
new file mode 100644
index 00000000000..556b53e1d3c
--- /dev/null
+++ b/homeassistant/components/watergate/valve.py
@@ -0,0 +1,91 @@
+"""Support for Watergate Valve."""
+
+from homeassistant.components.sensor import Any, HomeAssistant
+from homeassistant.components.valve import (
+ ValveDeviceClass,
+ ValveEntity,
+ ValveEntityFeature,
+ ValveState,
+)
+from homeassistant.core import callback
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+
+from . import WatergateConfigEntry
+from .coordinator import WatergateDataCoordinator
+from .entity import WatergateEntity
+
+ENTITY_NAME = "valve"
+PARALLEL_UPDATES = 0
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ config_entry: WatergateConfigEntry,
+ async_add_entities: AddEntitiesCallback,
+) -> None:
+ """Set up all entries for Watergate Platform."""
+
+ async_add_entities([SonicValve(config_entry.runtime_data)])
+
+
+class SonicValve(WatergateEntity, ValveEntity):
+ """Define a Sonic Valve entity."""
+
+ _attr_supported_features = ValveEntityFeature.OPEN | ValveEntityFeature.CLOSE
+ _attr_reports_position = False
+ _valve_state: str | None = None
+ _attr_device_class = ValveDeviceClass.WATER
+ _attr_name = None
+
+ def __init__(
+ self,
+ coordinator: WatergateDataCoordinator,
+ ) -> None:
+ """Initialize the sensor."""
+ super().__init__(coordinator, ENTITY_NAME)
+ self._valve_state = (
+ coordinator.data.state.valve_state if coordinator.data.state else None
+ )
+
+ @property
+ def is_closed(self) -> bool:
+ """Return if the valve is closed or not."""
+ return self._valve_state == ValveState.CLOSED
+
+ @property
+ def is_opening(self) -> bool | None:
+ """Return if the valve is opening or not."""
+ return self._valve_state == ValveState.OPENING
+
+ @property
+ def is_closing(self) -> bool | None:
+ """Return if the valve is closing or not."""
+ return self._valve_state == ValveState.CLOSING
+
+ @callback
+ def _handle_coordinator_update(self) -> None:
+ """Handle data update."""
+ self._attr_available = self.coordinator.data is not None
+ self._valve_state = (
+ self.coordinator.data.state.valve_state
+ if self.coordinator.data.state
+ else None
+ )
+ self.async_write_ha_state()
+
+ async def async_open_valve(self, **kwargs: Any) -> None:
+ """Open the valve."""
+ await self._api_client.async_set_valve_state(ValveState.OPEN)
+ self._valve_state = ValveState.OPENING
+ self.async_write_ha_state()
+
+ async def async_close_valve(self, **kwargs: Any) -> None:
+ """Close the valve."""
+ await self._api_client.async_set_valve_state(ValveState.CLOSED)
+ self._valve_state = ValveState.CLOSING
+ self.async_write_ha_state()
+
+ @property
+ def available(self) -> bool:
+ """Return True if entity is available."""
+ return super().available and self.coordinator.data.state is not None
diff --git a/homeassistant/components/watson_iot/manifest.json b/homeassistant/components/watson_iot/manifest.json
index 702c5492246..a457dcc44b1 100644
--- a/homeassistant/components/watson_iot/manifest.json
+++ b/homeassistant/components/watson_iot/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/watson_iot",
"iot_class": "cloud_push",
"loggers": ["ibmiotf", "paho_mqtt"],
+ "quality_scale": "legacy",
"requirements": ["ibmiotf==0.3.4"]
}
diff --git a/homeassistant/components/watson_tts/manifest.json b/homeassistant/components/watson_tts/manifest.json
index f26fc006561..ecc3d97be46 100644
--- a/homeassistant/components/watson_tts/manifest.json
+++ b/homeassistant/components/watson_tts/manifest.json
@@ -6,5 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/watson_tts",
"iot_class": "cloud_push",
"loggers": ["ibm_cloud_sdk_core", "ibm_watson"],
+ "quality_scale": "legacy",
"requirements": ["ibm-watson==5.2.2"]
}
diff --git a/homeassistant/components/waze_travel_time/__init__.py b/homeassistant/components/waze_travel_time/__init__.py
index 1abcf9d391d..34f22c9218f 100644
--- a/homeassistant/components/waze_travel_time/__init__.py
+++ b/homeassistant/components/waze_travel_time/__init__.py
@@ -3,12 +3,13 @@
import asyncio
from collections.abc import Collection
import logging
+from typing import Literal
from pywaze.route_calculator import CalcRoutesResponse, WazeRouteCalculator, WRCError
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import CONF_REGION, Platform
+from homeassistant.const import CONF_REGION, Platform, UnitOfLength
from homeassistant.core import (
HomeAssistant,
ServiceCall,
@@ -22,7 +23,10 @@ from homeassistant.helpers.selector import (
SelectSelectorConfig,
SelectSelectorMode,
TextSelector,
+ TextSelectorConfig,
+ TextSelectorType,
)
+from homeassistant.util.unit_conversion import DistanceConverter
from .const import (
CONF_AVOID_FERRIES,
@@ -38,6 +42,7 @@ from .const import (
DEFAULT_FILTER,
DEFAULT_VEHICLE_TYPE,
DOMAIN,
+ IMPERIAL_UNITS,
METRIC_UNITS,
REGIONS,
SEMAPHORE,
@@ -80,6 +85,18 @@ SERVICE_GET_TRAVEL_TIMES_SCHEMA = vol.Schema(
vol.Optional(CONF_AVOID_TOLL_ROADS, default=False): BooleanSelector(),
vol.Optional(CONF_AVOID_SUBSCRIPTION_ROADS, default=False): BooleanSelector(),
vol.Optional(CONF_AVOID_FERRIES, default=False): BooleanSelector(),
+ vol.Optional(CONF_INCL_FILTER): TextSelector(
+ TextSelectorConfig(
+ type=TextSelectorType.TEXT,
+ multiple=True,
+ ),
+ ),
+ vol.Optional(CONF_EXCL_FILTER): TextSelector(
+ TextSelectorConfig(
+ type=TextSelectorType.TEXT,
+ multiple=True,
+ ),
+ ),
}
)
@@ -107,6 +124,9 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
avoid_subscription_roads=service.data[CONF_AVOID_SUBSCRIPTION_ROADS],
avoid_ferries=service.data[CONF_AVOID_FERRIES],
realtime=service.data[CONF_REALTIME],
+ units=service.data[CONF_UNITS],
+ incl_filters=service.data.get(CONF_INCL_FILTER, DEFAULT_FILTER),
+ excl_filters=service.data.get(CONF_EXCL_FILTER, DEFAULT_FILTER),
)
return {"routes": [vars(route) for route in response]} if response else None
@@ -129,6 +149,7 @@ async def async_get_travel_times(
avoid_subscription_roads: bool,
avoid_ferries: bool,
realtime: bool,
+ units: Literal["metric", "imperial"] = "metric",
incl_filters: Collection[str] | None = None,
excl_filters: Collection[str] | None = None,
) -> list[CalcRoutesResponse] | None:
@@ -194,6 +215,20 @@ async def async_get_travel_times(
route for route in incl_routes if not should_exclude_route(route)
]
+ if units == IMPERIAL_UNITS:
+ filtered_routes = [
+ CalcRoutesResponse(
+ name=route.name,
+ distance=DistanceConverter.convert(
+ route.distance, UnitOfLength.KILOMETERS, UnitOfLength.MILES
+ ),
+ duration=route.duration,
+ street_names=route.street_names,
+ )
+ for route in filtered_routes
+ if route.distance is not None
+ ]
+
if len(filtered_routes) < 1:
_LOGGER.warning("No routes found")
return None
diff --git a/homeassistant/components/waze_travel_time/sensor.py b/homeassistant/components/waze_travel_time/sensor.py
index c2d3ee12cf8..a216a02f61e 100644
--- a/homeassistant/components/waze_travel_time/sensor.py
+++ b/homeassistant/components/waze_travel_time/sensor.py
@@ -20,7 +20,6 @@ from homeassistant.const import (
CONF_NAME,
CONF_REGION,
EVENT_HOMEASSISTANT_STARTED,
- UnitOfLength,
UnitOfTime,
)
from homeassistant.core import CoreState, HomeAssistant
@@ -28,7 +27,6 @@ from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.httpx_client import get_async_client
from homeassistant.helpers.location import find_coordinates
-from homeassistant.util.unit_conversion import DistanceConverter
from . import async_get_travel_times
from .const import (
@@ -44,7 +42,6 @@ from .const import (
CONF_VEHICLE_TYPE,
DEFAULT_NAME,
DOMAIN,
- IMPERIAL_UNITS,
SEMAPHORE,
)
@@ -201,6 +198,7 @@ class WazeTravelTimeData:
avoid_subscription_roads,
avoid_ferries,
realtime,
+ self.config_entry.options[CONF_UNITS],
incl_filter,
excl_filter,
)
@@ -211,14 +209,5 @@ class WazeTravelTimeData:
return
self.duration = route.duration
- distance = route.distance
-
- if self.config_entry.options[CONF_UNITS] == IMPERIAL_UNITS:
- # Convert to miles.
- self.distance = DistanceConverter.convert(
- distance, UnitOfLength.KILOMETERS, UnitOfLength.MILES
- )
- else:
- self.distance = distance
-
+ self.distance = route.distance
self.route = route.name
diff --git a/homeassistant/components/waze_travel_time/services.yaml b/homeassistant/components/waze_travel_time/services.yaml
index 7fba565dd47..fd5f2e9adea 100644
--- a/homeassistant/components/waze_travel_time/services.yaml
+++ b/homeassistant/components/waze_travel_time/services.yaml
@@ -55,3 +55,13 @@ get_travel_times:
required: false
selector:
boolean:
+ incl_filter:
+ required: false
+ selector:
+ text:
+ multiple: true
+ excl_filter:
+ required: false
+ selector:
+ text:
+ multiple: true
diff --git a/homeassistant/components/waze_travel_time/strings.json b/homeassistant/components/waze_travel_time/strings.json
index f053f033307..8f8de694b2d 100644
--- a/homeassistant/components/waze_travel_time/strings.json
+++ b/homeassistant/components/waze_travel_time/strings.json
@@ -3,7 +3,7 @@
"config": {
"step": {
"user": {
- "description": "For Origin and Destination, enter the address or the GPS coordinates of the location (GPS coordinates has to be separated by a comma). You can also enter an entity id which provides this information in its state, an entity id with latitude and longitude attributes, or zone friendly name.",
+ "description": "For Origin and Destination, enter the address or the GPS coordinates of the location (GPS coordinates has to be separated by a comma). You can also enter an entity ID which provides this information in its state, an entity ID with latitude and longitude attributes, or zone friendly name.",
"data": {
"name": "[%key:common::config_flow::data::name%]",
"origin": "Origin",
@@ -26,13 +26,13 @@
"description": "Some options will allow you to force the integration to use a particular route or avoid a particular route in its time travel calculation.",
"data": {
"units": "Units",
- "vehicle_type": "Vehicle Type",
+ "vehicle_type": "Vehicle type",
"incl_filter": "Exact streetname which must be part of the selected route",
"excl_filter": "Exact streetname which must NOT be part of the selected route",
- "realtime": "Realtime Travel Time?",
- "avoid_toll_roads": "Avoid Toll Roads?",
- "avoid_ferries": "Avoid Ferries?",
- "avoid_subscription_roads": "Avoid Roads Needing a Vignette / Subscription?"
+ "realtime": "Realtime travel time?",
+ "avoid_toll_roads": "Avoid toll roads?",
+ "avoid_ferries": "Avoid ferries?",
+ "avoid_subscription_roads": "Avoid roads needing a vignette / subscription?"
}
}
}
@@ -47,8 +47,8 @@
},
"units": {
"options": {
- "metric": "Metric System",
- "imperial": "Imperial System"
+ "metric": "Metric system",
+ "imperial": "Imperial system"
}
},
"region": {
@@ -63,8 +63,8 @@
},
"services": {
"get_travel_times": {
- "name": "Get Travel Times",
- "description": "Get route alternatives and travel times between two locations.",
+ "name": "Get travel times",
+ "description": "Retrieves route alternatives and travel times between two locations.",
"fields": {
"origin": {
"name": "[%key:component::waze_travel_time::config::step::user::data::origin%]",
@@ -76,7 +76,7 @@
},
"region": {
"name": "[%key:component::waze_travel_time::config::step::user::data::region%]",
- "description": "The region. Controls which waze server is used."
+ "description": "The region. Controls which Waze server is used."
},
"units": {
"name": "[%key:component::waze_travel_time::options::step::init::data::units%]",
@@ -101,6 +101,14 @@
"avoid_subscription_roads": {
"name": "[%key:component::waze_travel_time::options::step::init::data::avoid_subscription_roads%]",
"description": "Whether to avoid subscription roads."
+ },
+ "incl_filter": {
+ "name": "[%key:component::waze_travel_time::options::step::init::data::incl_filter%]",
+ "description": "Exact streetname which must be part of the selected route."
+ },
+ "excl_filter": {
+ "name": "[%key:component::waze_travel_time::options::step::init::data::excl_filter%]",
+ "description": "Exact streetname which must NOT be part of the selected route."
}
}
}
diff --git a/homeassistant/components/weatherkit/coordinator.py b/homeassistant/components/weatherkit/coordinator.py
index ddabba2fc1f..6438d7503db 100644
--- a/homeassistant/components/weatherkit/coordinator.py
+++ b/homeassistant/components/weatherkit/coordinator.py
@@ -2,7 +2,7 @@
from __future__ import annotations
-from datetime import timedelta
+from datetime import datetime, timedelta
from apple_weatherkit import DataSetType
from apple_weatherkit.client import WeatherKitApiClient, WeatherKitApiClientError
@@ -20,12 +20,15 @@ REQUESTED_DATA_SETS = [
DataSetType.HOURLY_FORECAST,
]
+STALE_DATA_THRESHOLD = timedelta(hours=1)
+
class WeatherKitDataUpdateCoordinator(DataUpdateCoordinator):
"""Class to manage fetching data from the API."""
config_entry: ConfigEntry
supported_data_sets: list[DataSetType] | None = None
+ last_updated_at: datetime | None = None
def __init__(
self,
@@ -62,10 +65,20 @@ class WeatherKitDataUpdateCoordinator(DataUpdateCoordinator):
if not self.supported_data_sets:
await self.update_supported_data_sets()
- return await self.client.get_weather_data(
+ updated_data = await self.client.get_weather_data(
self.config_entry.data[CONF_LATITUDE],
self.config_entry.data[CONF_LONGITUDE],
self.supported_data_sets,
)
except WeatherKitApiClientError as exception:
- raise UpdateFailed(exception) from exception
+ if self.data is None or (
+ self.last_updated_at is not None
+ and datetime.now() - self.last_updated_at > STALE_DATA_THRESHOLD
+ ):
+ raise UpdateFailed(exception) from exception
+
+ LOGGER.debug("Using stale data because update failed: %s", exception)
+ return self.data
+ else:
+ self.last_updated_at = datetime.now()
+ return updated_data
diff --git a/homeassistant/components/webostv/config_flow.py b/homeassistant/components/webostv/config_flow.py
index 45395bd282a..c62ecaa78cf 100644
--- a/homeassistant/components/webostv/config_flow.py
+++ b/homeassistant/components/webostv/config_flow.py
@@ -3,7 +3,6 @@
from __future__ import annotations
from collections.abc import Mapping
-import logging
from typing import Any, Self
from urllib.parse import urlparse
@@ -17,9 +16,8 @@ from homeassistant.config_entries import (
ConfigFlowResult,
OptionsFlow,
)
-from homeassistant.const import CONF_CLIENT_SECRET, CONF_HOST, CONF_NAME
+from homeassistant.const import CONF_CLIENT_SECRET, CONF_HOST
from homeassistant.core import callback
-from homeassistant.data_entry_flow import AbortFlow
from homeassistant.helpers import config_validation as cv
from . import async_control_connect, update_client_key
@@ -29,13 +27,10 @@ from .helpers import async_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__)
-
class FlowHandler(ConfigFlow, domain=DOMAIN):
"""WebosTV configuration flow."""
@@ -61,35 +56,17 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
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")
-
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 = {}
@@ -107,6 +84,9 @@ 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)
@@ -119,7 +99,9 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
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(
+ ssdp.ATTR_UPNP_FRIENDLY_NAME, DEFAULT_NAME
+ ).replace("[LG]", "LG")
uuid = discovery_info.upnp[ssdp.ATTR_UPNP_UDN]
assert uuid
diff --git a/homeassistant/components/webostv/const.py b/homeassistant/components/webostv/const.py
index c20060cae91..0d839568f13 100644
--- a/homeassistant/components/webostv/const.py
+++ b/homeassistant/components/webostv/const.py
@@ -11,7 +11,7 @@ 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"
diff --git a/homeassistant/components/webostv/manifest.json b/homeassistant/components/webostv/manifest.json
index 679bad9b9f5..6c826c2f997 100644
--- a/homeassistant/components/webostv/manifest.json
+++ b/homeassistant/components/webostv/manifest.json
@@ -6,7 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/webostv",
"iot_class": "local_push",
"loggers": ["aiowebostv"],
- "quality_scale": "platinum",
"requirements": ["aiowebostv==0.4.2"],
"ssdp": [
{
diff --git a/homeassistant/components/webostv/quality_scale.yaml b/homeassistant/components/webostv/quality_scale.yaml
new file mode 100644
index 00000000000..693cefcdbfc
--- /dev/null
+++ b/homeassistant/components/webostv/quality_scale.yaml
@@ -0,0 +1,88 @@
+rules:
+ # Bronze
+ action-setup:
+ status: todo
+ comment: move actions to entity services
+ appropriate-polling: done
+ brands: done
+ common-modules:
+ status: exempt
+ comment: The integration does not use common patterns.
+ config-flow-test-coverage:
+ status: todo
+ comment: remove duplicated config flow start in tests, make sure tests ends with CREATE_ENTRY or ABORT, use hass.config_entries.async_setup instead of async_setup_component, snapshot in diagnostics (and other tests when possible), test_client_disconnected validate no error in log
+ config-flow:
+ status: todo
+ comment: make reauth flow more graceful
+ dependency-transparency: done
+ docs-actions:
+ status: todo
+ comment: add description for parameters
+ 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: todo
+ 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: todo
+ integration-owner: done
+ log-when-unavailable: todo
+ parallel-updates: todo
+ reauthentication-flow: done
+ test-coverage: todo
+
+ # Gold
+ devices: done
+ diagnostics: done
+ discovery-update-info: done
+ discovery: done
+ 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: 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: todo
+ icon-translations:
+ status: exempt
+ comment: The only entity can use the device class.
+ reconfiguration-flow: todo
+ 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:
+ status: todo
+ comment: need to check if it is needed for websockets or migrate to aiohttp
+ strict-typing:
+ status: todo
+ comment: aiowebostv is not fully typed
diff --git a/homeassistant/components/webostv/strings.json b/homeassistant/components/webostv/strings.json
index 3ceab5f50a3..34c1b44e195 100644
--- a/homeassistant/components/webostv/strings.json
+++ b/homeassistant/components/webostv/strings.json
@@ -1,12 +1,11 @@
{
"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."
diff --git a/homeassistant/components/weheat/__init__.py b/homeassistant/components/weheat/__init__.py
index d924d6ceaab..a043a3a6845 100644
--- a/homeassistant/components/weheat/__init__.py
+++ b/homeassistant/components/weheat/__init__.py
@@ -17,7 +17,7 @@ from homeassistant.helpers.config_entry_oauth2_flow import (
from .const import API_URL, LOGGER
from .coordinator import WeheatDataUpdateCoordinator
-PLATFORMS: list[Platform] = [Platform.SENSOR]
+PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR]
type WeheatConfigEntry = ConfigEntry[list[WeheatDataUpdateCoordinator]]
diff --git a/homeassistant/components/weheat/binary_sensor.py b/homeassistant/components/weheat/binary_sensor.py
new file mode 100644
index 00000000000..ea939227e77
--- /dev/null
+++ b/homeassistant/components/weheat/binary_sensor.py
@@ -0,0 +1,100 @@
+"""Binary sensor platform for Weheat integration."""
+
+from collections.abc import Callable
+from dataclasses import dataclass
+
+from weheat.abstractions.heat_pump import HeatPump
+
+from homeassistant.components.binary_sensor import (
+ BinarySensorDeviceClass,
+ BinarySensorEntity,
+ BinarySensorEntityDescription,
+)
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.typing import StateType
+
+from . import WeheatConfigEntry
+from .coordinator import WeheatDataUpdateCoordinator
+from .entity import WeheatEntity
+
+
+@dataclass(frozen=True, kw_only=True)
+class WeHeatBinarySensorEntityDescription(BinarySensorEntityDescription):
+ """Describes Weheat binary sensor entity."""
+
+ value_fn: Callable[[HeatPump], StateType]
+
+
+BINARY_SENSORS = [
+ WeHeatBinarySensorEntityDescription(
+ translation_key="indoor_unit_water_pump_state",
+ key="indoor_unit_water_pump_state",
+ device_class=BinarySensorDeviceClass.RUNNING,
+ value_fn=lambda status: status.indoor_unit_water_pump_state,
+ ),
+ WeHeatBinarySensorEntityDescription(
+ translation_key="indoor_unit_auxiliary_pump_state",
+ key="indoor_unit_auxiliary_pump_state",
+ device_class=BinarySensorDeviceClass.RUNNING,
+ value_fn=lambda status: status.indoor_unit_auxiliary_pump_state,
+ ),
+ WeHeatBinarySensorEntityDescription(
+ translation_key="indoor_unit_dhw_valve_or_pump_state",
+ key="indoor_unit_dhw_valve_or_pump_state",
+ device_class=BinarySensorDeviceClass.RUNNING,
+ value_fn=lambda status: status.indoor_unit_dhw_valve_or_pump_state,
+ ),
+ WeHeatBinarySensorEntityDescription(
+ translation_key="indoor_unit_gas_boiler_state",
+ key="indoor_unit_gas_boiler_state",
+ value_fn=lambda status: status.indoor_unit_gas_boiler_state,
+ ),
+ WeHeatBinarySensorEntityDescription(
+ translation_key="indoor_unit_electric_heater_state",
+ key="indoor_unit_electric_heater_state",
+ device_class=BinarySensorDeviceClass.RUNNING,
+ value_fn=lambda status: status.indoor_unit_electric_heater_state,
+ ),
+]
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: WeheatConfigEntry,
+ async_add_entities: AddEntitiesCallback,
+) -> None:
+ """Set up the sensors for weheat heat pump."""
+ entities = [
+ WeheatHeatPumpBinarySensor(coordinator, entity_description)
+ for entity_description in BINARY_SENSORS
+ for coordinator in entry.runtime_data
+ if entity_description.value_fn(coordinator.data) is not None
+ ]
+
+ async_add_entities(entities)
+
+
+class WeheatHeatPumpBinarySensor(WeheatEntity, BinarySensorEntity):
+ """Defines a Weheat heat pump binary sensor."""
+
+ coordinator: WeheatDataUpdateCoordinator
+ entity_description: WeHeatBinarySensorEntityDescription
+
+ def __init__(
+ self,
+ coordinator: WeheatDataUpdateCoordinator,
+ entity_description: WeHeatBinarySensorEntityDescription,
+ ) -> None:
+ """Pass coordinator to CoordinatorEntity."""
+ super().__init__(coordinator)
+
+ self.entity_description = entity_description
+
+ self._attr_unique_id = f"{coordinator.heatpump_id}_{entity_description.key}"
+
+ @property
+ def is_on(self) -> bool | None:
+ """Return True if the binary sensor is on."""
+ value = self.entity_description.value_fn(self.coordinator.data)
+ return bool(value) if value is not None else None
diff --git a/homeassistant/components/weheat/icons.json b/homeassistant/components/weheat/icons.json
index 6fdae84cfff..e7f54b478c6 100644
--- a/homeassistant/components/weheat/icons.json
+++ b/homeassistant/components/weheat/icons.json
@@ -1,5 +1,22 @@
{
"entity": {
+ "binary_sensor": {
+ "indoor_unit_water_pump_state": {
+ "default": "mdi:pump"
+ },
+ "indoor_unit_auxiliary_pump_state": {
+ "default": "mdi:pump"
+ },
+ "indoor_unit_dhw_valve_or_pump_state": {
+ "default": "mdi:pump"
+ },
+ "indoor_unit_gas_boiler_state": {
+ "default": "mdi:toggle-switch"
+ },
+ "indoor_unit_electric_heater_state": {
+ "default": "mdi:heating-coil"
+ }
+ },
"sensor": {
"power_output": {
"default": "mdi:heat-wave"
@@ -27,6 +44,12 @@
},
"electricity_used": {
"default": "mdi:flash"
+ },
+ "compressor_rpm": {
+ "default": "mdi:fan"
+ },
+ "compressor_percentage": {
+ "default": "mdi:fan"
}
}
}
diff --git a/homeassistant/components/weheat/manifest.json b/homeassistant/components/weheat/manifest.json
index d32e0ce4047..1c6242de29c 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.09.23"]
+ "requirements": ["weheat==2024.12.22"]
}
diff --git a/homeassistant/components/weheat/sensor.py b/homeassistant/components/weheat/sensor.py
index ef5be9030b9..3e5d9376c34 100644
--- a/homeassistant/components/weheat/sensor.py
+++ b/homeassistant/components/weheat/sensor.py
@@ -11,7 +11,13 @@ from homeassistant.components.sensor import (
SensorEntityDescription,
SensorStateClass,
)
-from homeassistant.const import UnitOfEnergy, UnitOfPower, UnitOfTemperature
+from homeassistant.const import (
+ PERCENTAGE,
+ REVOLUTIONS_PER_MINUTE,
+ UnitOfEnergy,
+ UnitOfPower,
+ UnitOfTemperature,
+)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import StateType
@@ -142,6 +148,28 @@ SENSORS = [
state_class=SensorStateClass.TOTAL_INCREASING,
value_fn=lambda status: status.energy_total,
),
+ WeHeatSensorEntityDescription(
+ translation_key="energy_output",
+ key="energy_output",
+ native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
+ device_class=SensorDeviceClass.ENERGY,
+ state_class=SensorStateClass.TOTAL_INCREASING,
+ value_fn=lambda status: status.energy_output,
+ ),
+ WeHeatSensorEntityDescription(
+ translation_key="compressor_rpm",
+ key="compressor_rpm",
+ state_class=SensorStateClass.MEASUREMENT,
+ native_unit_of_measurement=REVOLUTIONS_PER_MINUTE,
+ value_fn=lambda status: status.compressor_rpm,
+ ),
+ WeHeatSensorEntityDescription(
+ translation_key="compressor_percentage",
+ key="compressor_percentage",
+ state_class=SensorStateClass.MEASUREMENT,
+ native_unit_of_measurement=PERCENTAGE,
+ value_fn=lambda status: status.compressor_percentage,
+ ),
]
diff --git a/homeassistant/components/weheat/strings.json b/homeassistant/components/weheat/strings.json
index 0733024cbed..2a208c2f8ca 100644
--- a/homeassistant/components/weheat/strings.json
+++ b/homeassistant/components/weheat/strings.json
@@ -32,6 +32,23 @@
}
},
"entity": {
+ "binary_sensor": {
+ "indoor_unit_water_pump_state": {
+ "name": "Indoor unit water pump"
+ },
+ "indoor_unit_auxiliary_pump_state": {
+ "name": "Indoor unit auxilary water pump"
+ },
+ "indoor_unit_dhw_valve_or_pump_state": {
+ "name": "Indoor unit DHW valve or water pump"
+ },
+ "indoor_unit_gas_boiler_state": {
+ "name": "Indoor unit gas boiler heating allowed"
+ },
+ "indoor_unit_electric_heater_state": {
+ "name": "Indoor unit electric heater"
+ }
+ },
"sensor": {
"power_output": {
"name": "Output power"
@@ -84,6 +101,15 @@
},
"electricity_used": {
"name": "Electricity used"
+ },
+ "energy_output": {
+ "name": "Total energy output"
+ },
+ "compressor_rpm": {
+ "name": "Compressor speed"
+ },
+ "compressor_percentage": {
+ "name": "Compressor usage"
}
}
}
diff --git a/homeassistant/components/wemo/fan.py b/homeassistant/components/wemo/fan.py
index f9d3270aaa0..42dae679aa5 100644
--- a/homeassistant/components/wemo/fan.py
+++ b/homeassistant/components/wemo/fan.py
@@ -81,7 +81,6 @@ class WemoHumidifier(WemoBinaryStateEntity, FanEntity):
)
wemo: Humidifier
_last_fan_on_mode: FanMode
- _enable_turn_on_off_backwards_compatibility = False
def __init__(self, coordinator: DeviceCoordinator) -> None:
"""Initialize the WeMo switch."""
diff --git a/homeassistant/components/wemo/light.py b/homeassistant/components/wemo/light.py
index 26dec417631..6068cd3ff0b 100644
--- a/homeassistant/components/wemo/light.py
+++ b/homeassistant/components/wemo/light.py
@@ -8,9 +8,11 @@ from pywemo import Bridge, BridgeLight, Dimmer
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
- ATTR_COLOR_TEMP,
+ ATTR_COLOR_TEMP_KELVIN,
ATTR_HS_COLOR,
ATTR_TRANSITION,
+ DEFAULT_MAX_KELVIN,
+ DEFAULT_MIN_KELVIN,
ColorMode,
LightEntity,
LightEntityFeature,
@@ -77,6 +79,8 @@ def async_setup_bridge(
class WemoLight(WemoEntity, LightEntity):
"""Representation of a WeMo light."""
+ _attr_max_color_temp_kelvin = DEFAULT_MAX_KELVIN
+ _attr_min_color_temp_kelvin = DEFAULT_MIN_KELVIN
_attr_supported_features = LightEntityFeature.TRANSITION
def __init__(self, coordinator: DeviceCoordinator, light: BridgeLight) -> None:
@@ -123,9 +127,11 @@ class WemoLight(WemoEntity, LightEntity):
return self.light.state.get("color_xy")
@property
- def color_temp(self) -> int | None:
- """Return the color temperature of this light in mireds."""
- return self.light.state.get("temperature_mireds")
+ def color_temp_kelvin(self) -> int | None:
+ """Return the color temperature value in Kelvin."""
+ if not (mireds := self.light.state.get("temperature_mireds")):
+ return None
+ return color_util.color_temperature_mired_to_kelvin(mireds)
@property
def color_mode(self) -> ColorMode:
@@ -165,7 +171,7 @@ class WemoLight(WemoEntity, LightEntity):
xy_color = None
brightness = kwargs.get(ATTR_BRIGHTNESS, self.brightness or 255)
- color_temp = kwargs.get(ATTR_COLOR_TEMP)
+ color_temp_kelvin = kwargs.get(ATTR_COLOR_TEMP_KELVIN)
hs_color = kwargs.get(ATTR_HS_COLOR)
transition_time = int(kwargs.get(ATTR_TRANSITION, 0))
@@ -182,9 +188,9 @@ class WemoLight(WemoEntity, LightEntity):
if xy_color is not None:
self.light.set_color(xy_color, transition=transition_time)
- if color_temp is not None:
+ if color_temp_kelvin is not None:
self.light.set_temperature(
- mireds=color_temp, transition=transition_time
+ kelvin=color_temp_kelvin, transition=transition_time
)
self.light.turn_on(**turn_on_kwargs)
diff --git a/homeassistant/components/whirlpool/__init__.py b/homeassistant/components/whirlpool/__init__.py
index 36f8fbec59d..64adcda4742 100644
--- a/homeassistant/components/whirlpool/__init__.py
+++ b/homeassistant/components/whirlpool/__init__.py
@@ -20,8 +20,10 @@ _LOGGER = logging.getLogger(__name__)
PLATFORMS = [Platform.CLIMATE, Platform.SENSOR]
+type WhirlpoolConfigEntry = ConfigEntry[WhirlpoolData]
-async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+
+async def async_setup_entry(hass: HomeAssistant, entry: WhirlpoolConfigEntry) -> bool:
"""Set up Whirlpool Sixth Sense from a config entry."""
hass.data.setdefault(DOMAIN, {})
@@ -47,21 +49,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
_LOGGER.error("Cannot fetch appliances")
return False
- hass.data[DOMAIN][entry.entry_id] = WhirlpoolData(
- appliances_manager, auth, backend_selector
- )
+ entry.runtime_data = WhirlpoolData(appliances_manager, auth, backend_selector)
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: WhirlpoolConfigEntry) -> 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)
@dataclass
diff --git a/homeassistant/components/whirlpool/climate.py b/homeassistant/components/whirlpool/climate.py
index aa399746006..943c5d1c956 100644
--- a/homeassistant/components/whirlpool/climate.py
+++ b/homeassistant/components/whirlpool/climate.py
@@ -23,7 +23,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.aiohttp_client import async_get_clientsession
@@ -31,7 +30,7 @@ from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import generate_entity_id
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from . import WhirlpoolData
+from . import WhirlpoolConfigEntry
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
@@ -70,11 +69,11 @@ SUPPORTED_TARGET_TEMPERATURE_STEP = 1
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ config_entry: WhirlpoolConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up entry."""
- whirlpool_data: WhirlpoolData = hass.data[DOMAIN][config_entry.entry_id]
+ whirlpool_data = config_entry.runtime_data
aircons = [
AirConEntity(
@@ -110,7 +109,6 @@ class AirConEntity(ClimateEntity):
_attr_swing_modes = SUPPORTED_SWING_MODES
_attr_target_temperature_step = SUPPORTED_TARGET_TEMPERATURE_STEP
_attr_temperature_unit = UnitOfTemperature.CELSIUS
- _enable_turn_on_off_backwards_compatibility = False
def __init__(
self,
diff --git a/homeassistant/components/whirlpool/diagnostics.py b/homeassistant/components/whirlpool/diagnostics.py
index 9b1dd00e7bd..87d6ea827e2 100644
--- a/homeassistant/components/whirlpool/diagnostics.py
+++ b/homeassistant/components/whirlpool/diagnostics.py
@@ -5,11 +5,9 @@ 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 . import WhirlpoolData
-from .const import DOMAIN
+from . import WhirlpoolConfigEntry
TO_REDACT = {
"SERIAL_NUMBER",
@@ -24,11 +22,11 @@ TO_REDACT = {
async def async_get_config_entry_diagnostics(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ config_entry: WhirlpoolConfigEntry,
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
- whirlpool: WhirlpoolData = hass.data[DOMAIN][config_entry.entry_id]
+ whirlpool = config_entry.runtime_data
diagnostics_data = {
"Washer_dryers": {
wd["NAME"]: dict(wd.items())
diff --git a/homeassistant/components/whirlpool/manifest.json b/homeassistant/components/whirlpool/manifest.json
index 5618a3f61cb..b463a1a76f8 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.8"]
+ "requirements": ["whirlpool-sixth-sense==0.18.11"]
}
diff --git a/homeassistant/components/whirlpool/sensor.py b/homeassistant/components/whirlpool/sensor.py
index 8c74f01298e..b84518cedf1 100644
--- a/homeassistant/components/whirlpool/sensor.py
+++ b/homeassistant/components/whirlpool/sensor.py
@@ -15,7 +15,6 @@ from homeassistant.components.sensor import (
SensorEntity,
SensorEntityDescription,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.device_registry import DeviceInfo
@@ -23,7 +22,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.util.dt import utcnow
-from . import WhirlpoolData
+from . import WhirlpoolConfigEntry
from .const import DOMAIN
TANK_FILL = {
@@ -132,12 +131,12 @@ SENSOR_TIMER: tuple[SensorEntityDescription] = (
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ config_entry: WhirlpoolConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Config flow entry for Whrilpool Laundry."""
entities: list = []
- whirlpool_data: WhirlpoolData = hass.data[DOMAIN][config_entry.entry_id]
+ whirlpool_data = config_entry.runtime_data
for appliance in whirlpool_data.appliances_manager.washer_dryers:
_wd = WasherDryer(
whirlpool_data.backend_selector,
diff --git a/homeassistant/components/wilight/fan.py b/homeassistant/components/wilight/fan.py
index 71f1098603b..a14198e3b5d 100644
--- a/homeassistant/components/wilight/fan.py
+++ b/homeassistant/components/wilight/fan.py
@@ -64,7 +64,6 @@ class WiLightFan(WiLightDevice, FanEntity):
| FanEntityFeature.TURN_ON
| FanEntityFeature.TURN_OFF
)
- _enable_turn_on_off_backwards_compatibility = False
def __init__(self, api_device: PyWiLightDevice, index: str, item_name: str) -> None:
"""Initialize the device."""
diff --git a/homeassistant/components/wilight/manifest.json b/homeassistant/components/wilight/manifest.json
index 8da0ffd9241..7f7e16d55fb 100644
--- a/homeassistant/components/wilight/manifest.json
+++ b/homeassistant/components/wilight/manifest.json
@@ -6,7 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/wilight",
"iot_class": "local_polling",
"loggers": ["pywilight"],
- "quality_scale": "silver",
"requirements": ["pywilight==0.0.74"],
"ssdp": [
{
diff --git a/homeassistant/components/wirelesstag/manifest.json b/homeassistant/components/wirelesstag/manifest.json
index 9735c833453..1ff9403d3bc 100644
--- a/homeassistant/components/wirelesstag/manifest.json
+++ b/homeassistant/components/wirelesstag/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/wirelesstag",
"iot_class": "cloud_push",
"loggers": ["wirelesstagpy"],
+ "quality_scale": "legacy",
"requirements": ["wirelesstagpy==0.8.1"]
}
diff --git a/homeassistant/components/withings/icons.json b/homeassistant/components/withings/icons.json
index 79ff7489bf8..8123337dc82 100644
--- a/homeassistant/components/withings/icons.json
+++ b/homeassistant/components/withings/icons.json
@@ -16,6 +16,9 @@
"heart_pulse": {
"default": "mdi:heart-pulse"
},
+ "height": {
+ "default": "mdi:human-male-height-variant"
+ },
"hydration": {
"default": "mdi:water"
},
diff --git a/homeassistant/components/withings/manifest.json b/homeassistant/components/withings/manifest.json
index a0a86be5da3..ad9b9a6fe71 100644
--- a/homeassistant/components/withings/manifest.json
+++ b/homeassistant/components/withings/manifest.json
@@ -5,9 +5,13 @@
"codeowners": ["@joostlek"],
"config_flow": true,
"dependencies": ["application_credentials", "http", "webhook"],
+ "dhcp": [
+ {
+ "macaddress": "0024E4*"
+ }
+ ],
"documentation": "https://www.home-assistant.io/integrations/withings",
"iot_class": "cloud_push",
"loggers": ["aiowithings"],
- "quality_scale": "platinum",
- "requirements": ["aiowithings==3.1.1"]
+ "requirements": ["aiowithings==3.1.4"]
}
diff --git a/homeassistant/components/wiz/light.py b/homeassistant/components/wiz/light.py
index a3f36d580d2..9ef4cd57b3d 100644
--- a/homeassistant/components/wiz/light.py
+++ b/homeassistant/components/wiz/light.py
@@ -10,7 +10,7 @@ from pywizlight.scenes import get_id_from_scene_name
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
- ATTR_COLOR_TEMP,
+ ATTR_COLOR_TEMP_KELVIN,
ATTR_EFFECT,
ATTR_RGBW_COLOR,
ATTR_RGBWW_COLOR,
@@ -21,10 +21,6 @@ from homeassistant.components.light import (
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from homeassistant.util.color import (
- color_temperature_kelvin_to_mired,
- color_temperature_mired_to_kelvin,
-)
from . import WizConfigEntry
from .entity import WizToggleEntity
@@ -43,10 +39,10 @@ def _async_pilot_builder(**kwargs: Any) -> PilotBuilder:
if ATTR_RGBW_COLOR in kwargs:
return PilotBuilder(brightness=brightness, rgbw=kwargs[ATTR_RGBW_COLOR])
- if ATTR_COLOR_TEMP in kwargs:
+ if ATTR_COLOR_TEMP_KELVIN in kwargs:
return PilotBuilder(
brightness=brightness,
- colortemp=color_temperature_mired_to_kelvin(kwargs[ATTR_COLOR_TEMP]),
+ colortemp=kwargs[ATTR_COLOR_TEMP_KELVIN],
)
if ATTR_EFFECT in kwargs:
@@ -93,8 +89,8 @@ class WizBulbEntity(WizToggleEntity, LightEntity):
self._attr_effect_list = wiz_data.scenes
if bulb_type.bulb_type != BulbClass.DW:
kelvin = bulb_type.kelvin_range
- self._attr_min_mireds = color_temperature_kelvin_to_mired(kelvin.max)
- self._attr_max_mireds = color_temperature_kelvin_to_mired(kelvin.min)
+ self._attr_max_color_temp_kelvin = kelvin.max
+ self._attr_min_color_temp_kelvin = kelvin.min
if bulb_type.features.effect:
self._attr_supported_features = LightEntityFeature.EFFECT
self._async_update_attrs()
@@ -111,7 +107,7 @@ class WizBulbEntity(WizToggleEntity, LightEntity):
color_temp := state.get_colortemp()
):
self._attr_color_mode = ColorMode.COLOR_TEMP
- self._attr_color_temp = color_temperature_kelvin_to_mired(color_temp)
+ self._attr_color_temp_kelvin = color_temp
elif (
ColorMode.RGBWW in color_modes and (rgbww := state.get_rgbww()) is not None
):
diff --git a/homeassistant/components/wiz/manifest.json b/homeassistant/components/wiz/manifest.json
index bb5527bc467..7b1ecdcdb6b 100644
--- a/homeassistant/components/wiz/manifest.json
+++ b/homeassistant/components/wiz/manifest.json
@@ -26,6 +26,5 @@
],
"documentation": "https://www.home-assistant.io/integrations/wiz",
"iot_class": "local_push",
- "quality_scale": "platinum",
"requirements": ["pywizlight==0.5.14"]
}
diff --git a/homeassistant/components/wled/const.py b/homeassistant/components/wled/const.py
index 69ff6ccb1fa..8d09867a46e 100644
--- a/homeassistant/components/wled/const.py
+++ b/homeassistant/components/wled/const.py
@@ -53,7 +53,9 @@ LIGHT_CAPABILITIES_COLOR_MODE_MAPPING: dict[LightCapability, list[ColorMode]] =
ColorMode.COLOR_TEMP,
],
LightCapability.RGB_COLOR | LightCapability.COLOR_TEMPERATURE: [
- ColorMode.RGBWW,
+ # Technically this is RGBWW but wled does not support RGBWW colors (with warm and cold white separately)
+ # but rather RGB + CCT which does not have a direct mapping in HA
+ ColorMode.RGB,
],
LightCapability.WHITE_CHANNEL | LightCapability.COLOR_TEMPERATURE: [
ColorMode.COLOR_TEMP,
diff --git a/homeassistant/components/wled/manifest.json b/homeassistant/components/wled/manifest.json
index 71939127356..326008ae1af 100644
--- a/homeassistant/components/wled/manifest.json
+++ b/homeassistant/components/wled/manifest.json
@@ -6,7 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/wled",
"integration_type": "device",
"iot_class": "local_push",
- "quality_scale": "platinum",
- "requirements": ["wled==0.20.2"],
+ "requirements": ["wled==0.21.0"],
"zeroconf": ["_wled._tcp.local."]
}
diff --git a/homeassistant/components/workday/binary_sensor.py b/homeassistant/components/workday/binary_sensor.py
index f4a2541a1d7..3684208f102 100644
--- a/homeassistant/components/workday/binary_sensor.py
+++ b/homeassistant/components/workday/binary_sensor.py
@@ -94,7 +94,11 @@ def _get_obj_holidays(
language=language,
categories=set_categories,
)
- if (supported_languages := obj_holidays.supported_languages) and language == "en":
+ if (
+ (supported_languages := obj_holidays.supported_languages)
+ and language
+ and language.startswith("en")
+ ):
for lang in supported_languages:
if lang.startswith("en"):
obj_holidays = country_holidays(
diff --git a/homeassistant/components/workday/config_flow.py b/homeassistant/components/workday/config_flow.py
index 4d93fccb1a7..895c7cd50e2 100644
--- a/homeassistant/components/workday/config_flow.py
+++ b/homeassistant/components/workday/config_flow.py
@@ -67,12 +67,14 @@ def add_province_and_language_to_schema(
_country = country_holidays(country=country)
if country_default_language := (_country.default_language):
selectable_languages = _country.supported_languages
- new_selectable_languages = [lang[:2] for lang in selectable_languages]
+ new_selectable_languages = list(selectable_languages)
language_schema = {
vol.Optional(
CONF_LANGUAGE, default=country_default_language
): LanguageSelector(
- LanguageSelectorConfig(languages=new_selectable_languages)
+ LanguageSelectorConfig(
+ languages=new_selectable_languages, native_name=True
+ )
)
}
@@ -134,7 +136,7 @@ def validate_custom_dates(user_input: dict[str, Any]) -> None:
year: int = dt_util.now().year
if country := user_input.get(CONF_COUNTRY):
- language = user_input.get(CONF_LANGUAGE)
+ language: str | None = user_input.get(CONF_LANGUAGE)
province = user_input.get(CONF_PROVINCE)
obj_holidays = country_holidays(
country=country,
@@ -143,8 +145,10 @@ def validate_custom_dates(user_input: dict[str, Any]) -> None:
language=language,
)
if (
- supported_languages := obj_holidays.supported_languages
- ) and language == "en":
+ (supported_languages := obj_holidays.supported_languages)
+ and language
+ and language.startswith("en")
+ ):
for lang in supported_languages:
if lang.startswith("en"):
obj_holidays = country_holidays(
@@ -372,7 +376,7 @@ class WorkdayOptionsFlowHandler(OptionsFlow):
errors=errors,
description_placeholders={
"name": options[CONF_NAME],
- "country": options.get(CONF_COUNTRY),
+ "country": options.get(CONF_COUNTRY, "-"),
},
)
diff --git a/homeassistant/components/workday/manifest.json b/homeassistant/components/workday/manifest.json
index b02db734729..bb5e6333b8b 100644
--- a/homeassistant/components/workday/manifest.json
+++ b/homeassistant/components/workday/manifest.json
@@ -7,5 +7,5 @@
"iot_class": "local_polling",
"loggers": ["holidays"],
"quality_scale": "internal",
- "requirements": ["holidays==0.60"]
+ "requirements": ["holidays==0.64"]
}
diff --git a/homeassistant/components/workday/strings.json b/homeassistant/components/workday/strings.json
index f3b966e28ea..87fa294dbba 100644
--- a/homeassistant/components/workday/strings.json
+++ b/homeassistant/components/workday/strings.json
@@ -14,9 +14,9 @@
"options": {
"description": "Set additional options for {name} configured for country {country}",
"data": {
- "excludes": "Excludes",
+ "excludes": "Days to exclude",
"days_offset": "Offset",
- "workdays": "Workdays",
+ "workdays": "Days to include",
"add_holidays": "Add holidays",
"remove_holidays": "Remove Holidays",
"province": "Subdivision of country",
@@ -24,9 +24,9 @@
"category": "Additional category as holiday"
},
"data_description": {
- "excludes": "List of workdays to exclude, notice the keyword `holiday` and read the documentation on how to use it correctly",
+ "excludes": "Select which weekdays to exclude as workdays.\nThe key `holidays` adds those for the configured country, customizable by all the settings below. Read the documentation on how to use them correctly.",
"days_offset": "Days offset from current day",
- "workdays": "List of working days",
+ "workdays": "Select which weekdays to include as possible workdays.",
"add_holidays": "Add custom holidays as YYYY-MM-DD or as range using `,` as separator",
"remove_holidays": "Remove holidays as YYYY-MM-DD, as range using `,` as separator or by using partial of name",
"province": "State, territory, province or region of country",
@@ -86,18 +86,19 @@
"options": {
"armed_forces": "Armed forces",
"bank": "Bank",
+ "catholic": "Catholic",
+ "chinese": "Chinese",
+ "christian": "Christian",
"government": "Government",
"half_day": "Half day",
+ "hebrew": "Hebrew",
+ "hindu": "Hindu",
+ "islamic": "Islamic",
"optional": "Optional",
"public": "Public",
"school": "School",
"unofficial": "Unofficial",
- "workday": "Workday",
- "chinese": "Chinese",
- "christian": "Christian",
- "hebrew": "Hebrew",
- "hindu": "Hindu",
- "islamic": "Islamic"
+ "workday": "Workday"
}
},
"days": {
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..89ea14bbbd0 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 .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/manifest.json b/homeassistant/components/worldtidesinfo/manifest.json
index 962e63617f4..c873f2f08f3 100644
--- a/homeassistant/components/worldtidesinfo/manifest.json
+++ b/homeassistant/components/worldtidesinfo/manifest.json
@@ -3,5 +3,6 @@
"name": "World Tides",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/worldtidesinfo",
- "iot_class": "cloud_polling"
+ "iot_class": "cloud_polling",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/worxlandroid/manifest.json b/homeassistant/components/worxlandroid/manifest.json
index a74228295c8..7a65b3b91b6 100644
--- a/homeassistant/components/worxlandroid/manifest.json
+++ b/homeassistant/components/worxlandroid/manifest.json
@@ -3,5 +3,6 @@
"name": "Worx Landroid",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/worxlandroid",
- "iot_class": "local_polling"
+ "iot_class": "local_polling",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/ws66i/manifest.json b/homeassistant/components/ws66i/manifest.json
index d259823d5af..c465a9f9f37 100644
--- a/homeassistant/components/ws66i/manifest.json
+++ b/homeassistant/components/ws66i/manifest.json
@@ -5,6 +5,5 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/ws66i",
"iot_class": "local_polling",
- "quality_scale": "silver",
"requirements": ["pyws66i==1.1"]
}
diff --git a/homeassistant/components/wsdot/manifest.json b/homeassistant/components/wsdot/manifest.json
index 4444cfbac4a..9b7746eea74 100644
--- a/homeassistant/components/wsdot/manifest.json
+++ b/homeassistant/components/wsdot/manifest.json
@@ -3,5 +3,6 @@
"name": "Washington State Department of Transportation (WSDOT)",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/wsdot",
- "iot_class": "cloud_polling"
+ "iot_class": "cloud_polling",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/wyoming/config_flow.py b/homeassistant/components/wyoming/config_flow.py
index 5fdcb1a5484..ddf57cf0ed0 100644
--- a/homeassistant/components/wyoming/config_flow.py
+++ b/homeassistant/components/wyoming/config_flow.py
@@ -9,7 +9,7 @@ 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
@@ -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(
{
@@ -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/x10/manifest.json b/homeassistant/components/x10/manifest.json
index 258080dc374..517bab07f6c 100644
--- a/homeassistant/components/x10/manifest.json
+++ b/homeassistant/components/x10/manifest.json
@@ -3,5 +3,6 @@
"name": "Heyu X10",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/x10",
- "iot_class": "local_polling"
+ "iot_class": "local_polling",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/xbox/__init__.py b/homeassistant/components/xbox/__init__.py
index 6ab46cea069..5282a34903a 100644
--- a/homeassistant/components/xbox/__init__.py
+++ b/homeassistant/components/xbox/__init__.py
@@ -10,11 +10,7 @@ from xbox.webapi.api.provider.smartglass.models import SmartglassConsoleList
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
-from homeassistant.helpers import (
- aiohttp_client,
- config_entry_oauth2_flow,
- config_validation as cv,
-)
+from homeassistant.helpers import config_entry_oauth2_flow, config_validation as cv
from . import api
from .const import DOMAIN
@@ -40,9 +36,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
)
)
session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation)
- auth = api.AsyncConfigEntryAuth(
- aiohttp_client.async_get_clientsession(hass), session
- )
+ auth = api.AsyncConfigEntryAuth(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 a0c2d4cfb16..d4c47e4cc39 100644
--- a/homeassistant/components/xbox/api.py
+++ b/homeassistant/components/xbox/api.py
@@ -1,24 +1,20 @@
"""API for xbox bound to Home Assistant OAuth."""
-from aiohttp import ClientSession
from xbox.webapi.authentication.manager import AuthenticationManager
from xbox.webapi.authentication.models import OAuth2TokenResponse
+from xbox.webapi.common.signed_session import SignedSession
-from homeassistant.helpers import config_entry_oauth2_flow
+from homeassistant.helpers.config_entry_oauth2_flow import OAuth2Session
from homeassistant.util.dt import utc_from_timestamp
class AsyncConfigEntryAuth(AuthenticationManager):
"""Provide xbox authentication tied to an OAuth2 based config entry."""
- def __init__(
- self,
- websession: ClientSession,
- oauth_session: config_entry_oauth2_flow.OAuth2Session,
- ) -> None:
+ def __init__(self, oauth_session: OAuth2Session) -> None:
"""Initialize xbox auth."""
# Leaving out client credentials as they are handled by Home Assistant
- super().__init__(websession, "", "", "")
+ super().__init__(SignedSession(), "", "", "")
self._oauth_session = oauth_session
self.oauth = self._get_oauth_token()
diff --git a/homeassistant/components/xbox/manifest.json b/homeassistant/components/xbox/manifest.json
index 30a6c3bc700..3fc2071e66b 100644
--- a/homeassistant/components/xbox/manifest.json
+++ b/homeassistant/components/xbox/manifest.json
@@ -6,5 +6,5 @@
"dependencies": ["auth", "application_credentials"],
"documentation": "https://www.home-assistant.io/integrations/xbox",
"iot_class": "cloud_polling",
- "requirements": ["xbox-webapi==2.0.11"]
+ "requirements": ["xbox-webapi==2.1.0"]
}
diff --git a/homeassistant/components/xeoma/manifest.json b/homeassistant/components/xeoma/manifest.json
index d66177ca214..839724cc781 100644
--- a/homeassistant/components/xeoma/manifest.json
+++ b/homeassistant/components/xeoma/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/xeoma",
"iot_class": "local_polling",
"loggers": ["pyxeoma"],
+ "quality_scale": "legacy",
"requirements": ["pyxeoma==1.4.2"]
}
diff --git a/homeassistant/components/xiaomi/manifest.json b/homeassistant/components/xiaomi/manifest.json
index ef7085f2aa4..45540db47f3 100644
--- a/homeassistant/components/xiaomi/manifest.json
+++ b/homeassistant/components/xiaomi/manifest.json
@@ -4,5 +4,6 @@
"codeowners": [],
"dependencies": ["ffmpeg"],
"documentation": "https://www.home-assistant.io/integrations/xiaomi",
- "iot_class": "local_polling"
+ "iot_class": "local_polling",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/xiaomi_aqara/strings.json b/homeassistant/components/xiaomi_aqara/strings.json
index a77b78c5a09..75b4ab1ecda 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"
}
},
@@ -28,9 +28,9 @@
"error": {
"discovery_error": "Failed to discover a Xiaomi Aqara Gateway, try using the IP of the device running HomeAssistant 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%]",
diff --git a/homeassistant/components/xiaomi_miio/fan.py b/homeassistant/components/xiaomi_miio/fan.py
index 81ca38eb053..e1de3f56252 100644
--- a/homeassistant/components/xiaomi_miio/fan.py
+++ b/homeassistant/components/xiaomi_miio/fan.py
@@ -300,7 +300,6 @@ class XiaomiGenericDevice(XiaomiCoordinatedMiioEntity, FanEntity):
"""Representation of a generic Xiaomi device."""
_attr_name = None
- _enable_turn_on_off_backwards_compatibility = False
def __init__(self, device, entry, unique_id, coordinator):
"""Initialize the generic Xiaomi device."""
diff --git a/homeassistant/components/xiaomi_miio/light.py b/homeassistant/components/xiaomi_miio/light.py
index 8ccc798a2e1..3f1f8b926b3 100644
--- a/homeassistant/components/xiaomi_miio/light.py
+++ b/homeassistant/components/xiaomi_miio/light.py
@@ -28,7 +28,7 @@ import voluptuous as vol
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
- ATTR_COLOR_TEMP,
+ ATTR_COLOR_TEMP_KELVIN,
ATTR_HS_COLOR,
ColorMode,
LightEntity,
@@ -45,7 +45,7 @@ from homeassistant.core import HomeAssistant, ServiceCall
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from homeassistant.util import color, dt as dt_util
+from homeassistant.util import color as color_util, dt as dt_util
from .const import (
CONF_FLOW_TYPE,
@@ -430,33 +430,54 @@ class XiaomiPhilipsBulb(XiaomiPhilipsGenericLight):
self._color_temp = None
@property
- def color_temp(self):
+ def _current_mireds(self):
"""Return the color temperature."""
return self._color_temp
@property
- def min_mireds(self):
+ def _min_mireds(self):
"""Return the coldest color_temp that this light supports."""
return 175
@property
- def max_mireds(self):
+ def _max_mireds(self):
"""Return the warmest color_temp that this light supports."""
return 333
+ @property
+ def color_temp_kelvin(self) -> int | None:
+ """Return the color temperature value in Kelvin."""
+ return (
+ color_util.color_temperature_mired_to_kelvin(self._color_temp)
+ if self._color_temp
+ else None
+ )
+
+ @property
+ def min_color_temp_kelvin(self) -> int:
+ """Return the warmest color_temp_kelvin that this light supports."""
+ return color_util.color_temperature_mired_to_kelvin(self._max_mireds)
+
+ @property
+ def max_color_temp_kelvin(self) -> int:
+ """Return the coldest color_temp_kelvin that this light supports."""
+ return color_util.color_temperature_mired_to_kelvin(self._min_mireds)
+
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the light on."""
- if ATTR_COLOR_TEMP in kwargs:
- color_temp = kwargs[ATTR_COLOR_TEMP]
+ if ATTR_COLOR_TEMP_KELVIN in kwargs:
+ color_temp = color_util.color_temperature_kelvin_to_mired(
+ kwargs[ATTR_COLOR_TEMP_KELVIN]
+ )
percent_color_temp = self.translate(
- color_temp, self.max_mireds, self.min_mireds, CCT_MIN, CCT_MAX
+ color_temp, self._max_mireds, self._min_mireds, CCT_MIN, CCT_MAX
)
if ATTR_BRIGHTNESS in kwargs:
brightness = kwargs[ATTR_BRIGHTNESS]
percent_brightness = ceil(100 * brightness / 255.0)
- if ATTR_BRIGHTNESS in kwargs and ATTR_COLOR_TEMP in kwargs:
+ if ATTR_BRIGHTNESS in kwargs and ATTR_COLOR_TEMP_KELVIN in kwargs:
_LOGGER.debug(
"Setting brightness and color temperature: %s %s%%, %s mireds, %s%% cct",
brightness,
@@ -476,7 +497,7 @@ class XiaomiPhilipsBulb(XiaomiPhilipsGenericLight):
self._color_temp = color_temp
self._brightness = brightness
- elif ATTR_COLOR_TEMP in kwargs:
+ elif ATTR_COLOR_TEMP_KELVIN in kwargs:
_LOGGER.debug(
"Setting color temperature: %s mireds, %s%% cct",
color_temp,
@@ -526,7 +547,11 @@ class XiaomiPhilipsBulb(XiaomiPhilipsGenericLight):
self._state = state.is_on
self._brightness = ceil((255 / 100.0) * state.brightness)
self._color_temp = self.translate(
- state.color_temperature, CCT_MIN, CCT_MAX, self.max_mireds, self.min_mireds
+ state.color_temperature,
+ CCT_MIN,
+ CCT_MAX,
+ self._max_mireds,
+ self._min_mireds,
)
delayed_turn_off = self.delayed_turn_off_timestamp(
@@ -560,12 +585,12 @@ class XiaomiPhilipsCeilingLamp(XiaomiPhilipsBulb):
)
@property
- def min_mireds(self):
+ def _min_mireds(self):
"""Return the coldest color_temp that this light supports."""
return 175
@property
- def max_mireds(self):
+ def _max_mireds(self):
"""Return the warmest color_temp that this light supports."""
return 370
@@ -585,7 +610,11 @@ class XiaomiPhilipsCeilingLamp(XiaomiPhilipsBulb):
self._state = state.is_on
self._brightness = ceil((255 / 100.0) * state.brightness)
self._color_temp = self.translate(
- state.color_temperature, CCT_MIN, CCT_MAX, self.max_mireds, self.min_mireds
+ state.color_temperature,
+ CCT_MIN,
+ CCT_MAX,
+ self._max_mireds,
+ self._min_mireds,
)
delayed_turn_off = self.delayed_turn_off_timestamp(
@@ -797,12 +826,12 @@ class XiaomiPhilipsMoonlightLamp(XiaomiPhilipsBulb):
)
@property
- def min_mireds(self):
+ def _min_mireds(self):
"""Return the coldest color_temp that this light supports."""
return 153
@property
- def max_mireds(self):
+ def _max_mireds(self):
"""Return the warmest color_temp that this light supports."""
return 588
@@ -820,10 +849,12 @@ class XiaomiPhilipsMoonlightLamp(XiaomiPhilipsBulb):
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the light on."""
- if ATTR_COLOR_TEMP in kwargs:
- color_temp = kwargs[ATTR_COLOR_TEMP]
+ if ATTR_COLOR_TEMP_KELVIN in kwargs:
+ color_temp = color_util.color_temperature_kelvin_to_mired(
+ kwargs[ATTR_COLOR_TEMP_KELVIN]
+ )
percent_color_temp = self.translate(
- color_temp, self.max_mireds, self.min_mireds, CCT_MIN, CCT_MAX
+ color_temp, self._max_mireds, self._min_mireds, CCT_MIN, CCT_MAX
)
if ATTR_BRIGHTNESS in kwargs:
@@ -832,7 +863,7 @@ class XiaomiPhilipsMoonlightLamp(XiaomiPhilipsBulb):
if ATTR_HS_COLOR in kwargs:
hs_color = kwargs[ATTR_HS_COLOR]
- rgb = color.color_hs_to_RGB(*hs_color)
+ rgb = color_util.color_hs_to_RGB(*hs_color)
if ATTR_BRIGHTNESS in kwargs and ATTR_HS_COLOR in kwargs:
_LOGGER.debug(
@@ -853,7 +884,7 @@ class XiaomiPhilipsMoonlightLamp(XiaomiPhilipsBulb):
self._hs_color = hs_color
self._brightness = brightness
- elif ATTR_BRIGHTNESS in kwargs and ATTR_COLOR_TEMP in kwargs:
+ elif ATTR_BRIGHTNESS in kwargs and ATTR_COLOR_TEMP_KELVIN in kwargs:
_LOGGER.debug(
(
"Setting brightness and color temperature: "
@@ -886,7 +917,7 @@ class XiaomiPhilipsMoonlightLamp(XiaomiPhilipsBulb):
if result:
self._hs_color = hs_color
- elif ATTR_COLOR_TEMP in kwargs:
+ elif ATTR_COLOR_TEMP_KELVIN in kwargs:
_LOGGER.debug(
"Setting color temperature: %s mireds, %s%% cct",
color_temp,
@@ -936,9 +967,13 @@ class XiaomiPhilipsMoonlightLamp(XiaomiPhilipsBulb):
self._state = state.is_on
self._brightness = ceil((255 / 100.0) * state.brightness)
self._color_temp = self.translate(
- state.color_temperature, CCT_MIN, CCT_MAX, self.max_mireds, self.min_mireds
+ state.color_temperature,
+ CCT_MIN,
+ CCT_MAX,
+ self._max_mireds,
+ self._min_mireds,
)
- self._hs_color = color.color_RGB_to_hs(*state.rgb)
+ self._hs_color = color_util.color_RGB_to_hs(*state.rgb)
self._state_attrs.update(
{
@@ -1014,7 +1049,7 @@ class XiaomiGatewayLight(LightEntity):
def turn_on(self, **kwargs: Any) -> None:
"""Turn the light on."""
if ATTR_HS_COLOR in kwargs:
- rgb = color.color_hs_to_RGB(*kwargs[ATTR_HS_COLOR])
+ rgb = color_util.color_hs_to_RGB(*kwargs[ATTR_HS_COLOR])
else:
rgb = self._rgb
@@ -1052,7 +1087,7 @@ class XiaomiGatewayLight(LightEntity):
if self._is_on:
self._brightness_pct = state_dict["brightness"]
self._rgb = state_dict["rgb"]
- self._hs = color.color_RGB_to_hs(*self._rgb)
+ self._hs = color_util.color_RGB_to_hs(*self._rgb)
class XiaomiGatewayBulb(XiaomiGatewayDevice, LightEntity):
@@ -1067,7 +1102,7 @@ class XiaomiGatewayBulb(XiaomiGatewayDevice, LightEntity):
return round((self._sub_device.status["brightness"] * 255) / 100)
@property
- def color_temp(self):
+ def _current_mireds(self):
"""Return current color temperature."""
return self._sub_device.status["color_temp"]
@@ -1077,12 +1112,12 @@ class XiaomiGatewayBulb(XiaomiGatewayDevice, LightEntity):
return self._sub_device.status["status"] == "on"
@property
- def min_mireds(self):
+ def _min_mireds(self):
"""Return min cct."""
return self._sub_device.status["cct_min"]
@property
- def max_mireds(self):
+ def _max_mireds(self):
"""Return max cct."""
return self._sub_device.status["cct_max"]
@@ -1090,8 +1125,10 @@ class XiaomiGatewayBulb(XiaomiGatewayDevice, LightEntity):
"""Instruct the light to turn on."""
await self.hass.async_add_executor_job(self._sub_device.on)
- if ATTR_COLOR_TEMP in kwargs:
- color_temp = kwargs[ATTR_COLOR_TEMP]
+ if ATTR_COLOR_TEMP_KELVIN in kwargs:
+ color_temp = color_util.color_temperature_kelvin_to_mired(
+ kwargs[ATTR_COLOR_TEMP_KELVIN]
+ )
await self.hass.async_add_executor_job(
self._sub_device.set_color_temp, color_temp
)
diff --git a/homeassistant/components/xiaomi_miio/sensor.py b/homeassistant/components/xiaomi_miio/sensor.py
index 3f6f4e9b50b..aafcba97487 100644
--- a/homeassistant/components/xiaomi_miio/sensor.py
+++ b/homeassistant/components/xiaomi_miio/sensor.py
@@ -24,7 +24,6 @@ from homeassistant.components.sensor import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
- AREA_SQUARE_METERS,
ATTR_BATTERY_LEVEL,
ATTR_TEMPERATURE,
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
@@ -37,6 +36,7 @@ from homeassistant.const import (
PERCENTAGE,
REVOLUTIONS_PER_MINUTE,
EntityCategory,
+ UnitOfArea,
UnitOfPower,
UnitOfPressure,
UnitOfTemperature,
@@ -622,7 +622,7 @@ VACUUM_SENSORS = {
entity_category=EntityCategory.DIAGNOSTIC,
),
f"last_clean_{ATTR_LAST_CLEAN_AREA}": XiaomiMiioSensorDescription(
- native_unit_of_measurement=AREA_SQUARE_METERS,
+ native_unit_of_measurement=UnitOfArea.SQUARE_METERS,
icon="mdi:texture-box",
key=ATTR_LAST_CLEAN_AREA,
parent_key=VacuumCoordinatorDataAttributes.last_clean_details,
@@ -639,7 +639,7 @@ VACUUM_SENSORS = {
entity_category=EntityCategory.DIAGNOSTIC,
),
f"current_{ATTR_LAST_CLEAN_AREA}": XiaomiMiioSensorDescription(
- native_unit_of_measurement=AREA_SQUARE_METERS,
+ native_unit_of_measurement=UnitOfArea.SQUARE_METERS,
icon="mdi:texture-box",
key=ATTR_STATUS_CLEAN_AREA,
parent_key=VacuumCoordinatorDataAttributes.status,
@@ -657,7 +657,7 @@ VACUUM_SENSORS = {
entity_category=EntityCategory.DIAGNOSTIC,
),
f"clean_history_{ATTR_CLEAN_HISTORY_TOTAL_AREA}": XiaomiMiioSensorDescription(
- native_unit_of_measurement=AREA_SQUARE_METERS,
+ native_unit_of_measurement=UnitOfArea.SQUARE_METERS,
icon="mdi:texture-box",
key=ATTR_CLEAN_HISTORY_TOTAL_AREA,
parent_key=VacuumCoordinatorDataAttributes.clean_history_status,
diff --git a/homeassistant/components/xiaomi_miio/strings.json b/homeassistant/components/xiaomi_miio/strings.json
index 31fe547b162..bafc1ec543b 100644
--- a/homeassistant/components/xiaomi_miio/strings.json
+++ b/homeassistant/components/xiaomi_miio/strings.json
@@ -216,22 +216,22 @@
"name": "Air quality index"
},
"filter_life_remaining": {
- "name": "Filter lifetime remaining"
+ "name": "Filter life remaining"
},
"filter_hours_used": {
"name": "Filter use"
},
"filter_left_time": {
- "name": "Filter lifetime left"
+ "name": "Filter lifetime remaining"
},
"dust_filter_life_remaining": {
- "name": "Dust filter lifetime remaining"
+ "name": "Dust filter life remaining"
},
"dust_filter_life_remaining_days": {
"name": "Dust filter lifetime remaining days"
},
"upper_filter_life_remaining": {
- "name": "Upper filter lifetime remaining"
+ "name": "Upper filter life remaining"
},
"upper_filter_life_remaining_days": {
"name": "Upper filter lifetime remaining days"
@@ -276,16 +276,16 @@
"name": "Total dust collection count"
},
"main_brush_left": {
- "name": "Main brush left"
+ "name": "Main brush remaining"
},
"side_brush_left": {
- "name": "Side brush left"
+ "name": "Side brush remaining"
},
"filter_left": {
- "name": "Filter left"
+ "name": "Filter remaining"
},
"sensor_dirty_left": {
- "name": "Sensor dirty left"
+ "name": "Sensor dirty remaining"
}
},
"switch": {
diff --git a/homeassistant/components/xiaomi_miio/vacuum.py b/homeassistant/components/xiaomi_miio/vacuum.py
index b720cc90d2c..532eb9581cd 100644
--- a/homeassistant/components/xiaomi_miio/vacuum.py
+++ b/homeassistant/components/xiaomi_miio/vacuum.py
@@ -10,13 +10,8 @@ from miio import DeviceException
import voluptuous as vol
from homeassistant.components.vacuum import (
- STATE_CLEANING,
- STATE_DOCKED,
- STATE_ERROR,
- STATE_IDLE,
- STATE_PAUSED,
- STATE_RETURNING,
StateVacuumEntity,
+ VacuumActivity,
VacuumEntityFeature,
)
from homeassistant.config_entries import ConfigEntry
@@ -55,29 +50,29 @@ ATTR_ZONE_REPEATER = "repeats"
ATTR_TIMERS = "timers"
STATE_CODE_TO_STATE = {
- 1: STATE_IDLE, # "Starting"
- 2: STATE_IDLE, # "Charger disconnected"
- 3: STATE_IDLE, # "Idle"
- 4: STATE_CLEANING, # "Remote control active"
- 5: STATE_CLEANING, # "Cleaning"
- 6: STATE_RETURNING, # "Returning home"
- 7: STATE_CLEANING, # "Manual mode"
- 8: STATE_DOCKED, # "Charging"
- 9: STATE_ERROR, # "Charging problem"
- 10: STATE_PAUSED, # "Paused"
- 11: STATE_CLEANING, # "Spot cleaning"
- 12: STATE_ERROR, # "Error"
- 13: STATE_IDLE, # "Shutting down"
- 14: STATE_DOCKED, # "Updating"
- 15: STATE_RETURNING, # "Docking"
- 16: STATE_CLEANING, # "Going to target"
- 17: STATE_CLEANING, # "Zoned cleaning"
- 18: STATE_CLEANING, # "Segment cleaning"
- 22: STATE_DOCKED, # "Emptying the bin" on s7+
- 23: STATE_DOCKED, # "Washing the mop" on s7maxV
- 26: STATE_RETURNING, # "Going to wash the mop" on s7maxV
- 100: STATE_DOCKED, # "Charging complete"
- 101: STATE_ERROR, # "Device offline"
+ 1: VacuumActivity.IDLE, # "Starting"
+ 2: VacuumActivity.IDLE, # "Charger disconnected"
+ 3: VacuumActivity.IDLE, # "Idle"
+ 4: VacuumActivity.CLEANING, # "Remote control active"
+ 5: VacuumActivity.CLEANING, # "Cleaning"
+ 6: VacuumActivity.RETURNING, # "Returning home"
+ 7: VacuumActivity.CLEANING, # "Manual mode"
+ 8: VacuumActivity.DOCKED, # "Charging"
+ 9: VacuumActivity.ERROR, # "Charging problem"
+ 10: VacuumActivity.PAUSED, # "Paused"
+ 11: VacuumActivity.CLEANING, # "Spot cleaning"
+ 12: VacuumActivity.ERROR, # "Error"
+ 13: VacuumActivity.IDLE, # "Shutting down"
+ 14: VacuumActivity.DOCKED, # "Updating"
+ 15: VacuumActivity.RETURNING, # "Docking"
+ 16: VacuumActivity.CLEANING, # "Going to target"
+ 17: VacuumActivity.CLEANING, # "Zoned cleaning"
+ 18: VacuumActivity.CLEANING, # "Segment cleaning"
+ 22: VacuumActivity.DOCKED, # "Emptying the bin" on s7+
+ 23: VacuumActivity.DOCKED, # "Washing the mop" on s7maxV
+ 26: VacuumActivity.RETURNING, # "Going to wash the mop" on s7maxV
+ 100: VacuumActivity.DOCKED, # "Charging complete"
+ 101: VacuumActivity.ERROR, # "Device offline"
}
@@ -211,7 +206,7 @@ class MiroboVacuum(
) -> None:
"""Initialize the Xiaomi vacuum cleaner robot handler."""
super().__init__(device, entry, unique_id, coordinator)
- self._state: str | None = None
+ self._state: VacuumActivity | None = None
async def async_added_to_hass(self) -> None:
"""Run when entity is about to be added to hass."""
@@ -219,12 +214,12 @@ class MiroboVacuum(
self._handle_coordinator_update()
@property
- def state(self) -> str | None:
+ def activity(self) -> VacuumActivity | None:
"""Return the status of the vacuum cleaner."""
# The vacuum reverts back to an idle state after erroring out.
# We want to keep returning an error until it has been cleared.
if self.coordinator.data.status.got_error:
- return STATE_ERROR
+ return VacuumActivity.ERROR
return self._state
diff --git a/homeassistant/components/xiaomi_tv/manifest.json b/homeassistant/components/xiaomi_tv/manifest.json
index 2e913e80fdc..8335adff333 100644
--- a/homeassistant/components/xiaomi_tv/manifest.json
+++ b/homeassistant/components/xiaomi_tv/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/xiaomi_tv",
"iot_class": "assumed_state",
"loggers": ["pymitv"],
+ "quality_scale": "legacy",
"requirements": ["pymitv==1.4.3"]
}
diff --git a/homeassistant/components/xmpp/manifest.json b/homeassistant/components/xmpp/manifest.json
index 308c3d70978..d77d70ff86c 100644
--- a/homeassistant/components/xmpp/manifest.json
+++ b/homeassistant/components/xmpp/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/xmpp",
"iot_class": "cloud_push",
"loggers": ["pyasn1", "slixmpp"],
+ "quality_scale": "legacy",
"requirements": ["slixmpp==1.8.5", "emoji==2.8.0"]
}
diff --git a/homeassistant/components/xs1/climate.py b/homeassistant/components/xs1/climate.py
index c7d580631d3..3bb80df25b2 100644
--- a/homeassistant/components/xs1/climate.py
+++ b/homeassistant/components/xs1/climate.py
@@ -56,7 +56,6 @@ class XS1ThermostatEntity(XS1DeviceEntity, ClimateEntity):
_attr_hvac_mode = HVACMode.HEAT
_attr_hvac_modes = [HVACMode.HEAT]
_attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE
- _enable_turn_on_off_backwards_compatibility = False
def __init__(self, device, sensor):
"""Initialize the actuator."""
diff --git a/homeassistant/components/xs1/manifest.json b/homeassistant/components/xs1/manifest.json
index 9f4c921642d..88a5e4427ae 100644
--- a/homeassistant/components/xs1/manifest.json
+++ b/homeassistant/components/xs1/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/xs1",
"iot_class": "local_polling",
"loggers": ["xs1_api_client"],
+ "quality_scale": "legacy",
"requirements": ["xs1-api-client==3.0.0"]
}
diff --git a/homeassistant/components/yale/manifest.json b/homeassistant/components/yale/manifest.json
index 34f3a7a1728..f1cde31d066 100644
--- a/homeassistant/components/yale/manifest.json
+++ b/homeassistant/components/yale/manifest.json
@@ -13,5 +13,5 @@
"documentation": "https://www.home-assistant.io/integrations/yale",
"iot_class": "cloud_push",
"loggers": ["socketio", "engineio", "yalexs"],
- "requirements": ["yalexs==8.10.0", "yalexs-ble==2.5.0"]
+ "requirements": ["yalexs==8.10.0", "yalexs-ble==2.5.6"]
}
diff --git a/homeassistant/components/yale_smart_alarm/__init__.py b/homeassistant/components/yale_smart_alarm/__init__.py
index c543de89b84..d67e136be4a 100644
--- a/homeassistant/components/yale_smart_alarm/__init__.py
+++ b/homeassistant/components/yale_smart_alarm/__init__.py
@@ -4,7 +4,7 @@ from __future__ import annotations
from homeassistant.components.lock import CONF_DEFAULT_CODE, DOMAIN as LOCK_DOMAIN
from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import CONF_CODE
+from homeassistant.const import CONF_CODE, CONF_NAME
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
@@ -27,21 +27,22 @@ async def async_setup_entry(hass: HomeAssistant, entry: YaleConfigEntry) -> bool
return True
-async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
+async def update_listener(hass: HomeAssistant, entry: YaleConfigEntry) -> None:
"""Handle 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: YaleConfigEntry) -> bool:
"""Unload a 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: YaleConfigEntry) -> bool:
"""Migrate old entry."""
LOGGER.debug("Migrating from version %s", entry.version)
if entry.version == 1:
+ new_options = entry.options.copy()
if config_entry_default_code := entry.options.get(CONF_CODE):
entity_reg = er.async_get(hass)
entries = er.async_entries_for_config_entry(entity_reg, entry.entry_id)
@@ -52,12 +53,15 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
LOCK_DOMAIN,
{CONF_DEFAULT_CODE: config_entry_default_code},
)
- new_options = entry.options.copy()
del new_options[CONF_CODE]
- hass.config_entries.async_update_entry(entry, options=new_options)
+ hass.config_entries.async_update_entry(entry, options=new_options, version=2)
- hass.config_entries.async_update_entry(entry, version=2)
+ if entry.version == 2 and entry.minor_version == 1:
+ # Removes name from entry data
+ new_data = entry.data.copy()
+ del new_data[CONF_NAME]
+ hass.config_entries.async_update_entry(entry, data=new_data, minor_version=2)
LOGGER.debug("Migration to version %s successful", entry.version)
diff --git a/homeassistant/components/yale_smart_alarm/alarm_control_panel.py b/homeassistant/components/yale_smart_alarm/alarm_control_panel.py
index 0f5b7d0b8e5..8244d96064a 100644
--- a/homeassistant/components/yale_smart_alarm/alarm_control_panel.py
+++ b/homeassistant/components/yale_smart_alarm/alarm_control_panel.py
@@ -15,7 +15,6 @@ from homeassistant.components.alarm_control_panel import (
AlarmControlPanelEntityFeature,
AlarmControlPanelState,
)
-from homeassistant.const import CONF_NAME
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddEntitiesCallback
@@ -47,7 +46,7 @@ class YaleAlarmDevice(YaleAlarmEntity, AlarmControlPanelEntity):
def __init__(self, coordinator: YaleDataUpdateCoordinator) -> None:
"""Initialize the Yale Alarm Device."""
super().__init__(coordinator)
- self._attr_unique_id = coordinator.entry.entry_id
+ self._attr_unique_id = coordinator.config_entry.entry_id
async def async_alarm_disarm(self, code: str | None = None) -> None:
"""Send disarm command."""
@@ -84,7 +83,7 @@ class YaleAlarmDevice(YaleAlarmEntity, AlarmControlPanelEntity):
translation_domain=DOMAIN,
translation_key="set_alarm",
translation_placeholders={
- "name": self.coordinator.entry.data[CONF_NAME],
+ "name": self.coordinator.config_entry.title,
"error": str(error),
},
) from error
diff --git a/homeassistant/components/yale_smart_alarm/binary_sensor.py b/homeassistant/components/yale_smart_alarm/binary_sensor.py
index 8e68b1f0cb4..17b6035321a 100644
--- a/homeassistant/components/yale_smart_alarm/binary_sensor.py
+++ b/homeassistant/components/yale_smart_alarm/binary_sensor.py
@@ -108,7 +108,9 @@ class YaleProblemSensor(YaleAlarmEntity, BinarySensorEntity):
"""Initiate Yale Problem Sensor."""
super().__init__(coordinator)
self.entity_description = entity_description
- self._attr_unique_id = f"{coordinator.entry.entry_id}-{entity_description.key}"
+ self._attr_unique_id = (
+ f"{coordinator.config_entry.entry_id}-{entity_description.key}"
+ )
@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 c71b7b33a08..3ceee367284 100644
--- a/homeassistant/components/yale_smart_alarm/config_flow.py
+++ b/homeassistant/components/yale_smart_alarm/config_flow.py
@@ -15,7 +15,7 @@ from homeassistant.config_entries import (
ConfigFlowResult,
OptionsFlow,
)
-from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_USERNAME
+from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import callback
import homeassistant.helpers.config_validation as cv
@@ -23,7 +23,6 @@ from .const import (
CONF_AREA_ID,
CONF_LOCK_CODE_DIGITS,
DEFAULT_AREA_ID,
- DEFAULT_NAME,
DOMAIN,
YALE_BASE_ERRORS,
)
@@ -67,6 +66,7 @@ class YaleConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Yale integration."""
VERSION = 2
+ MINOR_VERSION = 2
@staticmethod
@callback
@@ -146,7 +146,6 @@ class YaleConfigFlow(ConfigFlow, domain=DOMAIN):
if user_input is not None:
username = user_input[CONF_USERNAME]
password = user_input[CONF_PASSWORD]
- name = DEFAULT_NAME
area = user_input.get(CONF_AREA_ID, DEFAULT_AREA_ID)
errors = await self.hass.async_add_executor_job(
@@ -161,7 +160,6 @@ class YaleConfigFlow(ConfigFlow, domain=DOMAIN):
data={
CONF_USERNAME: username,
CONF_PASSWORD: password,
- CONF_NAME: name,
CONF_AREA_ID: area,
},
)
diff --git a/homeassistant/components/yale_smart_alarm/coordinator.py b/homeassistant/components/yale_smart_alarm/coordinator.py
index 66bd71c9f1e..7ece2a3448b 100644
--- a/homeassistant/components/yale_smart_alarm/coordinator.py
+++ b/homeassistant/components/yale_smart_alarm/coordinator.py
@@ -9,12 +9,14 @@ from yalesmartalarmclient import YaleLock
from yalesmartalarmclient.client import YaleSmartAlarmClient
from yalesmartalarmclient.exceptions import AuthenticationError
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
+if TYPE_CHECKING:
+ from . import YaleConfigEntry
+
from .const import DEFAULT_SCAN_INTERVAL, DOMAIN, LOGGER, YALE_BASE_ERRORS
@@ -22,13 +24,14 @@ class YaleDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"""A Yale Data Update Coordinator."""
yale: YaleSmartAlarmClient
+ config_entry: YaleConfigEntry
- def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
+ def __init__(self, hass: HomeAssistant, config_entry: YaleConfigEntry) -> None:
"""Initialize the Yale hub."""
- self.entry = entry
super().__init__(
hass,
LOGGER,
+ config_entry=config_entry,
name=DOMAIN,
update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL),
always_update=False,
@@ -40,8 +43,8 @@ class YaleDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
try:
self.yale = await self.hass.async_add_executor_job(
YaleSmartAlarmClient,
- self.entry.data[CONF_USERNAME],
- self.entry.data[CONF_PASSWORD],
+ self.config_entry.data[CONF_USERNAME],
+ self.config_entry.data[CONF_PASSWORD],
)
self.locks = await self.hass.async_add_executor_job(self.yale.get_locks)
except AuthenticationError as error:
diff --git a/homeassistant/components/yale_smart_alarm/entity.py b/homeassistant/components/yale_smart_alarm/entity.py
index e37dc3562f5..2610f54f0a9 100644
--- a/homeassistant/components/yale_smart_alarm/entity.py
+++ b/homeassistant/components/yale_smart_alarm/entity.py
@@ -2,7 +2,7 @@
from yalesmartalarmclient import YaleLock
-from homeassistant.const import CONF_NAME, CONF_USERNAME
+from homeassistant.const import CONF_USERNAME
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -25,7 +25,7 @@ class YaleEntity(CoordinatorEntity[YaleDataUpdateCoordinator]):
manufacturer=MANUFACTURER,
model=MODEL,
identifiers={(DOMAIN, data["address"])},
- via_device=(DOMAIN, coordinator.entry.data[CONF_USERNAME]),
+ via_device=(DOMAIN, coordinator.config_entry.data[CONF_USERNAME]),
)
@@ -43,7 +43,7 @@ class YaleLockEntity(CoordinatorEntity[YaleDataUpdateCoordinator]):
manufacturer=MANUFACTURER,
model=MODEL,
identifiers={(DOMAIN, lock.sid())},
- via_device=(DOMAIN, coordinator.entry.data[CONF_USERNAME]),
+ via_device=(DOMAIN, coordinator.config_entry.data[CONF_USERNAME]),
)
self.lock_data = lock
@@ -58,10 +58,10 @@ class YaleAlarmEntity(CoordinatorEntity[YaleDataUpdateCoordinator], Entity):
super().__init__(coordinator)
panel_info = coordinator.data["panel_info"]
self._attr_device_info = DeviceInfo(
- identifiers={(DOMAIN, coordinator.entry.data[CONF_USERNAME])},
+ identifiers={(DOMAIN, coordinator.config_entry.data[CONF_USERNAME])},
manufacturer=MANUFACTURER,
model=MODEL,
- name=coordinator.entry.data[CONF_NAME],
+ name=coordinator.config_entry.title,
connections={(CONNECTION_NETWORK_MAC, panel_info["mac"])},
sw_version=panel_info["version"],
)
diff --git a/homeassistant/components/yale_smart_alarm/lock.py b/homeassistant/components/yale_smart_alarm/lock.py
index 243299658ed..7a93baf0827 100644
--- a/homeassistant/components/yale_smart_alarm/lock.py
+++ b/homeassistant/components/yale_smart_alarm/lock.py
@@ -9,7 +9,7 @@ from yalesmartalarmclient import YaleLock, YaleLockState
from homeassistant.components.lock import LockEntity, LockState
from homeassistant.const import ATTR_CODE
from homeassistant.core import HomeAssistant
-from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
+from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import YaleConfigEntry
@@ -65,12 +65,6 @@ class YaleDoorlock(YaleLockEntity, LockEntity):
async def async_set_lock(self, state: YaleLockState, code: str | None) -> None:
"""Set lock."""
- if state is YaleLockState.UNLOCKED and not code:
- raise ServiceValidationError(
- translation_domain=DOMAIN,
- translation_key="no_code",
- )
-
lock_state = False
try:
if state is YaleLockState.LOCKED:
diff --git a/homeassistant/components/yale_smart_alarm/strings.json b/homeassistant/components/yale_smart_alarm/strings.json
index 7f940e1139e..ebcf0b3af63 100644
--- a/homeassistant/components/yale_smart_alarm/strings.json
+++ b/homeassistant/components/yale_smart_alarm/strings.json
@@ -88,14 +88,11 @@
"set_lock": {
"message": "Could not set lock for {name}: {error}"
},
- "no_code": {
- "message": "Can not unlock without code"
- },
"could_not_change_lock": {
"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/manifest.json b/homeassistant/components/yalexs_ble/manifest.json
index 1baeaeea63f..15b11719fdb 100644
--- a/homeassistant/components/yalexs_ble/manifest.json
+++ b/homeassistant/components/yalexs_ble/manifest.json
@@ -12,5 +12,5 @@
"dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/yalexs_ble",
"iot_class": "local_push",
- "requirements": ["yalexs-ble==2.5.0"]
+ "requirements": ["yalexs-ble==2.5.6"]
}
diff --git a/homeassistant/components/yamaha/manifest.json b/homeassistant/components/yamaha/manifest.json
index 8e6ba0b8854..936028330a5 100644
--- a/homeassistant/components/yamaha/manifest.json
+++ b/homeassistant/components/yamaha/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/yamaha",
"iot_class": "local_polling",
"loggers": ["rxv"],
+ "quality_scale": "legacy",
"requirements": ["rxv==0.7.0"]
}
diff --git a/homeassistant/components/yamaha_musiccast/config_flow.py b/homeassistant/components/yamaha_musiccast/config_flow.py
index a074f34c782..d6ad54c4a3d 100644
--- a/homeassistant/components/yamaha_musiccast/config_flow.py
+++ b/homeassistant/components/yamaha_musiccast/config_flow.py
@@ -10,9 +10,8 @@ from aiohttp import ClientConnectorError
from aiomusiccast import MusicCastConnectionException, MusicCastDevice
import voluptuous as vol
-from homeassistant import data_entry_flow
from homeassistant.components import ssdp
-from homeassistant.config_entries import ConfigFlow
+from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST
from homeassistant.helpers.aiohttp_client import async_get_clientsession
@@ -33,7 +32,7 @@ class MusicCastFlowHandler(ConfigFlow, domain=DOMAIN):
async def async_step_user(
self, user_input: dict[str, Any] | None = None
- ) -> data_entry_flow.ConfigFlowResult:
+ ) -> ConfigFlowResult:
"""Handle a flow initiated by the user."""
# Request user input, unless we are preparing discovery flow
if user_input is None:
@@ -73,9 +72,7 @@ class MusicCastFlowHandler(ConfigFlow, domain=DOMAIN):
return self._show_setup_form(errors)
- def _show_setup_form(
- self, errors: dict | None = None
- ) -> data_entry_flow.ConfigFlowResult:
+ def _show_setup_form(self, errors: dict | None = None) -> ConfigFlowResult:
"""Show the setup form to the user."""
return self.async_show_form(
step_id="user",
@@ -85,7 +82,7 @@ class MusicCastFlowHandler(ConfigFlow, domain=DOMAIN):
async def async_step_ssdp(
self, discovery_info: ssdp.SsdpServiceInfo
- ) -> data_entry_flow.ConfigFlowResult:
+ ) -> ConfigFlowResult:
"""Handle ssdp discoveries."""
if not await MusicCastDevice.check_yamaha_ssdp(
discovery_info.ssdp_location, async_get_clientsession(self.hass)
@@ -117,9 +114,7 @@ class MusicCastFlowHandler(ConfigFlow, domain=DOMAIN):
return await self.async_step_confirm()
- async def async_step_confirm(
- self, user_input=None
- ) -> data_entry_flow.ConfigFlowResult:
+ async def async_step_confirm(self, user_input=None) -> ConfigFlowResult:
"""Allow the user to confirm adding the device."""
if user_input is not None:
return self.async_create_entry(
diff --git a/homeassistant/components/yandex_transport/manifest.json b/homeassistant/components/yandex_transport/manifest.json
index 1d1219d5a95..ad31d495253 100644
--- a/homeassistant/components/yandex_transport/manifest.json
+++ b/homeassistant/components/yandex_transport/manifest.json
@@ -4,5 +4,6 @@
"codeowners": ["@rishatik92", "@devbis"],
"documentation": "https://www.home-assistant.io/integrations/yandex_transport",
"iot_class": "cloud_polling",
+ "quality_scale": "legacy",
"requirements": ["aioymaps==1.2.5"]
}
diff --git a/homeassistant/components/yandextts/manifest.json b/homeassistant/components/yandextts/manifest.json
index e1ab27272ef..418516a2d09 100644
--- a/homeassistant/components/yandextts/manifest.json
+++ b/homeassistant/components/yandextts/manifest.json
@@ -3,5 +3,6 @@
"name": "Yandex TTS",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/yandextts",
- "iot_class": "cloud_push"
+ "iot_class": "cloud_push",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/yeelight/light.py b/homeassistant/components/yeelight/light.py
index d0d53510859..8cc3f2600e5 100644
--- a/homeassistant/components/yeelight/light.py
+++ b/homeassistant/components/yeelight/light.py
@@ -16,11 +16,10 @@ from yeelight.main import BulbException
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
- ATTR_COLOR_TEMP,
+ ATTR_COLOR_TEMP_KELVIN,
ATTR_EFFECT,
ATTR_FLASH,
ATTR_HS_COLOR,
- ATTR_KELVIN,
ATTR_RGB_COLOR,
ATTR_TRANSITION,
FLASH_LONG,
@@ -40,10 +39,6 @@ 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.color import (
- color_temperature_kelvin_to_mired as kelvin_to_mired,
- color_temperature_mired_to_kelvin as mired_to_kelvin,
-)
from . import YEELIGHT_FLOW_TRANSITION_SCHEMA
from .const import (
@@ -71,6 +66,7 @@ from .entity import YeelightEntity
_LOGGER = logging.getLogger(__name__)
ATTR_MINUTES = "minutes"
+ATTR_KELVIN = "kelvin"
SERVICE_SET_MODE = "set_mode"
SERVICE_SET_MUSIC_MODE = "set_music_mode"
@@ -440,8 +436,8 @@ class YeelightBaseLight(YeelightEntity, LightEntity):
self._effect = None
model_specs = self._bulb.get_model_specs()
- self._attr_min_mireds = kelvin_to_mired(model_specs["color_temp"]["max"])
- self._attr_max_mireds = kelvin_to_mired(model_specs["color_temp"]["min"])
+ self._attr_max_color_temp_kelvin = model_specs["color_temp"]["max"]
+ self._attr_min_color_temp_kelvin = model_specs["color_temp"]["min"]
self._light_type = LightType.Main
@@ -476,10 +472,10 @@ class YeelightBaseLight(YeelightEntity, LightEntity):
return self._predefined_effects + self.custom_effects_names
@property
- def color_temp(self) -> int | None:
- """Return the color temperature."""
+ def color_temp_kelvin(self) -> int | None:
+ """Return the color temperature value in Kelvin."""
if temp_in_k := self._get_property("ct"):
- self._color_temp = kelvin_to_mired(int(temp_in_k))
+ self._color_temp = int(temp_in_k)
return self._color_temp
@property
@@ -678,20 +674,19 @@ class YeelightBaseLight(YeelightEntity, LightEntity):
)
@_async_cmd
- async def async_set_colortemp(self, colortemp, duration) -> None:
+ async def async_set_colortemp(self, temp_in_k, duration) -> None:
"""Set bulb's color temperature."""
if (
- not colortemp
+ not temp_in_k
or not self.supported_color_modes
or ColorMode.COLOR_TEMP not in self.supported_color_modes
):
return
- temp_in_k = mired_to_kelvin(colortemp)
if (
not self.device.is_color_flow_enabled
and self.color_mode == ColorMode.COLOR_TEMP
- and self.color_temp == colortemp
+ and self.color_temp_kelvin == temp_in_k
):
_LOGGER.debug("Color temp already set to: %s", temp_in_k)
# Already set, and since we get pushed updates
@@ -779,7 +774,7 @@ class YeelightBaseLight(YeelightEntity, LightEntity):
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the bulb on."""
brightness = kwargs.get(ATTR_BRIGHTNESS)
- colortemp = kwargs.get(ATTR_COLOR_TEMP)
+ colortemp = kwargs.get(ATTR_COLOR_TEMP_KELVIN)
hs_color = kwargs.get(ATTR_HS_COLOR)
rgb = kwargs.get(ATTR_RGB_COLOR)
flash = kwargs.get(ATTR_FLASH)
@@ -933,12 +928,12 @@ class YeelightWithoutNightlightSwitchMixIn(YeelightBaseLight):
return super()._brightness_property
@property
- def color_temp(self) -> int | None:
- """Return the color temperature."""
+ def color_temp_kelvin(self) -> int | None:
+ """Return the color temperature value in Kelvin."""
if self.device.is_nightlight_enabled:
# Enabling the nightlight locks the colortemp to max
- return self.max_mireds
- return super().color_temp
+ return self.min_color_temp_kelvin
+ return super().color_temp_kelvin
class YeelightColorLightWithoutNightlightSwitch(
@@ -1081,8 +1076,8 @@ class YeelightAmbientLight(YeelightColorLightWithoutNightlightSwitch):
def __init__(self, *args, **kwargs):
"""Initialize the Yeelight Ambient light."""
super().__init__(*args, **kwargs)
- self._attr_min_mireds = kelvin_to_mired(6500)
- self._attr_max_mireds = kelvin_to_mired(1700)
+ self._attr_max_color_temp_kelvin = 6500
+ self._attr_min_color_temp_kelvin = 1700
self._light_type = LightType.Ambient
diff --git a/homeassistant/components/yeelight/manifest.json b/homeassistant/components/yeelight/manifest.json
index 8d0a2e31185..eba970dc2db 100644
--- a/homeassistant/components/yeelight/manifest.json
+++ b/homeassistant/components/yeelight/manifest.json
@@ -16,8 +16,7 @@
},
"iot_class": "local_push",
"loggers": ["async_upnp_client", "yeelight"],
- "quality_scale": "platinum",
- "requirements": ["yeelight==0.7.14", "async-upnp-client==0.41.0"],
+ "requirements": ["yeelight==0.7.14", "async-upnp-client==0.42.0"],
"zeroconf": [
{
"type": "_miio._udp.local.",
diff --git a/homeassistant/components/yeelight/scanner.py b/homeassistant/components/yeelight/scanner.py
index ac482504880..7e908396ff3 100644
--- a/homeassistant/components/yeelight/scanner.py
+++ b/homeassistant/components/yeelight/scanner.py
@@ -9,7 +9,7 @@ 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
@@ -44,11 +44,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)
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/manifest.json b/homeassistant/components/yeelightsunflower/manifest.json
index 67746e122cb..bfd185cfa72 100644
--- a/homeassistant/components/yeelightsunflower/manifest.json
+++ b/homeassistant/components/yeelightsunflower/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/yeelightsunflower",
"iot_class": "local_polling",
"loggers": ["yeelightsunflower"],
+ "quality_scale": "legacy",
"requirements": ["yeelightsunflower==0.0.10"]
}
diff --git a/homeassistant/components/yi/manifest.json b/homeassistant/components/yi/manifest.json
index d8514b251cc..24b5aaad758 100644
--- a/homeassistant/components/yi/manifest.json
+++ b/homeassistant/components/yi/manifest.json
@@ -7,5 +7,6 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["aioftp"],
+ "quality_scale": "legacy",
"requirements": ["aioftp==0.21.3"]
}
diff --git a/homeassistant/components/yolink/binary_sensor.py b/homeassistant/components/yolink/binary_sensor.py
index 07a1fb07cc0..fa4c2202b03 100644
--- a/homeassistant/components/yolink/binary_sensor.py
+++ b/homeassistant/components/yolink/binary_sensor.py
@@ -12,6 +12,7 @@ from yolink.const import (
ATTR_DEVICE_LEAK_SENSOR,
ATTR_DEVICE_MOTION_SENSOR,
ATTR_DEVICE_VIBRATION_SENSOR,
+ ATTR_DEVICE_WATER_METER_CONTROLLER,
)
from yolink.device import YoLinkDevice
@@ -44,6 +45,7 @@ SENSOR_DEVICE_TYPE = [
ATTR_DEVICE_LEAK_SENSOR,
ATTR_DEVICE_VIBRATION_SENSOR,
ATTR_DEVICE_CO_SMOKE_SENSOR,
+ ATTR_DEVICE_WATER_METER_CONTROLLER,
]
@@ -84,6 +86,15 @@ SENSOR_TYPES: tuple[YoLinkBinarySensorEntityDescription, ...] = (
value=lambda state: state.get("smokeAlarm"),
exists_fn=lambda device: device.device_type == ATTR_DEVICE_CO_SMOKE_SENSOR,
),
+ YoLinkBinarySensorEntityDescription(
+ key="pipe_leak_detected",
+ state_key="alarm",
+ device_class=BinarySensorDeviceClass.MOISTURE,
+ value=lambda state: state.get("leak") if state is not None else None,
+ exists_fn=lambda device: (
+ device.device_type == ATTR_DEVICE_WATER_METER_CONTROLLER
+ ),
+ ),
)
diff --git a/homeassistant/components/yolink/climate.py b/homeassistant/components/yolink/climate.py
index 98f1b764498..ff3bbf0d93b 100644
--- a/homeassistant/components/yolink/climate.py
+++ b/homeassistant/components/yolink/climate.py
@@ -63,7 +63,6 @@ class YoLinkClimateEntity(YoLinkEntity, ClimateEntity):
"""YoLink Climate Entity."""
_attr_name = None
- _enable_turn_on_off_backwards_compatibility = False
def __init__(
self,
diff --git a/homeassistant/components/zabbix/__init__.py b/homeassistant/components/zabbix/__init__.py
index d9bab3e6fe4..05881d649cf 100644
--- a/homeassistant/components/zabbix/__init__.py
+++ b/homeassistant/components/zabbix/__init__.py
@@ -11,8 +11,9 @@ import time
from urllib.error import HTTPError
from urllib.parse import urljoin
-from pyzabbix import ZabbixAPI, ZabbixAPIException, ZabbixMetric, ZabbixSender
import voluptuous as vol
+from zabbix_utils import ItemValue, Sender, ZabbixAPI
+from zabbix_utils.exceptions import APIRequestError
from homeassistant.const import (
CONF_HOST,
@@ -42,6 +43,7 @@ CONF_PUBLISH_STATES_HOST = "publish_states_host"
DEFAULT_SSL = False
DEFAULT_PATH = "zabbix"
+DEFAULT_SENDER_PORT = 10051
TIMEOUT = 5
RETRY_DELAY = 20
@@ -86,7 +88,7 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool:
try:
zapi = ZabbixAPI(url=url, user=username, password=password)
_LOGGER.debug("Connected to Zabbix API Version %s", zapi.api_version())
- except ZabbixAPIException as login_exception:
+ except APIRequestError as login_exception:
_LOGGER.error("Unable to login to the Zabbix API: %s", login_exception)
return False
except HTTPError as http_error:
@@ -104,7 +106,7 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool:
def event_to_metrics(
event: Event, float_keys: set[str], string_keys: set[str]
- ) -> list[ZabbixMetric] | None:
+ ) -> list[ItemValue] | None:
"""Add an event to the outgoing Zabbix list."""
state = event.data.get("new_state")
if state is None or state.state in (STATE_UNKNOWN, "", STATE_UNAVAILABLE):
@@ -145,14 +147,14 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool:
float_keys.update(floats)
if len(float_keys) != float_keys_count:
floats_discovery = [{"{#KEY}": float_key} for float_key in float_keys]
- metric = ZabbixMetric(
+ metric = ItemValue(
publish_states_host,
"homeassistant.floats_discovery",
json.dumps(floats_discovery),
)
metrics.append(metric)
for key, value in floats.items():
- metric = ZabbixMetric(
+ metric = ItemValue(
publish_states_host, f"homeassistant.float[{key}]", value
)
metrics.append(metric)
@@ -161,7 +163,7 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool:
return metrics
if publish_states_host:
- zabbix_sender = ZabbixSender(zabbix_server=conf[CONF_HOST])
+ zabbix_sender = Sender(server=conf[CONF_HOST], port=DEFAULT_SENDER_PORT)
instance = ZabbixThread(zabbix_sender, event_to_metrics)
instance.setup(hass)
@@ -175,10 +177,8 @@ class ZabbixThread(threading.Thread):
def __init__(
self,
- zabbix_sender: ZabbixSender,
- event_to_metrics: Callable[
- [Event, set[str], set[str]], list[ZabbixMetric] | None
- ],
+ zabbix_sender: Sender,
+ event_to_metrics: Callable[[Event, set[str], set[str]], list[ItemValue] | None],
) -> None:
"""Initialize the listener."""
threading.Thread.__init__(self, name="Zabbix")
@@ -208,12 +208,12 @@ class ZabbixThread(threading.Thread):
item = (time.monotonic(), event)
self.queue.put(item)
- def get_metrics(self) -> tuple[int, list[ZabbixMetric]]:
+ def get_metrics(self) -> tuple[int, list[ItemValue]]:
"""Return a batch of events formatted for writing."""
queue_seconds = QUEUE_BACKLOG_SECONDS + self.MAX_TRIES * RETRY_DELAY
count = 0
- metrics: list[ZabbixMetric] = []
+ metrics: list[ItemValue] = []
dropped = 0
@@ -243,7 +243,7 @@ class ZabbixThread(threading.Thread):
return count, metrics
- def write_to_zabbix(self, metrics: list[ZabbixMetric]) -> None:
+ def write_to_zabbix(self, metrics: list[ItemValue]) -> None:
"""Write preprocessed events to zabbix, with retry."""
for retry in range(self.MAX_TRIES + 1):
diff --git a/homeassistant/components/zabbix/manifest.json b/homeassistant/components/zabbix/manifest.json
index d1823051636..6707cb7ddb3 100644
--- a/homeassistant/components/zabbix/manifest.json
+++ b/homeassistant/components/zabbix/manifest.json
@@ -1,9 +1,10 @@
{
"domain": "zabbix",
"name": "Zabbix",
- "codeowners": [],
+ "codeowners": ["@kruton"],
"documentation": "https://www.home-assistant.io/integrations/zabbix",
"iot_class": "local_polling",
- "loggers": ["pyzabbix"],
- "requirements": ["py-zabbix==1.1.7"]
+ "loggers": ["zabbix_utils"],
+ "quality_scale": "legacy",
+ "requirements": ["zabbix-utils==2.0.2"]
}
diff --git a/homeassistant/components/zabbix/sensor.py b/homeassistant/components/zabbix/sensor.py
index f5d96f106cb..7728233ebc0 100644
--- a/homeassistant/components/zabbix/sensor.py
+++ b/homeassistant/components/zabbix/sensor.py
@@ -6,8 +6,8 @@ from collections.abc import Mapping
import logging
from typing import Any
-from pyzabbix import ZabbixAPI
import voluptuous as vol
+from zabbix_utils import ZabbixAPI
from homeassistant.components.sensor import (
PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA,
diff --git a/homeassistant/components/zengge/manifest.json b/homeassistant/components/zengge/manifest.json
index 5a4525079da..03d989c5f3b 100644
--- a/homeassistant/components/zengge/manifest.json
+++ b/homeassistant/components/zengge/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/zengge",
"iot_class": "local_polling",
"loggers": ["zengge"],
+ "quality_scale": "legacy",
"requirements": ["bluepy==1.3.0", "zengge==0.2"]
}
diff --git a/homeassistant/components/zeroconf/__init__.py b/homeassistant/components/zeroconf/__init__.py
index 449c2ccef91..69c745c46a3 100644
--- a/homeassistant/components/zeroconf/__init__.py
+++ b/homeassistant/components/zeroconf/__init__.py
@@ -144,17 +144,27 @@ class ZeroconfServiceInfo(BaseServiceInfo):
@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 +231,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)
diff --git a/homeassistant/components/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json
index 98b09f1a251..98fa02a716e 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.0"]
+ "requirements": ["zeroconf==0.137.2"]
}
diff --git a/homeassistant/components/zeroconf/usage.py b/homeassistant/components/zeroconf/usage.py
index b9d51cd3c36..8ddfdbd592d 100644
--- a/homeassistant/components/zeroconf/usage.py
+++ b/homeassistant/components/zeroconf/usage.py
@@ -4,7 +4,7 @@ from typing import Any
import zeroconf
-from homeassistant.helpers.frame import report
+from homeassistant.helpers.frame import ReportBehavior, report_usage
from .models import HaZeroconf
@@ -16,14 +16,14 @@ def install_multiple_zeroconf_catcher(hass_zc: HaZeroconf) -> None:
"""
def new_zeroconf_new(self: zeroconf.Zeroconf, *k: Any, **kw: Any) -> HaZeroconf:
- report(
+ report_usage(
(
"attempted to create another Zeroconf instance. Please use the shared"
" Zeroconf via await"
" homeassistant.components.zeroconf.async_get_instance(hass)"
),
exclude_integrations={"zeroconf"},
- error_if_core=False,
+ core_behavior=ReportBehavior.LOG,
)
return hass_zc
diff --git a/homeassistant/components/zestimate/manifest.json b/homeassistant/components/zestimate/manifest.json
index a881adf503d..a787a9b1099 100644
--- a/homeassistant/components/zestimate/manifest.json
+++ b/homeassistant/components/zestimate/manifest.json
@@ -4,5 +4,6 @@
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/zestimate",
"iot_class": "cloud_polling",
+ "quality_scale": "legacy",
"requirements": ["xmltodict==0.13.0"]
}
diff --git a/homeassistant/components/zha/climate.py b/homeassistant/components/zha/climate.py
index fcf5afb5ac5..af9f56cd7dc 100644
--- a/homeassistant/components/zha/climate.py
+++ b/homeassistant/components/zha/climate.py
@@ -88,7 +88,6 @@ class Thermostat(ZHAEntity, ClimateEntity):
_attr_precision = PRECISION_TENTHS
_attr_temperature_unit = UnitOfTemperature.CELSIUS
_attr_translation_key: str = "thermostat"
- _enable_turn_on_off_backwards_compatibility = False
def __init__(self, entity_data: EntityData, **kwargs: Any) -> None:
"""Initialize the ZHA thermostat entity."""
diff --git a/homeassistant/components/zha/config_flow.py b/homeassistant/components/zha/config_flow.py
index 1c7e0d105c4..5cb67489423 100644
--- a/homeassistant/components/zha/config_flow.py
+++ b/homeassistant/components/zha/config_flow.py
@@ -33,6 +33,7 @@ from homeassistant.config_entries import (
from homeassistant.const import CONF_NAME
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.util import dt as dt_util
@@ -69,8 +70,17 @@ UPLOADED_BACKUP_FILE = "uploaded_backup_file"
REPAIR_MY_URL = "https://my.home-assistant.io/redirect/repairs/"
-DEFAULT_ZHA_ZEROCONF_PORT = 6638
-ESPHOME_API_PORT = 6053
+LEGACY_ZEROCONF_PORT = 6638
+LEGACY_ZEROCONF_ESPHOME_API_PORT = 6053
+
+ZEROCONF_SERVICE_TYPE = "_zigbee-coordinator._tcp.local."
+ZEROCONF_PROPERTIES_SCHEMA = vol.Schema(
+ {
+ vol.Required("radio_type"): vol.All(str, vol.In([t.name for t in RadioType])),
+ vol.Required("serial_number"): str,
+ },
+ extra=vol.ALLOW_EXTRA,
+)
def _format_backup_choice(
@@ -92,7 +102,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:
@@ -104,25 +115,26 @@ async def list_serial_ports(hass: HomeAssistant) -> list[ListPortInfo]:
yellow_radio.description = "Yellow Zigbee module"
yellow_radio.manufacturer = "Nabu Casa"
- # Present the multi-PAN addon as a setup option, if it's available
- multipan_manager = await silabs_multiprotocol_addon.get_multiprotocol_addon_manager(
- hass
- )
-
- try:
- addon_info = await multipan_manager.async_get_addon_info()
- except (AddonError, KeyError):
- addon_info = None
-
- if addon_info is not None and addon_info.state != AddonState.NOT_INSTALLED:
- addon_port = ListPortInfo(
- device=silabs_multiprotocol_addon.get_zigbee_socket(),
- skip_link_detection=True,
+ if is_hassio(hass):
+ # Present the multi-PAN addon as a setup option, if it's available
+ multipan_manager = (
+ await silabs_multiprotocol_addon.get_multiprotocol_addon_manager(hass)
)
- addon_port.description = "Multiprotocol add-on"
- addon_port.manufacturer = "Nabu Casa"
- ports.append(addon_port)
+ try:
+ addon_info = await multipan_manager.async_get_addon_info()
+ except (AddonError, KeyError):
+ addon_info = None
+
+ if addon_info is not None and addon_info.state != AddonState.NOT_INSTALLED:
+ addon_port = ListPortInfo(
+ device=silabs_multiprotocol_addon.get_zigbee_socket(),
+ skip_link_detection=True,
+ )
+
+ addon_port.description = "Multiprotocol add-on"
+ addon_port.manufacturer = "Nabu Casa"
+ ports.append(addon_port)
return ports
@@ -615,34 +627,65 @@ class ZhaConfigFlowHandler(BaseZhaFlow, ConfigFlow, domain=DOMAIN):
) -> ConfigFlowResult:
"""Handle zeroconf discovery."""
- # Hostname is format: livingroom.local.
- local_name = discovery_info.hostname[:-1]
- port = discovery_info.port or DEFAULT_ZHA_ZEROCONF_PORT
+ # Transform legacy zeroconf discovery into the new format
+ if discovery_info.type != ZEROCONF_SERVICE_TYPE:
+ port = discovery_info.port or LEGACY_ZEROCONF_PORT
+ name = discovery_info.name
- # Fix incorrect port for older TubesZB devices
- if "tube" in local_name and port == ESPHOME_API_PORT:
- port = DEFAULT_ZHA_ZEROCONF_PORT
+ # Fix incorrect port for older TubesZB devices
+ if "tube" in name and port == LEGACY_ZEROCONF_ESPHOME_API_PORT:
+ port = LEGACY_ZEROCONF_PORT
- if "radio_type" in discovery_info.properties:
- self._radio_mgr.radio_type = self._radio_mgr.parse_radio_type(
- discovery_info.properties["radio_type"]
+ # Determine the radio type
+ if "radio_type" in discovery_info.properties:
+ radio_type = discovery_info.properties["radio_type"]
+ elif "efr32" in name:
+ radio_type = RadioType.ezsp.name
+ elif "zigate" in name:
+ radio_type = RadioType.zigate.name
+ else:
+ radio_type = RadioType.znp.name
+
+ fallback_title = name.split("._", 1)[0]
+ title = discovery_info.properties.get("name", fallback_title)
+
+ discovery_info = zeroconf.ZeroconfServiceInfo(
+ ip_address=discovery_info.ip_address,
+ ip_addresses=discovery_info.ip_addresses,
+ port=port,
+ hostname=discovery_info.hostname,
+ type=ZEROCONF_SERVICE_TYPE,
+ name=f"{title}.{ZEROCONF_SERVICE_TYPE}",
+ properties={
+ "radio_type": radio_type,
+ # To maintain backwards compatibility
+ "serial_number": discovery_info.hostname.removesuffix(".local."),
+ },
)
- elif "efr32" in local_name:
- self._radio_mgr.radio_type = RadioType.ezsp
- else:
- self._radio_mgr.radio_type = RadioType.znp
- node_name = local_name.removesuffix(".local")
- device_path = f"socket://{discovery_info.host}:{port}"
+ try:
+ discovery_props = ZEROCONF_PROPERTIES_SCHEMA(discovery_info.properties)
+ except vol.Invalid:
+ return self.async_abort(reason="invalid_zeroconf_data")
+
+ radio_type = self._radio_mgr.parse_radio_type(discovery_props["radio_type"])
+ device_path = f"socket://{discovery_info.host}:{discovery_info.port}"
+ title = discovery_info.name.removesuffix(f".{ZEROCONF_SERVICE_TYPE}")
await self._set_unique_id_and_update_ignored_flow(
- unique_id=node_name,
+ unique_id=discovery_props["serial_number"],
device_path=device_path,
)
- self.context["title_placeholders"] = {CONF_NAME: node_name}
- self._title = device_path
+ self.context["title_placeholders"] = {CONF_NAME: title}
+ self._title = title
self._radio_mgr.device_path = device_path
+ self._radio_mgr.radio_type = radio_type
+ self._radio_mgr.device_settings = {
+ CONF_DEVICE_PATH: device_path,
+ CONF_BAUDRATE: 115200,
+ CONF_FLOW_CONTROL: None,
+ }
return await self.async_step_confirm()
diff --git a/homeassistant/components/zha/entity.py b/homeassistant/components/zha/entity.py
index 3e3d0642ca2..77ba048312a 100644
--- a/homeassistant/components/zha/entity.py
+++ b/homeassistant/components/zha/entity.py
@@ -87,7 +87,7 @@ class ZHAEntity(LogMixin, RestoreEntity, Entity):
manufacturer=zha_device_info[ATTR_MANUFACTURER],
model=zha_device_info[ATTR_MODEL],
name=zha_device_info[ATTR_NAME],
- via_device=(DOMAIN, zha_gateway.state.node_info.ieee),
+ via_device=(DOMAIN, str(zha_gateway.state.node_info.ieee)),
)
@callback
diff --git a/homeassistant/components/zha/fan.py b/homeassistant/components/zha/fan.py
index 767c0d4cfb7..73b23e97387 100644
--- a/homeassistant/components/zha/fan.py
+++ b/homeassistant/components/zha/fan.py
@@ -47,7 +47,6 @@ class ZhaFan(FanEntity, ZHAEntity):
"""Representation of a ZHA fan."""
_attr_translation_key: str = "fan"
- _enable_turn_on_off_backwards_compatibility = False
def __init__(self, entity_data: EntityData) -> None:
"""Initialize the ZHA fan."""
diff --git a/homeassistant/components/zha/icons.json b/homeassistant/components/zha/icons.json
index 5b3b85ced39..6ba4aab18ab 100644
--- a/homeassistant/components/zha/icons.json
+++ b/homeassistant/components/zha/icons.json
@@ -118,6 +118,12 @@
},
"exercise_day_of_week": {
"default": "mdi:wrench-clock"
+ },
+ "off_led_color": {
+ "default": "mdi:palette-outline"
+ },
+ "on_led_color": {
+ "default": "mdi:palette"
}
},
"sensor": {
@@ -206,6 +212,9 @@
},
"use_load_balancing": {
"default": "mdi:scale-balance"
+ },
+ "double_up_full": {
+ "default": "mdi:gesture-double-tap"
}
}
},
diff --git a/homeassistant/components/zha/light.py b/homeassistant/components/zha/light.py
index 9a22dfb02e9..2f5d9e9e4c9 100644
--- a/homeassistant/components/zha/light.py
+++ b/homeassistant/components/zha/light.py
@@ -15,7 +15,7 @@ from zha.application.platforms.light.const import (
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
ATTR_COLOR_MODE,
- ATTR_COLOR_TEMP,
+ ATTR_COLOR_TEMP_KELVIN,
ATTR_EFFECT,
ATTR_FLASH,
ATTR_TRANSITION,
@@ -29,6 +29,7 @@ from homeassistant.const import STATE_ON, Platform
from homeassistant.core import HomeAssistant, State, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.util import color as color_util
from .entity import ZHAEntity
from .helpers import (
@@ -128,14 +129,18 @@ class Light(LightEntity, ZHAEntity):
return self.entity_data.entity.brightness
@property
- def min_mireds(self) -> int:
- """Return the coldest color_temp that this light supports."""
- return self.entity_data.entity.min_mireds
+ def max_color_temp_kelvin(self) -> int:
+ """Return the coldest color_temp_kelvin that this light supports."""
+ return color_util.color_temperature_mired_to_kelvin(
+ self.entity_data.entity.min_mireds
+ )
@property
- def max_mireds(self) -> int:
- """Return the warmest color_temp that this light supports."""
- return self.entity_data.entity.max_mireds
+ def min_color_temp_kelvin(self) -> int:
+ """Return the warmest color_temp_kelvin that this light supports."""
+ return color_util.color_temperature_mired_to_kelvin(
+ self.entity_data.entity.max_mireds
+ )
@property
def xy_color(self) -> tuple[float, float] | None:
@@ -143,9 +148,13 @@ class Light(LightEntity, ZHAEntity):
return self.entity_data.entity.xy_color
@property
- def color_temp(self) -> int | None:
- """Return the CT color value in mireds."""
- return self.entity_data.entity.color_temp
+ def color_temp_kelvin(self) -> int | None:
+ """Return the color temperature value in Kelvin."""
+ return (
+ color_util.color_temperature_mired_to_kelvin(mireds)
+ if (mireds := self.entity_data.entity.color_temp)
+ else None
+ )
@property
def color_mode(self) -> ColorMode | None:
@@ -167,12 +176,17 @@ class Light(LightEntity, ZHAEntity):
@convert_zha_error_to_ha_error
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the entity on."""
+ color_temp = (
+ color_util.color_temperature_kelvin_to_mired(color_temp_k)
+ if (color_temp_k := kwargs.get(ATTR_COLOR_TEMP_KELVIN))
+ else None
+ )
await self.entity_data.entity.async_turn_on(
transition=kwargs.get(ATTR_TRANSITION),
brightness=kwargs.get(ATTR_BRIGHTNESS),
effect=kwargs.get(ATTR_EFFECT),
flash=kwargs.get(ATTR_FLASH),
- color_temp=kwargs.get(ATTR_COLOR_TEMP),
+ color_temp=color_temp,
xy_color=kwargs.get(ATTR_XY_COLOR),
)
self.async_write_ha_state()
@@ -188,12 +202,17 @@ class Light(LightEntity, ZHAEntity):
@callback
def restore_external_state_attributes(self, state: State) -> None:
"""Restore entity state."""
+ color_temp = (
+ color_util.color_temperature_kelvin_to_mired(color_temp_k)
+ if (color_temp_k := state.attributes.get(ATTR_COLOR_TEMP_KELVIN))
+ else None
+ )
self.entity_data.entity.restore_external_state_attributes(
state=(state.state == STATE_ON),
off_with_transition=state.attributes.get(OFF_WITH_TRANSITION),
off_brightness=state.attributes.get(OFF_BRIGHTNESS),
brightness=state.attributes.get(ATTR_BRIGHTNESS),
- color_temp=state.attributes.get(ATTR_COLOR_TEMP),
+ color_temp=color_temp,
xy_color=state.attributes.get(ATTR_XY_COLOR),
color_mode=(
HA_TO_ZHA_COLOR_MODE[ColorMode(state.attributes[ATTR_COLOR_MODE])]
diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json
index 96c9bc030f6..f9323fe99df 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.24", "zha==0.0.37"],
+ "requirements": ["zha==0.0.45"],
"usb": [
{
"vid": "10C4",
@@ -130,6 +130,10 @@
{
"type": "_czc._tcp.local.",
"name": "czc*"
+ },
+ {
+ "type": "_zigbee-coordinator._tcp.local.",
+ "name": "*"
}
]
}
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/strings.json b/homeassistant/components/zha/strings.json
index d0505bf2460..da76c62e82e 100644
--- a/homeassistant/components/zha/strings.json
+++ b/homeassistant/components/zha/strings.json
@@ -76,7 +76,8 @@
"single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]",
"not_zha_device": "This device is not a zha device",
"usb_probe_failed": "Failed to probe the usb device",
- "wrong_firmware_installed": "Your device is running the wrong firmware and cannot be used with ZHA until the correct firmware is installed. [A repair has been created]({repair_url}) with more information and instructions for how to fix this."
+ "wrong_firmware_installed": "Your device is running the wrong firmware and cannot be used with ZHA until the correct firmware is installed. [A repair has been created]({repair_url}) with more information and instructions for how to fix this.",
+ "invalid_zeroconf_data": "The coordinator has invalid zeroconf service info and cannot be identified by ZHA"
}
},
"options": {
@@ -297,7 +298,7 @@
},
"reconfigure_device": {
"name": "Reconfigure device",
- "description": "Reconfigures a ZHA device (heal device). Use this if you are having issues with the device. If the device in question is a battery-powered device, ensure it is awake and accepting commands when you use this service.",
+ "description": "Reconfigures a ZHA device (heal device). Use this if you are having issues with the device. If the device in question is a battery-powered device, ensure it is awake and accepting commands when you use this action.",
"fields": {
"ieee": {
"name": "[%key:component::zha::services::permit::fields::ieee::name%]",
@@ -585,6 +586,12 @@
},
"preheat_status": {
"name": "Pre-heat status"
+ },
+ "open_window_detection_status": {
+ "name": "Open window detection status"
+ },
+ "window_detection": {
+ "name": "Open window detection"
}
},
"button": {
@@ -599,6 +606,12 @@
},
"self_test": {
"name": "Self-test"
+ },
+ "reset_summation_delivered": {
+ "name": "Reset summation delivered"
+ },
+ "restart_device": {
+ "name": "Restart device"
}
},
"climate": {
@@ -791,6 +804,87 @@
},
"valve_countdown_2": {
"name": "Irrigation time 2"
+ },
+ "on_led_intensity": {
+ "name": "On LED intensity"
+ },
+ "off_led_intensity": {
+ "name": "Off LED intensity"
+ },
+ "frost_protection_temperature": {
+ "name": "Frost protection temperature"
+ },
+ "valve_opening_degree": {
+ "name": "Valve opening degree"
+ },
+ "valve_closing_degree": {
+ "name": "Valve closing degree"
+ },
+ "siren_time": {
+ "name": "Siren time"
+ },
+ "timer_time_left": {
+ "name": "Timer time left"
+ },
+ "approach_distance": {
+ "name": "Approach distance"
+ },
+ "fixed_load_demand": {
+ "name": "Fixed load demand"
+ },
+ "display_brightness": {
+ "name": "Display brightness"
+ },
+ "display_inactive_brightness": {
+ "name": "Display inactive brightness"
+ },
+ "display_activity_timeout": {
+ "name": "Display activity timeout"
+ },
+ "open_window_detection_threshold": {
+ "name": "Open window detection threshold"
+ },
+ "open_window_event_duration": {
+ "name": "Open window event duration"
+ },
+ "open_window_detection_guard_period": {
+ "name": "Open window detection guard period"
+ },
+ "fallback_timeout": {
+ "name": "Fallback timeout"
+ },
+ "boost_amount": {
+ "name": "Boost amount"
+ },
+ "ambient_sensor_correction": {
+ "name": "Ambient sensor correction"
+ },
+ "external_sensor_correction": {
+ "name": "External sensor correction"
+ },
+ "move_sensitivity": {
+ "name": "Motion sensitivity"
+ },
+ "detection_distance_min": {
+ "name": "Minimum range"
+ },
+ "detection_distance_max": {
+ "name": "Maximum range"
+ },
+ "presence_sensitivity": {
+ "name": "Presence sensitivity"
+ },
+ "presence_timeout": {
+ "name": "Fade time"
+ },
+ "regulator_set_point": {
+ "name": "Regulator set point"
+ },
+ "detection_delay": {
+ "name": "Detection delay"
+ },
+ "fading_time": {
+ "name": "Fading time"
}
},
"select": {
@@ -886,6 +980,54 @@
},
"weather_delay": {
"name": "Weather delay"
+ },
+ "on_led_color": {
+ "name": "On LED color"
+ },
+ "off_led_color": {
+ "name": "Off LED color"
+ },
+ "external_trigger_mode": {
+ "name": "External trigger mode"
+ },
+ "local_temperature_source": {
+ "name": "Local temperature source"
+ },
+ "control_type": {
+ "name": "Control type"
+ },
+ "thermostat_application": {
+ "name": "Thermostat application"
+ },
+ "heating_fuel": {
+ "name": "Heating fuel"
+ },
+ "heat_transfer_medium": {
+ "name": "Heat transfer medium"
+ },
+ "heating_emitter_type": {
+ "name": "Heating emitter type"
+ },
+ "external_temperature_sensor_type": {
+ "name": "External temperature sensor type"
+ },
+ "preset_mode": {
+ "name": "Preset mode"
+ },
+ "sensor_mode": {
+ "name": "Sensor mode"
+ },
+ "thermostat_mode": {
+ "name": "Thermostat mode"
+ },
+ "regulator_period": {
+ "name": "Regulator period"
+ },
+ "click_mode": {
+ "name": "Click mode"
+ },
+ "operation_mode": {
+ "name": "Operation mode"
}
},
"sensor": {
@@ -1083,6 +1225,27 @@
},
"valve_status_2": {
"name": "Status 2"
+ },
+ "timer_state": {
+ "name": "Timer state"
+ },
+ "last_valve_open_duration": {
+ "name": "Last valve open duration"
+ },
+ "motion_distance": {
+ "name": "Motion distance"
+ },
+ "control_status": {
+ "name": "Control status"
+ },
+ "distance": {
+ "name": "Target distance"
+ },
+ "local_temperature_floor": {
+ "name": "Floor temperature"
+ },
+ "self_test": {
+ "name": "Self test result"
}
},
"switch": {
@@ -1193,6 +1356,24 @@
},
"valve_on_off_2": {
"name": "Valve 2"
+ },
+ "double_up_full": {
+ "name": "Double tap on - full"
+ },
+ "open_window": {
+ "name": "Open window"
+ },
+ "turbo_mode": {
+ "name": "Turbo mode"
+ },
+ "detach_relay": {
+ "name": "Detach relay"
+ },
+ "enable_siren": {
+ "name": "Enable siren"
+ },
+ "find_switch": {
+ "name": "Distance switch"
}
}
}
diff --git a/homeassistant/components/zha/update.py b/homeassistant/components/zha/update.py
index 151d1c495e8..cb5c160e7b3 100644
--- a/homeassistant/components/zha/update.py
+++ b/homeassistant/components/zha/update.py
@@ -4,7 +4,6 @@ from __future__ import annotations
import functools
import logging
-import math
from typing import Any
from zha.exceptions import ZHAException
@@ -37,6 +36,18 @@ from .helpers import (
_LOGGER = logging.getLogger(__name__)
+OTA_MESSAGE_BATTERY_POWERED = (
+ "Battery powered devices can sometimes take multiple hours to update and you may"
+ " need to wake the device for the update to begin."
+)
+
+ZHA_DOCS_NETWORK_RELIABILITY = "https://www.home-assistant.io/integrations/zha/#zigbee-interference-avoidance-and-network-rangecoverage-optimization"
+OTA_MESSAGE_RELIABILITY = (
+ "If you are having issues updating a specific device, make sure that you've"
+ f" eliminated [common environmental issues]({ZHA_DOCS_NETWORK_RELIABILITY}) that"
+ " could be affecting network reliability. OTA updates require a reliable network."
+)
+
async def async_setup_entry(
hass: HomeAssistant,
@@ -97,6 +108,7 @@ class ZHAFirmwareUpdateEntity(
| UpdateEntityFeature.SPECIFIC_VERSION
| UpdateEntityFeature.RELEASE_NOTES
)
+ _attr_display_precision = 2 # 40 byte chunks with ~200KB files increments by 0.02%
def __init__(self, entity_data: EntityData, **kwargs: Any) -> None:
"""Initialize the ZHA siren."""
@@ -115,20 +127,19 @@ class ZHAFirmwareUpdateEntity(
def in_progress(self) -> bool | int | None:
"""Update installation progress.
+ Should return a boolean (True if in progress, False if not).
+ """
+ return self.entity_data.entity.in_progress
+
+ @property
+ def update_percentage(self) -> int | float | None:
+ """Update installation progress.
+
Needs UpdateEntityFeature.PROGRESS flag to be set for it to be used.
- Can either return a boolean (True if in progress, False if not)
- or an integer to indicate the progress in from 0 to 100%.
+ Can either return a number to indicate the progress from 0 to 100% or None.
"""
- if not self.entity_data.entity.in_progress:
- return self.entity_data.entity.in_progress
-
- # Stay in an indeterminate state until we actually send something
- if self.entity_data.entity.progress == 0:
- return True
-
- # Rescale 0-100% to 2-100% to avoid 0 and 1 colliding with None, False, and True
- return int(math.ceil(2 + 98 * self.entity_data.entity.progress / 100))
+ return self.entity_data.entity.update_percentage
@property
def latest_version(self) -> str | None:
@@ -150,7 +161,21 @@ class ZHAFirmwareUpdateEntity(
This is suitable for a long changelog that does not fit in the release_summary
property. The returned string can contain markdown.
"""
- return self.entity_data.entity.release_notes
+
+ if self.entity_data.device_proxy.device.is_mains_powered:
+ header = (
+ ""
+ f"{OTA_MESSAGE_RELIABILITY}"
+ ""
+ )
+ else:
+ header = (
+ ""
+ f"{OTA_MESSAGE_BATTERY_POWERED} {OTA_MESSAGE_RELIABILITY}"
+ ""
+ )
+
+ return f"{header}\n\n{self.entity_data.entity.release_notes or ''}"
@property
def release_url(self) -> str | None:
diff --git a/homeassistant/components/zhong_hong/climate.py b/homeassistant/components/zhong_hong/climate.py
index eaf00b5432f..b5acc230472 100644
--- a/homeassistant/components/zhong_hong/climate.py
+++ b/homeassistant/components/zhong_hong/climate.py
@@ -135,7 +135,6 @@ class ZhongHongClimate(ClimateEntity):
| ClimateEntityFeature.TURN_ON
)
_attr_temperature_unit = UnitOfTemperature.CELSIUS
- _enable_turn_on_off_backwards_compatibility = False
def __init__(self, hub, addr_out, addr_in):
"""Set up the ZhongHong climate devices."""
diff --git a/homeassistant/components/zhong_hong/manifest.json b/homeassistant/components/zhong_hong/manifest.json
index 9da0e9ab72b..3569466fb0a 100644
--- a/homeassistant/components/zhong_hong/manifest.json
+++ b/homeassistant/components/zhong_hong/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/zhong_hong",
"iot_class": "local_push",
"loggers": ["zhong_hong_hvac"],
+ "quality_scale": "legacy",
"requirements": ["zhong-hong-hvac==1.0.13"]
}
diff --git a/homeassistant/components/ziggo_mediabox_xl/manifest.json b/homeassistant/components/ziggo_mediabox_xl/manifest.json
index 81aac99e58d..1ae09c9927d 100644
--- a/homeassistant/components/ziggo_mediabox_xl/manifest.json
+++ b/homeassistant/components/ziggo_mediabox_xl/manifest.json
@@ -4,5 +4,6 @@
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/ziggo_mediabox_xl",
"iot_class": "local_polling",
+ "quality_scale": "legacy",
"requirements": ["ziggo-mediabox-xl==1.1.0"]
}
diff --git a/homeassistant/components/zodiac/manifest.json b/homeassistant/components/zodiac/manifest.json
index 88f3d7fadef..f641826ca7b 100644
--- a/homeassistant/components/zodiac/manifest.json
+++ b/homeassistant/components/zodiac/manifest.json
@@ -4,6 +4,5 @@
"codeowners": ["@JulienTant"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/zodiac",
- "iot_class": "calculated",
- "quality_scale": "silver"
+ "iot_class": "calculated"
}
diff --git a/homeassistant/components/zoneminder/manifest.json b/homeassistant/components/zoneminder/manifest.json
index f441a800555..2501aba2cf1 100644
--- a/homeassistant/components/zoneminder/manifest.json
+++ b/homeassistant/components/zoneminder/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/zoneminder",
"iot_class": "local_polling",
"loggers": ["zoneminder"],
+ "quality_scale": "legacy",
"requirements": ["zm-py==0.5.4"]
}
diff --git a/homeassistant/components/zwave_js/__init__.py b/homeassistant/components/zwave_js/__init__.py
index 06b8214d941..c8503b1f4c6 100644
--- a/homeassistant/components/zwave_js/__init__.py
+++ b/homeassistant/components/zwave_js/__init__.py
@@ -9,6 +9,7 @@ import logging
from typing import Any
from awesomeversion import AwesomeVersion
+import voluptuous as vol
from zwave_js_server.client import Client as ZwaveClient
from zwave_js_server.const import CommandClass, RemoveNodeReason
from zwave_js_server.exceptions import BaseZwaveJSServerError, InvalidServerVersion
@@ -87,6 +88,7 @@ from .const import (
CONF_ADDON_S2_AUTHENTICATED_KEY,
CONF_ADDON_S2_UNAUTHENTICATED_KEY,
CONF_DATA_COLLECTION_OPTED_IN,
+ CONF_INSTALLER_MODE,
CONF_INTEGRATION_CREATED_ADDON,
CONF_LR_S2_ACCESS_CONTROL_KEY,
CONF_LR_S2_AUTHENTICATED_KEY,
@@ -132,12 +134,21 @@ DATA_CLIENT_LISTEN_TASK = "client_listen_task"
DATA_DRIVER_EVENTS = "driver_events"
DATA_START_CLIENT_TASK = "start_client_task"
-CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
+CONFIG_SCHEMA = vol.Schema(
+ {
+ DOMAIN: vol.Schema(
+ {
+ vol.Optional(CONF_INSTALLER_MODE, default=False): cv.boolean,
+ }
+ )
+ },
+ extra=vol.ALLOW_EXTRA,
+)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Z-Wave JS component."""
- hass.data[DOMAIN] = {}
+ hass.data[DOMAIN] = config.get(DOMAIN, {})
for entry in hass.config_entries.async_entries(DOMAIN):
if not isinstance(entry.unique_id, str):
hass.config_entries.async_update_entry(
diff --git a/homeassistant/components/zwave_js/api.py b/homeassistant/components/zwave_js/api.py
index bd49e85b601..1a1cd6ae9c1 100644
--- a/homeassistant/components/zwave_js/api.py
+++ b/homeassistant/components/zwave_js/api.py
@@ -83,7 +83,9 @@ from .const import (
ATTR_PARAMETERS,
ATTR_WAIT_FOR_RESULT,
CONF_DATA_COLLECTION_OPTED_IN,
+ CONF_INSTALLER_MODE,
DATA_CLIENT,
+ DOMAIN,
EVENT_DEVICE_ADDED_TO_REGISTRY,
USER_AGENT,
)
@@ -393,6 +395,8 @@ def async_register_api(hass: HomeAssistant) -> None:
websocket_api.async_register_command(hass, websocket_node_metadata)
websocket_api.async_register_command(hass, websocket_node_alerts)
websocket_api.async_register_command(hass, websocket_add_node)
+ websocket_api.async_register_command(hass, websocket_cancel_secure_bootstrap_s2)
+ websocket_api.async_register_command(hass, websocket_subscribe_s2_inclusion)
websocket_api.async_register_command(hass, websocket_grant_security_classes)
websocket_api.async_register_command(hass, websocket_validate_dsk_and_enter_pin)
websocket_api.async_register_command(hass, websocket_provision_smart_start_node)
@@ -450,6 +454,7 @@ def async_register_api(hass: HomeAssistant) -> None:
websocket_api.async_register_command(hass, websocket_hard_reset_controller)
websocket_api.async_register_command(hass, websocket_node_capabilities)
websocket_api.async_register_command(hass, websocket_invoke_cc_api)
+ websocket_api.async_register_command(hass, websocket_get_integration_settings)
hass.http.register_view(FirmwareUploadView(dr.async_get(hass)))
@@ -836,6 +841,63 @@ async def websocket_add_node(
)
+@websocket_api.require_admin
+@websocket_api.websocket_command(
+ {
+ vol.Required(TYPE): "zwave_js/cancel_secure_bootstrap_s2",
+ vol.Required(ENTRY_ID): str,
+ }
+)
+@websocket_api.async_response
+@async_handle_failed_command
+@async_get_entry
+async def websocket_cancel_secure_bootstrap_s2(
+ hass: HomeAssistant,
+ connection: ActiveConnection,
+ msg: dict[str, Any],
+ entry: ConfigEntry,
+ client: Client,
+ driver: Driver,
+) -> None:
+ """Cancel secure bootstrap S2."""
+ await driver.controller.async_cancel_secure_bootstrap_s2()
+ connection.send_result(msg[ID])
+
+
+@websocket_api.require_admin
+@websocket_api.websocket_command(
+ {
+ vol.Required(TYPE): "zwave_js/subscribe_s2_inclusion",
+ vol.Required(ENTRY_ID): str,
+ }
+)
+@websocket_api.async_response
+@async_handle_failed_command
+@async_get_entry
+async def websocket_subscribe_s2_inclusion(
+ hass: HomeAssistant,
+ connection: ActiveConnection,
+ msg: dict[str, Any],
+ entry: ConfigEntry,
+ client: Client,
+ driver: Driver,
+) -> None:
+ """Subscribe to S2 inclusion initiated by the controller."""
+
+ @callback
+ def forward_dsk(event: dict) -> None:
+ connection.send_message(
+ websocket_api.event_message(
+ msg[ID], {"event": event["event"], "dsk": event["dsk"]}
+ )
+ )
+
+ unsub = driver.controller.on("validate dsk and enter pin", forward_dsk)
+ connection.subscriptions[msg["id"]] = unsub
+ msg[DATA_UNSUBSCRIBE] = [unsub]
+ connection.send_result(msg[ID])
+
+
@websocket_api.require_admin
@websocket_api.websocket_command(
{
@@ -2682,3 +2744,25 @@ async def websocket_invoke_cc_api(
msg[ID],
result,
)
+
+
+@callback
+@websocket_api.require_admin
+@websocket_api.websocket_command(
+ {
+ vol.Required(TYPE): "zwave_js/get_integration_settings",
+ }
+)
+def websocket_get_integration_settings(
+ hass: HomeAssistant,
+ connection: ActiveConnection,
+ msg: dict[str, Any],
+) -> None:
+ """Get Z-Wave JS integration wide configuration."""
+ connection.send_result(
+ msg[ID],
+ {
+ # list explicitly to avoid leaking other keys and to set default
+ CONF_INSTALLER_MODE: hass.data[DOMAIN].get(CONF_INSTALLER_MODE, False),
+ },
+ )
diff --git a/homeassistant/components/zwave_js/climate.py b/homeassistant/components/zwave_js/climate.py
index c7ab579c2cb..580694cae11 100644
--- a/homeassistant/components/zwave_js/climate.py
+++ b/homeassistant/components/zwave_js/climate.py
@@ -128,7 +128,6 @@ class ZWaveClimate(ZWaveBaseEntity, ClimateEntity):
"""Representation of a Z-Wave climate."""
_attr_precision = PRECISION_TENTHS
- _enable_turn_on_off_backwards_compatibility = False
def __init__(
self, config_entry: ConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo
diff --git a/homeassistant/components/zwave_js/config_flow.py b/homeassistant/components/zwave_js/config_flow.py
index 36f208e18d5..711eb14070d 100644
--- a/homeassistant/components/zwave_js/config_flow.py
+++ b/homeassistant/components/zwave_js/config_flow.py
@@ -671,7 +671,7 @@ class ZWaveJSConfigFlow(BaseZwaveJSFlow, ConfigFlow, domain=DOMAIN):
discovery_info = await self._async_get_addon_discovery_info()
self.ws_address = f"ws://{discovery_info['host']}:{discovery_info['port']}"
- if not self.unique_id or self.context["source"] == SOURCE_USB:
+ if not self.unique_id or self.source == SOURCE_USB:
if not self.version_info:
try:
self.version_info = await async_get_version_info(
diff --git a/homeassistant/components/zwave_js/const.py b/homeassistant/components/zwave_js/const.py
index fd81cd7e7de..16cf6f748bb 100644
--- a/homeassistant/components/zwave_js/const.py
+++ b/homeassistant/components/zwave_js/const.py
@@ -25,6 +25,7 @@ CONF_ADDON_S2_AUTHENTICATED_KEY = "s2_authenticated_key"
CONF_ADDON_S2_UNAUTHENTICATED_KEY = "s2_unauthenticated_key"
CONF_ADDON_LR_S2_ACCESS_CONTROL_KEY = "lr_s2_access_control_key"
CONF_ADDON_LR_S2_AUTHENTICATED_KEY = "lr_s2_authenticated_key"
+CONF_INSTALLER_MODE = "installer_mode"
CONF_INTEGRATION_CREATED_ADDON = "integration_created_addon"
CONF_NETWORK_KEY = "network_key"
CONF_S0_LEGACY_KEY = "s0_legacy_key"
diff --git a/homeassistant/components/zwave_js/fan.py b/homeassistant/components/zwave_js/fan.py
index 37d3fc57886..d83132e4b95 100644
--- a/homeassistant/components/zwave_js/fan.py
+++ b/homeassistant/components/zwave_js/fan.py
@@ -83,7 +83,6 @@ class ZwaveFan(ZWaveBaseEntity, FanEntity):
| FanEntityFeature.TURN_OFF
| FanEntityFeature.TURN_ON
)
- _enable_turn_on_off_backwards_compatibility = False
def __init__(
self, config_entry: ConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo
diff --git a/homeassistant/components/zwave_js/light.py b/homeassistant/components/zwave_js/light.py
index 4a044ca3f52..e6cfc6c8b29 100644
--- a/homeassistant/components/zwave_js/light.py
+++ b/homeassistant/components/zwave_js/light.py
@@ -29,7 +29,7 @@ from zwave_js_server.model.value import Value
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
- ATTR_COLOR_TEMP,
+ ATTR_COLOR_TEMP_KELVIN,
ATTR_HS_COLOR,
ATTR_RGBW_COLOR,
ATTR_TRANSITION,
@@ -60,6 +60,8 @@ MULTI_COLOR_MAP = {
ColorComponent.CYAN: COLOR_SWITCH_COMBINED_CYAN,
ColorComponent.PURPLE: COLOR_SWITCH_COMBINED_PURPLE,
}
+MIN_MIREDS = 153 # 6500K as a safe default
+MAX_MIREDS = 370 # 2700K as a safe default
async def async_setup_entry(
@@ -103,6 +105,9 @@ def byte_to_zwave_brightness(value: int) -> int:
class ZwaveLight(ZWaveBaseEntity, LightEntity):
"""Representation of a Z-Wave light."""
+ _attr_min_color_temp_kelvin = 2700 # 370 mireds as a safe default
+ _attr_max_color_temp_kelvin = 6500 # 153 mireds as a safe default
+
def __init__(
self, config_entry: ConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo
) -> None:
@@ -116,8 +121,6 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity):
self._hs_color: tuple[float, float] | None = None
self._rgbw_color: tuple[int, int, int, int] | None = None
self._color_temp: int | None = None
- self._min_mireds = 153 # 6500K as a safe default
- self._max_mireds = 370 # 2700K as a safe default
self._warm_white = self.get_zwave_value(
TARGET_COLOR_PROPERTY,
CommandClass.SWITCH_COLOR,
@@ -241,20 +244,10 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity):
return self._rgbw_color
@property
- def color_temp(self) -> int | None:
- """Return the color temperature."""
+ def color_temp_kelvin(self) -> int | None:
+ """Return the color temperature value in Kelvin."""
return self._color_temp
- @property
- def min_mireds(self) -> int:
- """Return the coldest color_temp that this light supports."""
- return self._min_mireds
-
- @property
- def max_mireds(self) -> int:
- """Return the warmest color_temp that this light supports."""
- return self._max_mireds
-
@property
def supported_color_modes(self) -> set[ColorMode] | None:
"""Flag supported features."""
@@ -267,10 +260,10 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity):
brightness = kwargs.get(ATTR_BRIGHTNESS)
hs_color = kwargs.get(ATTR_HS_COLOR)
- color_temp = kwargs.get(ATTR_COLOR_TEMP)
+ color_temp_k = kwargs.get(ATTR_COLOR_TEMP_KELVIN)
rgbw = kwargs.get(ATTR_RGBW_COLOR)
- new_colors = self._get_new_colors(hs_color, color_temp, rgbw)
+ new_colors = self._get_new_colors(hs_color, color_temp_k, rgbw)
if new_colors is not None:
await self._async_set_colors(new_colors, transition)
@@ -284,7 +277,7 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity):
def _get_new_colors(
self,
hs_color: tuple[float, float] | None,
- color_temp: int | None,
+ color_temp_k: int | None,
rgbw: tuple[int, int, int, int] | None,
brightness_scale: float | None = None,
) -> dict[ColorComponent, int] | None:
@@ -309,17 +302,14 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity):
return colors
# Color temperature
- if color_temp is not None and self._supports_color_temp:
+ if color_temp_k is not None and self._supports_color_temp:
# Limit color temp to min/max values
+ color_temp = color_util.color_temperature_kelvin_to_mired(color_temp_k)
cold = max(
0,
min(
255,
- round(
- (self._max_mireds - color_temp)
- / (self._max_mireds - self._min_mireds)
- * 255
- ),
+ round((MAX_MIREDS - color_temp) / (MAX_MIREDS - MIN_MIREDS) * 255),
),
)
warm = 255 - cold
@@ -505,9 +495,8 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity):
cold_white = multi_color.get(COLOR_SWITCH_COMBINED_COLD_WHITE, cw_val.value)
# Calculate color temps based on whites
if cold_white or warm_white:
- self._color_temp = round(
- self._max_mireds
- - ((cold_white / 255) * (self._max_mireds - self._min_mireds))
+ self._color_temp = color_util.color_temperature_mired_to_kelvin(
+ MAX_MIREDS - ((cold_white / 255) * (MAX_MIREDS - MIN_MIREDS))
)
# White channels turned on, set color mode to color_temp
self._color_mode = ColorMode.COLOR_TEMP
@@ -568,7 +557,7 @@ class ZwaveColorOnOffLight(ZwaveLight):
if (
kwargs.get(ATTR_RGBW_COLOR) is not None
- or kwargs.get(ATTR_COLOR_TEMP) is not None
+ or kwargs.get(ATTR_COLOR_TEMP_KELVIN) is not None
):
# RGBW and color temp are not supported in this mode,
# delegate to the parent class
@@ -629,7 +618,7 @@ class ZwaveColorOnOffLight(ZwaveLight):
if new_colors is None:
new_colors = self._get_new_colors(
- hs_color=hs_color, color_temp=None, rgbw=None, brightness_scale=scale
+ hs_color=hs_color, color_temp_k=None, rgbw=None, brightness_scale=scale
)
if new_colors is not None:
diff --git a/homeassistant/components/zwave_js/manifest.json b/homeassistant/components/zwave_js/manifest.json
index e3f643486a0..011776f4556 100644
--- a/homeassistant/components/zwave_js/manifest.json
+++ b/homeassistant/components/zwave_js/manifest.json
@@ -9,8 +9,7 @@
"integration_type": "hub",
"iot_class": "local_push",
"loggers": ["zwave_js_server"],
- "quality_scale": "platinum",
- "requirements": ["pyserial==3.5", "zwave-js-server-python==0.59.0"],
+ "requirements": ["pyserial==3.5", "zwave-js-server-python==0.60.0"],
"usb": [
{
"vid": "0658",
diff --git a/homeassistant/components/zwave_js/strings.json b/homeassistant/components/zwave_js/strings.json
index 28789bbf9f4..fc63b7e9119 100644
--- a/homeassistant/components/zwave_js/strings.json
+++ b/homeassistant/components/zwave_js/strings.json
@@ -290,7 +290,7 @@
"name": "[%key:component::zwave_js::services::set_config_parameter::fields::value::name%]"
}
},
- "name": "Bulk set partial configuration parameters (advanced)."
+ "name": "Bulk set partial configuration parameters (advanced)"
},
"clear_lock_usercode": {
"description": "Clears a user code from a lock.",
@@ -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 service. If an area is specified, all zwave_js devices and entities in that area will be targeted for this service.",
+ "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.",
"name": "Area ID(s)"
},
"command_class": {
@@ -314,7 +314,7 @@
"name": "[%key:component::zwave_js::services::set_value::fields::command_class::name%]"
},
"device_id": {
- "description": "The device(s) to target for this service.",
+ "description": "The device(s) to target for this action.",
"name": "Device ID(s)"
},
"endpoint": {
@@ -322,7 +322,7 @@
"name": "[%key:component::zwave_js::services::set_config_parameter::fields::endpoint::name%]"
},
"entity_id": {
- "description": "The entity ID(s) to target for this service.",
+ "description": "The entity ID(s) to target for this action.",
"name": "Entity ID(s)"
},
"method_name": {
@@ -556,7 +556,7 @@
"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.",
"fields": {
"area_id": {
- "description": "The area(s) to target for this service. If an area is specified, all zwave_js devices and entities in that area will be targeted for this service.",
+ "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.",
"name": "Area ID(s)"
},
"command_class": {
@@ -564,7 +564,7 @@
"name": "Command class"
},
"device_id": {
- "description": "The device(s) to target for this service.",
+ "description": "The device(s) to target for this action.",
"name": "Device ID(s)"
},
"endpoint": {
@@ -572,7 +572,7 @@
"name": "[%key:component::zwave_js::services::set_config_parameter::fields::endpoint::name%]"
},
"entity_id": {
- "description": "The entity ID(s) to target for this service.",
+ "description": "The entity ID(s) to target for this action.",
"name": "Entity ID(s)"
},
"options": {
diff --git a/homeassistant/components/zwave_js/triggers/event.py b/homeassistant/components/zwave_js/triggers/event.py
index 9938d08408c..db52683c173 100644
--- a/homeassistant/components/zwave_js/triggers/event.py
+++ b/homeassistant/components/zwave_js/triggers/event.py
@@ -5,7 +5,7 @@ from __future__ import annotations
from collections.abc import Callable
import functools
-from pydantic import ValidationError
+from pydantic.v1 import ValidationError
import voluptuous as vol
from zwave_js_server.client import Client
from zwave_js_server.model.controller import CONTROLLER_EVENT_MODEL_MAP
diff --git a/homeassistant/components/zwave_me/climate.py b/homeassistant/components/zwave_me/climate.py
index de6f606745f..b8eed88b505 100644
--- a/homeassistant/components/zwave_me/climate.py
+++ b/homeassistant/components/zwave_me/climate.py
@@ -57,7 +57,6 @@ class ZWaveMeClimate(ZWaveMeEntity, ClimateEntity):
_attr_hvac_mode = HVACMode.HEAT
_attr_hvac_modes = [HVACMode.HEAT]
_attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE
- _enable_turn_on_off_backwards_compatibility = False
def set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperature."""
diff --git a/homeassistant/components/zwave_me/fan.py b/homeassistant/components/zwave_me/fan.py
index 1016586ab55..bd0feba0dfb 100644
--- a/homeassistant/components/zwave_me/fan.py
+++ b/homeassistant/components/zwave_me/fan.py
@@ -49,7 +49,6 @@ class ZWaveMeFan(ZWaveMeEntity, FanEntity):
| FanEntityFeature.TURN_OFF
| FanEntityFeature.TURN_ON
)
- _enable_turn_on_off_backwards_compatibility = False
@property
def percentage(self) -> int:
diff --git a/homeassistant/config.py b/homeassistant/config.py
index cab4d0c7aff..e9089f27662 100644
--- a/homeassistant/config.py
+++ b/homeassistant/config.py
@@ -814,6 +814,8 @@ def _get_log_message_and_stack_print_pref(
"domain": domain,
"error": str(exception),
"p_name": platform_path,
+ "config_file": "?",
+ "line": "?",
}
show_stack_trace: bool | None = _CONFIG_LOG_SHOW_STACK_TRACE.get(
diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py
index 64eadeb0d7e..ade4cd855ca 100644
--- a/homeassistant/config_entries.py
+++ b/homeassistant/config_entries.py
@@ -54,7 +54,12 @@ from .exceptions import (
ConfigEntryNotReady,
HomeAssistantError,
)
-from .helpers import device_registry, entity_registry, issue_registry as ir, storage
+from .helpers import (
+ device_registry as dr,
+ entity_registry as er,
+ issue_registry as ir,
+ storage,
+)
from .helpers.debounce import Debouncer
from .helpers.discovery_flow import DiscoveryKey
from .helpers.dispatcher import SignalType, async_dispatcher_send_internal
@@ -63,7 +68,7 @@ from .helpers.event import (
RANDOM_MICROSECOND_MIN,
async_call_later,
)
-from .helpers.frame import ReportBehavior, report, report_usage
+from .helpers.frame import ReportBehavior, report_usage
from .helpers.json import json_bytes, json_bytes_sorted, json_fragment
from .helpers.typing import UNDEFINED, ConfigType, DiscoveryInfoType, UndefinedType
from .loader import async_suggest_report_issue
@@ -1191,14 +1196,13 @@ class FlowCancelledError(Exception):
def _report_non_awaited_platform_forwards(entry: ConfigEntry, what: str) -> None:
"""Report non awaited platform forwards."""
- report(
+ report_usage(
f"calls {what} for integration {entry.domain} with "
f"title: {entry.title} and entry_id: {entry.entry_id}, "
f"during setup without awaiting {what}, which can cause "
- "the setup lock to be released before the setup is done. "
- "This will stop working in Home Assistant 2025.1",
- error_if_integration=False,
- error_if_core=False,
+ "the setup lock to be released before the setup is done",
+ core_behavior=ReportBehavior.LOG,
+ breaks_in_ha_version="2025.1",
)
@@ -1266,10 +1270,9 @@ class ConfigEntriesFlowManager(
SOURCE_RECONFIGURE,
} and "entry_id" not in context:
# Deprecated in 2024.12, should fail in 2025.12
- report(
+ report_usage(
f"initialises a {source} flow without a link to the config entry",
- error_if_integration=False,
- error_if_core=True,
+ breaks_in_ha_version="2025.12",
)
flow_id = ulid_util.ulid_now()
@@ -1484,8 +1487,6 @@ class ConfigEntriesFlowManager(
)
# Unload the entry before setting up the new one.
- # We will remove it only after the other one is set up,
- # so that device customizations are not getting lost.
if existing_entry is not None and existing_entry.state.recoverable:
await self.config_entries.async_unload(existing_entry.entry_id)
@@ -1508,12 +1509,14 @@ class ConfigEntriesFlowManager(
)
if existing_entry is not None:
- # Unload and remove the existing entry
+ # Unload and remove the existing entry, but don't clean up devices and
+ # entities until the new entry is added
await self.config_entries._async_remove(existing_entry.entry_id) # noqa: SLF001
await self.config_entries.async_add(entry)
if existing_entry is not None:
# Clean up devices and entities belonging to the existing entry
+ # which are not present in the new entry
self.config_entries._async_clean_up(existing_entry) # noqa: SLF001
result["result"] = entry
@@ -1830,6 +1833,16 @@ class ConfigEntries:
"""Return entry with matching entry_id."""
return self._entries.data.get(entry_id)
+ @callback
+ def async_get_known_entry(self, entry_id: str) -> ConfigEntry:
+ """Return entry with matching entry_id.
+
+ Raises UnknownEntry if entry is not found.
+ """
+ if (entry := self.async_get_entry(entry_id)) is None:
+ raise UnknownEntry
+ return entry
+
@callback
def async_entry_ids(self) -> list[str]:
"""Return entry ids."""
@@ -1919,8 +1932,7 @@ class ConfigEntries:
async def _async_remove(self, entry_id: str) -> tuple[bool, ConfigEntry]:
"""Remove and unload an entry."""
- if (entry := self.async_get_entry(entry_id)) is None:
- raise UnknownEntry
+ entry = self.async_get_known_entry(entry_id)
async with entry.setup_lock:
if not entry.state.recoverable:
@@ -1941,8 +1953,8 @@ class ConfigEntries:
"""Clean up after an entry."""
entry_id = entry.entry_id
- dev_reg = device_registry.async_get(self.hass)
- ent_reg = entity_registry.async_get(self.hass)
+ dev_reg = dr.async_get(self.hass)
+ ent_reg = er.async_get(self.hass)
dev_reg.async_clear_config_entry(entry_id)
ent_reg.async_clear_config_entry(entry_id)
@@ -2013,8 +2025,7 @@ class ConfigEntries:
Return True if entry has been successfully loaded.
"""
- if (entry := self.async_get_entry(entry_id)) is None:
- raise UnknownEntry
+ entry = self.async_get_known_entry(entry_id)
if entry.state is not ConfigEntryState.NOT_LOADED:
raise OperationNotAllowed(
@@ -2045,8 +2056,7 @@ class ConfigEntries:
async def async_unload(self, entry_id: str, _lock: bool = True) -> bool:
"""Unload a config entry."""
- if (entry := self.async_get_entry(entry_id)) is None:
- raise UnknownEntry
+ entry = self.async_get_known_entry(entry_id)
if not entry.state.recoverable:
raise OperationNotAllowed(
@@ -2064,8 +2074,7 @@ class ConfigEntries:
@callback
def async_schedule_reload(self, entry_id: str) -> None:
"""Schedule a config entry to be reloaded."""
- if (entry := self.async_get_entry(entry_id)) is None:
- raise UnknownEntry
+ entry = self.async_get_known_entry(entry_id)
entry.async_cancel_retry_setup()
self.hass.async_create_task(
self.async_reload(entry_id),
@@ -2083,8 +2092,7 @@ class ConfigEntries:
If an entry was not loaded, will just load.
"""
- if (entry := self.async_get_entry(entry_id)) is None:
- raise UnknownEntry
+ entry = self.async_get_known_entry(entry_id)
# Cancel the setup retry task before waiting for the
# reload lock to reduce the chance of concurrent reload
@@ -2114,8 +2122,7 @@ class ConfigEntries:
If disabled_by is changed, the config entry will be reloaded.
"""
- if (entry := self.async_get_entry(entry_id)) is None:
- raise UnknownEntry
+ entry = self.async_get_known_entry(entry_id)
_validate_item(disabled_by=disabled_by)
if entry.disabled_by is disabled_by:
@@ -2124,21 +2131,21 @@ class ConfigEntries:
entry.disabled_by = disabled_by
self._async_schedule_save()
- dev_reg = device_registry.async_get(self.hass)
- ent_reg = entity_registry.async_get(self.hass)
+ dev_reg = dr.async_get(self.hass)
+ ent_reg = er.async_get(self.hass)
if not entry.disabled_by:
# The config entry will no longer be disabled, enable devices and entities
- device_registry.async_config_entry_disabled_by_changed(dev_reg, entry)
- entity_registry.async_config_entry_disabled_by_changed(ent_reg, entry)
+ dr.async_config_entry_disabled_by_changed(dev_reg, entry)
+ er.async_config_entry_disabled_by_changed(ent_reg, entry)
# Load or unload the config entry
reload_result = await self.async_reload(entry_id)
if entry.disabled_by:
# The config entry has been disabled, disable devices and entities
- device_registry.async_config_entry_disabled_by_changed(dev_reg, entry)
- entity_registry.async_config_entry_disabled_by_changed(ent_reg, entry)
+ dr.async_config_entry_disabled_by_changed(dev_reg, entry)
+ er.async_config_entry_disabled_by_changed(ent_reg, entry)
return reload_result
@@ -2321,14 +2328,13 @@ class ConfigEntries:
multiple platforms at once and is more efficient since it
does not require a separate import executor job for each platform.
"""
- report(
+ report_usage(
"calls async_forward_entry_setup for "
f"integration, {entry.domain} with title: {entry.title} "
- f"and entry_id: {entry.entry_id}, which is deprecated and "
- "will stop working in Home Assistant 2025.6, "
+ f"and entry_id: {entry.entry_id}, which is deprecated, "
"await async_forward_entry_setups instead",
- error_if_core=False,
- error_if_integration=False,
+ core_behavior=ReportBehavior.LOG,
+ breaks_in_ha_version="2025.6",
)
if not entry.setup_lock.locked():
async with entry.setup_lock:
@@ -2890,18 +2896,12 @@ class ConfigFlow(ConfigEntryBaseFlow):
) -> ConfigFlowResult:
"""Finish config flow and create a config entry."""
if self.source in {SOURCE_REAUTH, SOURCE_RECONFIGURE}:
- report_issue = async_suggest_report_issue(
- self.hass, integration_domain=self.handler
- )
- _LOGGER.warning(
- (
- "Detected %s config flow creating a new entry, "
- "when it is expected to update an existing entry and abort. "
- "This will stop working in %s, please %s"
- ),
- self.source,
- "2025.11",
- report_issue,
+ report_usage(
+ f"creates a new entry in a '{self.source}' flow, "
+ "when it is expected to update an existing entry and abort",
+ core_behavior=ReportBehavior.LOG,
+ breaks_in_ha_version="2025.11",
+ integration_domain=self.handler,
)
result = super().async_create_entry(
title=title,
@@ -2970,7 +2970,7 @@ class ConfigFlow(ConfigEntryBaseFlow):
step_id: str | None = None,
data_schema: vol.Schema | None = None,
errors: dict[str, str] | None = None,
- description_placeholders: Mapping[str, str | None] | None = None,
+ description_placeholders: Mapping[str, str] | None = None,
last_step: bool | None = None,
preview: str | None = None,
) -> ConfigFlowResult:
@@ -3009,9 +3009,7 @@ class ConfigFlow(ConfigEntryBaseFlow):
@callback
def _get_reauth_entry(self) -> ConfigEntry:
"""Return the reauth config entry linked to the current context."""
- if entry := self.hass.config_entries.async_get_entry(self._reauth_entry_id):
- return entry
- raise UnknownEntry
+ return self.hass.config_entries.async_get_known_entry(self._reauth_entry_id)
@property
def _reconfigure_entry_id(self) -> str:
@@ -3023,11 +3021,9 @@ class ConfigFlow(ConfigEntryBaseFlow):
@callback
def _get_reconfigure_entry(self) -> ConfigEntry:
"""Return the reconfigure config entry linked to the current context."""
- if entry := self.hass.config_entries.async_get_entry(
+ return self.hass.config_entries.async_get_known_entry(
self._reconfigure_entry_id
- ):
- return entry
- raise UnknownEntry
+ )
class OptionsFlowManager(
@@ -3039,11 +3035,7 @@ class OptionsFlowManager(
def _async_get_config_entry(self, config_entry_id: str) -> ConfigEntry:
"""Return config entry or raise if not found."""
- entry = self.hass.config_entries.async_get_entry(config_entry_id)
- if entry is None:
- raise UnknownEntry(config_entry_id)
-
- return entry
+ return self.hass.config_entries.async_get_known_entry(config_entry_id)
async def async_create_flow(
self,
@@ -3077,9 +3069,8 @@ class OptionsFlowManager(
if result["type"] != data_entry_flow.FlowResultType.CREATE_ENTRY:
return result
- entry = self.hass.config_entries.async_get_entry(flow.handler)
- if entry is None:
- raise UnknownEntry(flow.handler)
+ entry = self.hass.config_entries.async_get_known_entry(flow.handler)
+
if result["data"] is not None:
self.hass.config_entries.async_update_entry(entry, options=result["data"])
@@ -3151,18 +3142,17 @@ class OptionsFlow(ConfigEntryBaseFlow):
if self.hass is None:
raise ValueError("The config entry is not available during initialisation")
- if entry := self.hass.config_entries.async_get_entry(self._config_entry_id):
- return entry
- raise UnknownEntry
+ return self.hass.config_entries.async_get_known_entry(self._config_entry_id)
@config_entry.setter
def config_entry(self, value: ConfigEntry) -> None:
"""Set the config entry value."""
- report(
- "sets option flow config_entry explicitly, which is deprecated "
- "and will stop working in 2025.12",
- error_if_integration=False,
- error_if_core=True,
+ report_usage(
+ "sets option flow config_entry explicitly, which is deprecated",
+ core_behavior=ReportBehavior.ERROR,
+ core_integration_behavior=ReportBehavior.ERROR,
+ custom_integration_behavior=ReportBehavior.LOG,
+ breaks_in_ha_version="2025.12",
)
self._config_entry = value
@@ -3197,7 +3187,7 @@ class EntityRegistryDisabledHandler:
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize the handler."""
self.hass = hass
- self.registry: entity_registry.EntityRegistry | None = None
+ self.registry: er.EntityRegistry | None = None
self.changed: set[str] = set()
self._remove_call_later: Callable[[], None] | None = None
@@ -3205,18 +3195,18 @@ class EntityRegistryDisabledHandler:
def async_setup(self) -> None:
"""Set up the disable handler."""
self.hass.bus.async_listen(
- entity_registry.EVENT_ENTITY_REGISTRY_UPDATED,
+ er.EVENT_ENTITY_REGISTRY_UPDATED,
self._handle_entry_updated,
event_filter=_handle_entry_updated_filter,
)
@callback
def _handle_entry_updated(
- self, event: Event[entity_registry.EventEntityRegistryUpdatedData]
+ self, event: Event[er.EventEntityRegistryUpdatedData]
) -> None:
"""Handle entity registry entry update."""
if self.registry is None:
- self.registry = entity_registry.async_get(self.hass)
+ self.registry = er.async_get(self.hass)
entity_entry = self.registry.async_get(event.data["entity_id"])
@@ -3231,10 +3221,9 @@ class EntityRegistryDisabledHandler:
):
return
- config_entry = self.hass.config_entries.async_get_entry(
+ config_entry = self.hass.config_entries.async_get_known_entry(
entity_entry.config_entry_id
)
- assert config_entry is not None
if config_entry.entry_id not in self.changed and config_entry.supports_unload:
self.changed.add(config_entry.entry_id)
@@ -3274,7 +3263,7 @@ class EntityRegistryDisabledHandler:
@callback
def _handle_entry_updated_filter(
- event_data: entity_registry.EventEntityRegistryUpdatedData,
+ event_data: er.EventEntityRegistryUpdatedData,
) -> bool:
"""Handle entity registry entry update filter.
@@ -3284,8 +3273,7 @@ def _handle_entry_updated_filter(
return not (
event_data["action"] != "update"
or "disabled_by" not in event_data["changes"]
- or event_data["changes"]["disabled_by"]
- is entity_registry.RegistryEntryDisabler.CONFIG_ENTRY
+ or event_data["changes"]["disabled_by"] is er.RegistryEntryDisabler.CONFIG_ENTRY
)
diff --git a/homeassistant/const.py b/homeassistant/const.py
index 0bdd625e417..4c4d2fa90c2 100644
--- a/homeassistant/const.py
+++ b/homeassistant/const.py
@@ -23,15 +23,15 @@ if TYPE_CHECKING:
from .helpers.typing import NoEventData
APPLICATION_NAME: Final = "HomeAssistant"
-MAJOR_VERSION: Final = 2024
-MINOR_VERSION: Final = 12
+MAJOR_VERSION: Final = 2025
+MINOR_VERSION: Final = 2
PATCH_VERSION: Final = "0.dev0"
__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_NEXT_PYTHON_VER: Final[tuple[int, int, int]] = (3, 12, 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 = ""
+REQUIRED_NEXT_PYTHON_HA_RELEASE: Final = "2025.2"
# Format for platform files
PLATFORM_FORMAT: Final = "{platform}.{domain}"
@@ -336,133 +336,6 @@ EVENT_RECORDER_HOURLY_STATISTICS_GENERATED: Final = (
)
EVENT_SHOPPING_LIST_UPDATED: Final = "shopping_list_updated"
-# #### DEVICE CLASSES ####
-# DEVICE_CLASS_* below are deprecated as of 2021.12
-# use the SensorDeviceClass enum instead.
-_DEPRECATED_DEVICE_CLASS_AQI: Final = DeprecatedConstant(
- "aqi", "SensorDeviceClass.AQI", "2025.1"
-)
-_DEPRECATED_DEVICE_CLASS_BATTERY: Final = DeprecatedConstant(
- "battery",
- "SensorDeviceClass.BATTERY",
- "2025.1",
-)
-_DEPRECATED_DEVICE_CLASS_CO: Final = DeprecatedConstant(
- "carbon_monoxide",
- "SensorDeviceClass.CO",
- "2025.1",
-)
-_DEPRECATED_DEVICE_CLASS_CO2: Final = DeprecatedConstant(
- "carbon_dioxide",
- "SensorDeviceClass.CO2",
- "2025.1",
-)
-_DEPRECATED_DEVICE_CLASS_CURRENT: Final = DeprecatedConstant(
- "current",
- "SensorDeviceClass.CURRENT",
- "2025.1",
-)
-_DEPRECATED_DEVICE_CLASS_DATE: Final = DeprecatedConstant(
- "date", "SensorDeviceClass.DATE", "2025.1"
-)
-_DEPRECATED_DEVICE_CLASS_ENERGY: Final = DeprecatedConstant(
- "energy",
- "SensorDeviceClass.ENERGY",
- "2025.1",
-)
-_DEPRECATED_DEVICE_CLASS_FREQUENCY: Final = DeprecatedConstant(
- "frequency",
- "SensorDeviceClass.FREQUENCY",
- "2025.1",
-)
-_DEPRECATED_DEVICE_CLASS_GAS: Final = DeprecatedConstant(
- "gas", "SensorDeviceClass.GAS", "2025.1"
-)
-_DEPRECATED_DEVICE_CLASS_HUMIDITY: Final = DeprecatedConstant(
- "humidity",
- "SensorDeviceClass.HUMIDITY",
- "2025.1",
-)
-_DEPRECATED_DEVICE_CLASS_ILLUMINANCE: Final = DeprecatedConstant(
- "illuminance",
- "SensorDeviceClass.ILLUMINANCE",
- "2025.1",
-)
-_DEPRECATED_DEVICE_CLASS_MONETARY: Final = DeprecatedConstant(
- "monetary",
- "SensorDeviceClass.MONETARY",
- "2025.1",
-)
-_DEPRECATED_DEVICE_CLASS_NITROGEN_DIOXIDE: Final = DeprecatedConstant(
- "nitrogen_dioxide",
- "SensorDeviceClass.NITROGEN_DIOXIDE",
- "2025.1",
-)
-_DEPRECATED_DEVICE_CLASS_NITROGEN_MONOXIDE: Final = DeprecatedConstant(
- "nitrogen_monoxide",
- "SensorDeviceClass.NITROGEN_MONOXIDE",
- "2025.1",
-)
-_DEPRECATED_DEVICE_CLASS_NITROUS_OXIDE: Final = DeprecatedConstant(
- "nitrous_oxide",
- "SensorDeviceClass.NITROUS_OXIDE",
- "2025.1",
-)
-_DEPRECATED_DEVICE_CLASS_OZONE: Final = DeprecatedConstant(
- "ozone", "SensorDeviceClass.OZONE", "2025.1"
-)
-_DEPRECATED_DEVICE_CLASS_PM1: Final = DeprecatedConstant(
- "pm1", "SensorDeviceClass.PM1", "2025.1"
-)
-_DEPRECATED_DEVICE_CLASS_PM10: Final = DeprecatedConstant(
- "pm10", "SensorDeviceClass.PM10", "2025.1"
-)
-_DEPRECATED_DEVICE_CLASS_PM25: Final = DeprecatedConstant(
- "pm25", "SensorDeviceClass.PM25", "2025.1"
-)
-_DEPRECATED_DEVICE_CLASS_POWER_FACTOR: Final = DeprecatedConstant(
- "power_factor",
- "SensorDeviceClass.POWER_FACTOR",
- "2025.1",
-)
-_DEPRECATED_DEVICE_CLASS_POWER: Final = DeprecatedConstant(
- "power", "SensorDeviceClass.POWER", "2025.1"
-)
-_DEPRECATED_DEVICE_CLASS_PRESSURE: Final = DeprecatedConstant(
- "pressure",
- "SensorDeviceClass.PRESSURE",
- "2025.1",
-)
-_DEPRECATED_DEVICE_CLASS_SIGNAL_STRENGTH: Final = DeprecatedConstant(
- "signal_strength",
- "SensorDeviceClass.SIGNAL_STRENGTH",
- "2025.1",
-)
-_DEPRECATED_DEVICE_CLASS_SULPHUR_DIOXIDE: Final = DeprecatedConstant(
- "sulphur_dioxide",
- "SensorDeviceClass.SULPHUR_DIOXIDE",
- "2025.1",
-)
-_DEPRECATED_DEVICE_CLASS_TEMPERATURE: Final = DeprecatedConstant(
- "temperature",
- "SensorDeviceClass.TEMPERATURE",
- "2025.1",
-)
-_DEPRECATED_DEVICE_CLASS_TIMESTAMP: Final = DeprecatedConstant(
- "timestamp",
- "SensorDeviceClass.TIMESTAMP",
- "2025.1",
-)
-_DEPRECATED_DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS: Final = DeprecatedConstant(
- "volatile_organic_compounds",
- "SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS",
- "2025.1",
-)
-_DEPRECATED_DEVICE_CLASS_VOLTAGE: Final = DeprecatedConstant(
- "voltage",
- "SensorDeviceClass.VOLTAGE",
- "2025.1",
-)
# #### STATES ####
STATE_ON: Final = "on"
@@ -712,17 +585,11 @@ class UnitOfApparentPower(StrEnum):
VOLT_AMPERE = "VA"
-_DEPRECATED_POWER_VOLT_AMPERE: Final = DeprecatedConstantEnum(
- UnitOfApparentPower.VOLT_AMPERE,
- "2025.1",
-)
-"""Deprecated: please use UnitOfApparentPower.VOLT_AMPERE."""
-
-
# Power units
class UnitOfPower(StrEnum):
"""Power units."""
+ MILLIWATT = "mW"
WATT = "W"
KILO_WATT = "kW"
MEGA_WATT = "MW"
@@ -731,23 +598,6 @@ class UnitOfPower(StrEnum):
BTU_PER_HOUR = "BTU/h"
-_DEPRECATED_POWER_WATT: Final = DeprecatedConstantEnum(
- UnitOfPower.WATT,
- "2025.1",
-)
-"""Deprecated: please use UnitOfPower.WATT."""
-_DEPRECATED_POWER_KILO_WATT: Final = DeprecatedConstantEnum(
- UnitOfPower.KILO_WATT,
- "2025.1",
-)
-"""Deprecated: please use UnitOfPower.KILO_WATT."""
-_DEPRECATED_POWER_BTU_PER_HOUR: Final = DeprecatedConstantEnum(
- UnitOfPower.BTU_PER_HOUR,
- "2025.1",
-)
-"""Deprecated: please use UnitOfPower.BTU_PER_HOUR."""
-
-
# Reactive power units
class UnitOfReactivePower(StrEnum):
"""Reactive power units."""
@@ -770,6 +620,7 @@ class UnitOfEnergy(StrEnum):
KILO_JOULE = "kJ"
MEGA_JOULE = "MJ"
GIGA_JOULE = "GJ"
+ MILLIWATT_HOUR = "mWh"
WATT_HOUR = "Wh"
KILO_WATT_HOUR = "kWh"
MEGA_WATT_HOUR = "MWh"
@@ -781,23 +632,6 @@ class UnitOfEnergy(StrEnum):
GIGA_CALORIE = "Gcal"
-_DEPRECATED_ENERGY_KILO_WATT_HOUR: Final = DeprecatedConstantEnum(
- UnitOfEnergy.KILO_WATT_HOUR,
- "2025.1",
-)
-"""Deprecated: please use UnitOfEnergy.KILO_WATT_HOUR."""
-_DEPRECATED_ENERGY_MEGA_WATT_HOUR: Final = DeprecatedConstantEnum(
- UnitOfEnergy.MEGA_WATT_HOUR,
- "2025.1",
-)
-"""Deprecated: please use UnitOfEnergy.MEGA_WATT_HOUR."""
-_DEPRECATED_ENERGY_WATT_HOUR: Final = DeprecatedConstantEnum(
- UnitOfEnergy.WATT_HOUR,
- "2025.1",
-)
-"""Deprecated: please use UnitOfEnergy.WATT_HOUR."""
-
-
# Electric_current units
class UnitOfElectricCurrent(StrEnum):
"""Electric current units."""
@@ -806,37 +640,15 @@ class UnitOfElectricCurrent(StrEnum):
AMPERE = "A"
-_DEPRECATED_ELECTRIC_CURRENT_MILLIAMPERE: Final = DeprecatedConstantEnum(
- UnitOfElectricCurrent.MILLIAMPERE,
- "2025.1",
-)
-"""Deprecated: please use UnitOfElectricCurrent.MILLIAMPERE."""
-_DEPRECATED_ELECTRIC_CURRENT_AMPERE: Final = DeprecatedConstantEnum(
- UnitOfElectricCurrent.AMPERE,
- "2025.1",
-)
-"""Deprecated: please use UnitOfElectricCurrent.AMPERE."""
-
-
# Electric_potential units
class UnitOfElectricPotential(StrEnum):
"""Electric potential units."""
+ MICROVOLT = "µV"
MILLIVOLT = "mV"
VOLT = "V"
-_DEPRECATED_ELECTRIC_POTENTIAL_MILLIVOLT: Final = DeprecatedConstantEnum(
- UnitOfElectricPotential.MILLIVOLT,
- "2025.1",
-)
-"""Deprecated: please use UnitOfElectricPotential.MILLIVOLT."""
-_DEPRECATED_ELECTRIC_POTENTIAL_VOLT: Final = DeprecatedConstantEnum(
- UnitOfElectricPotential.VOLT,
- "2025.1",
-)
-"""Deprecated: please use UnitOfElectricPotential.VOLT."""
-
# Degree units
DEGREE: Final = "°"
@@ -855,23 +667,6 @@ class UnitOfTemperature(StrEnum):
KELVIN = "K"
-_DEPRECATED_TEMP_CELSIUS: Final = DeprecatedConstantEnum(
- UnitOfTemperature.CELSIUS,
- "2025.1",
-)
-"""Deprecated: please use UnitOfTemperature.CELSIUS"""
-_DEPRECATED_TEMP_FAHRENHEIT: Final = DeprecatedConstantEnum(
- UnitOfTemperature.FAHRENHEIT,
- "2025.1",
-)
-"""Deprecated: please use UnitOfTemperature.FAHRENHEIT"""
-_DEPRECATED_TEMP_KELVIN: Final = DeprecatedConstantEnum(
- UnitOfTemperature.KELVIN,
- "2025.1",
-)
-"""Deprecated: please use UnitOfTemperature.KELVIN"""
-
-
# Time units
class UnitOfTime(StrEnum):
"""Time units."""
@@ -887,53 +682,6 @@ class UnitOfTime(StrEnum):
YEARS = "y"
-_DEPRECATED_TIME_MICROSECONDS: Final = DeprecatedConstantEnum(
- UnitOfTime.MICROSECONDS,
- "2025.1",
-)
-"""Deprecated: please use UnitOfTime.MICROSECONDS."""
-_DEPRECATED_TIME_MILLISECONDS: Final = DeprecatedConstantEnum(
- UnitOfTime.MILLISECONDS,
- "2025.1",
-)
-"""Deprecated: please use UnitOfTime.MILLISECONDS."""
-_DEPRECATED_TIME_SECONDS: Final = DeprecatedConstantEnum(
- UnitOfTime.SECONDS,
- "2025.1",
-)
-"""Deprecated: please use UnitOfTime.SECONDS."""
-_DEPRECATED_TIME_MINUTES: Final = DeprecatedConstantEnum(
- UnitOfTime.MINUTES,
- "2025.1",
-)
-"""Deprecated: please use UnitOfTime.MINUTES."""
-_DEPRECATED_TIME_HOURS: Final = DeprecatedConstantEnum(
- UnitOfTime.HOURS,
- "2025.1",
-)
-"""Deprecated: please use UnitOfTime.HOURS."""
-_DEPRECATED_TIME_DAYS: Final = DeprecatedConstantEnum(
- UnitOfTime.DAYS,
- "2025.1",
-)
-"""Deprecated: please use UnitOfTime.DAYS."""
-_DEPRECATED_TIME_WEEKS: Final = DeprecatedConstantEnum(
- UnitOfTime.WEEKS,
- "2025.1",
-)
-"""Deprecated: please use UnitOfTime.WEEKS."""
-_DEPRECATED_TIME_MONTHS: Final = DeprecatedConstantEnum(
- UnitOfTime.MONTHS,
- "2025.1",
-)
-"""Deprecated: please use UnitOfTime.MONTHS."""
-_DEPRECATED_TIME_YEARS: Final = DeprecatedConstantEnum(
- UnitOfTime.YEARS,
- "2025.1",
-)
-"""Deprecated: please use UnitOfTime.YEARS."""
-
-
# Length units
class UnitOfLength(StrEnum):
"""Length units."""
@@ -949,48 +697,6 @@ class UnitOfLength(StrEnum):
NAUTICAL_MILES = "nmi"
-_DEPRECATED_LENGTH_MILLIMETERS: Final = DeprecatedConstantEnum(
- UnitOfLength.MILLIMETERS,
- "2025.1",
-)
-"""Deprecated: please use UnitOfLength.MILLIMETERS."""
-_DEPRECATED_LENGTH_CENTIMETERS: Final = DeprecatedConstantEnum(
- UnitOfLength.CENTIMETERS,
- "2025.1",
-)
-"""Deprecated: please use UnitOfLength.CENTIMETERS."""
-_DEPRECATED_LENGTH_METERS: Final = DeprecatedConstantEnum(
- UnitOfLength.METERS,
- "2025.1",
-)
-"""Deprecated: please use UnitOfLength.METERS."""
-_DEPRECATED_LENGTH_KILOMETERS: Final = DeprecatedConstantEnum(
- UnitOfLength.KILOMETERS,
- "2025.1",
-)
-"""Deprecated: please use UnitOfLength.KILOMETERS."""
-_DEPRECATED_LENGTH_INCHES: Final = DeprecatedConstantEnum(
- UnitOfLength.INCHES,
- "2025.1",
-)
-"""Deprecated: please use UnitOfLength.INCHES."""
-_DEPRECATED_LENGTH_FEET: Final = DeprecatedConstantEnum(
- UnitOfLength.FEET,
- "2025.1",
-)
-"""Deprecated: please use UnitOfLength.FEET."""
-_DEPRECATED_LENGTH_YARD: Final = DeprecatedConstantEnum(
- UnitOfLength.YARDS,
- "2025.1",
-)
-"""Deprecated: please use UnitOfLength.YARDS."""
-_DEPRECATED_LENGTH_MILES: Final = DeprecatedConstantEnum(
- UnitOfLength.MILES,
- "2025.1",
-)
-"""Deprecated: please use UnitOfLength.MILES."""
-
-
# Frequency units
class UnitOfFrequency(StrEnum):
"""Frequency units."""
@@ -1001,28 +707,6 @@ class UnitOfFrequency(StrEnum):
GIGAHERTZ = "GHz"
-_DEPRECATED_FREQUENCY_HERTZ: Final = DeprecatedConstantEnum(
- UnitOfFrequency.HERTZ,
- "2025.1",
-)
-"""Deprecated: please use UnitOfFrequency.HERTZ"""
-_DEPRECATED_FREQUENCY_KILOHERTZ: Final = DeprecatedConstantEnum(
- UnitOfFrequency.KILOHERTZ,
- "2025.1",
-)
-"""Deprecated: please use UnitOfFrequency.KILOHERTZ"""
-_DEPRECATED_FREQUENCY_MEGAHERTZ: Final = DeprecatedConstantEnum(
- UnitOfFrequency.MEGAHERTZ,
- "2025.1",
-)
-"""Deprecated: please use UnitOfFrequency.MEGAHERTZ"""
-_DEPRECATED_FREQUENCY_GIGAHERTZ: Final = DeprecatedConstantEnum(
- UnitOfFrequency.GIGAHERTZ,
- "2025.1",
-)
-"""Deprecated: please use UnitOfFrequency.GIGAHERTZ"""
-
-
# Pressure units
class UnitOfPressure(StrEnum):
"""Pressure units."""
@@ -1038,53 +722,6 @@ class UnitOfPressure(StrEnum):
PSI = "psi"
-_DEPRECATED_PRESSURE_PA: Final = DeprecatedConstantEnum(
- UnitOfPressure.PA,
- "2025.1",
-)
-"""Deprecated: please use UnitOfPressure.PA"""
-_DEPRECATED_PRESSURE_HPA: Final = DeprecatedConstantEnum(
- UnitOfPressure.HPA,
- "2025.1",
-)
-"""Deprecated: please use UnitOfPressure.HPA"""
-_DEPRECATED_PRESSURE_KPA: Final = DeprecatedConstantEnum(
- UnitOfPressure.KPA,
- "2025.1",
-)
-"""Deprecated: please use UnitOfPressure.KPA"""
-_DEPRECATED_PRESSURE_BAR: Final = DeprecatedConstantEnum(
- UnitOfPressure.BAR,
- "2025.1",
-)
-"""Deprecated: please use UnitOfPressure.BAR"""
-_DEPRECATED_PRESSURE_CBAR: Final = DeprecatedConstantEnum(
- UnitOfPressure.CBAR,
- "2025.1",
-)
-"""Deprecated: please use UnitOfPressure.CBAR"""
-_DEPRECATED_PRESSURE_MBAR: Final = DeprecatedConstantEnum(
- UnitOfPressure.MBAR,
- "2025.1",
-)
-"""Deprecated: please use UnitOfPressure.MBAR"""
-_DEPRECATED_PRESSURE_MMHG: Final = DeprecatedConstantEnum(
- UnitOfPressure.MMHG,
- "2025.1",
-)
-"""Deprecated: please use UnitOfPressure.MMHG"""
-_DEPRECATED_PRESSURE_INHG: Final = DeprecatedConstantEnum(
- UnitOfPressure.INHG,
- "2025.1",
-)
-"""Deprecated: please use UnitOfPressure.INHG"""
-_DEPRECATED_PRESSURE_PSI: Final = DeprecatedConstantEnum(
- UnitOfPressure.PSI,
- "2025.1",
-)
-"""Deprecated: please use UnitOfPressure.PSI"""
-
-
# Sound pressure units
class UnitOfSoundPressure(StrEnum):
"""Sound pressure units."""
@@ -1093,18 +730,6 @@ class UnitOfSoundPressure(StrEnum):
WEIGHTED_DECIBEL_A = "dBA"
-_DEPRECATED_SOUND_PRESSURE_DB: Final = DeprecatedConstantEnum(
- UnitOfSoundPressure.DECIBEL,
- "2025.1",
-)
-"""Deprecated: please use UnitOfSoundPressure.DECIBEL"""
-_DEPRECATED_SOUND_PRESSURE_WEIGHTED_DBA: Final = DeprecatedConstantEnum(
- UnitOfSoundPressure.WEIGHTED_DECIBEL_A,
- "2025.1",
-)
-"""Deprecated: please use UnitOfSoundPressure.WEIGHTED_DECIBEL_A"""
-
-
# Volume units
class UnitOfVolume(StrEnum):
"""Volume units."""
@@ -1124,39 +749,6 @@ class UnitOfVolume(StrEnum):
British/Imperial fluid ounces are not yet supported"""
-_DEPRECATED_VOLUME_LITERS: Final = DeprecatedConstantEnum(
- UnitOfVolume.LITERS,
- "2025.1",
-)
-"""Deprecated: please use UnitOfVolume.LITERS"""
-_DEPRECATED_VOLUME_MILLILITERS: Final = DeprecatedConstantEnum(
- UnitOfVolume.MILLILITERS,
- "2025.1",
-)
-"""Deprecated: please use UnitOfVolume.MILLILITERS"""
-_DEPRECATED_VOLUME_CUBIC_METERS: Final = DeprecatedConstantEnum(
- UnitOfVolume.CUBIC_METERS,
- "2025.1",
-)
-"""Deprecated: please use UnitOfVolume.CUBIC_METERS"""
-_DEPRECATED_VOLUME_CUBIC_FEET: Final = DeprecatedConstantEnum(
- UnitOfVolume.CUBIC_FEET,
- "2025.1",
-)
-"""Deprecated: please use UnitOfVolume.CUBIC_FEET"""
-
-_DEPRECATED_VOLUME_GALLONS: Final = DeprecatedConstantEnum(
- UnitOfVolume.GALLONS,
- "2025.1",
-)
-"""Deprecated: please use UnitOfVolume.GALLONS"""
-_DEPRECATED_VOLUME_FLUID_OUNCE: Final = DeprecatedConstantEnum(
- UnitOfVolume.FLUID_OUNCES,
- "2025.1",
-)
-"""Deprecated: please use UnitOfVolume.FLUID_OUNCES"""
-
-
# Volume Flow Rate units
class UnitOfVolumeFlowRate(StrEnum):
"""Volume flow rate units."""
@@ -1165,21 +757,29 @@ class UnitOfVolumeFlowRate(StrEnum):
CUBIC_FEET_PER_MINUTE = "ft³/min"
LITERS_PER_MINUTE = "L/min"
GALLONS_PER_MINUTE = "gal/min"
+ MILLILITERS_PER_SECOND = "mL/s"
-_DEPRECATED_VOLUME_FLOW_RATE_CUBIC_METERS_PER_HOUR: Final = DeprecatedConstantEnum(
- UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR,
- "2025.1",
+class UnitOfArea(StrEnum):
+ """Area units."""
+
+ SQUARE_METERS = "m²"
+ SQUARE_CENTIMETERS = "cm²"
+ SQUARE_KILOMETERS = "km²"
+ SQUARE_MILLIMETERS = "mm²"
+ SQUARE_INCHES = "in²"
+ SQUARE_FEET = "ft²"
+ SQUARE_YARDS = "yd²"
+ SQUARE_MILES = "mi²"
+ ACRES = "ac"
+ HECTARES = "ha"
+
+
+_DEPRECATED_AREA_SQUARE_METERS: Final = DeprecatedConstantEnum(
+ UnitOfArea.SQUARE_METERS,
+ "2025.12",
)
-"""Deprecated: please use UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR"""
-_DEPRECATED_VOLUME_FLOW_RATE_CUBIC_FEET_PER_MINUTE: Final = DeprecatedConstantEnum(
- UnitOfVolumeFlowRate.CUBIC_FEET_PER_MINUTE,
- "2025.1",
-)
-"""Deprecated: please use UnitOfVolumeFlowRate.CUBIC_FEET_PER_MINUTE"""
-
-# Area units
-AREA_SQUARE_METERS: Final = "m²"
+"""Deprecated: please use UnitOfArea.SQUARE_METERS"""
# Mass units
@@ -1195,38 +795,6 @@ class UnitOfMass(StrEnum):
STONES = "st"
-_DEPRECATED_MASS_GRAMS: Final = DeprecatedConstantEnum(
- UnitOfMass.GRAMS,
- "2025.1",
-)
-"""Deprecated: please use UnitOfMass.GRAMS"""
-_DEPRECATED_MASS_KILOGRAMS: Final = DeprecatedConstantEnum(
- UnitOfMass.KILOGRAMS,
- "2025.1",
-)
-"""Deprecated: please use UnitOfMass.KILOGRAMS"""
-_DEPRECATED_MASS_MILLIGRAMS: Final = DeprecatedConstantEnum(
- UnitOfMass.MILLIGRAMS,
- "2025.1",
-)
-"""Deprecated: please use UnitOfMass.MILLIGRAMS"""
-_DEPRECATED_MASS_MICROGRAMS: Final = DeprecatedConstantEnum(
- UnitOfMass.MICROGRAMS,
- "2025.1",
-)
-"""Deprecated: please use UnitOfMass.MICROGRAMS"""
-_DEPRECATED_MASS_OUNCES: Final = DeprecatedConstantEnum(
- UnitOfMass.OUNCES,
- "2025.1",
-)
-"""Deprecated: please use UnitOfMass.OUNCES"""
-_DEPRECATED_MASS_POUNDS: Final = DeprecatedConstantEnum(
- UnitOfMass.POUNDS,
- "2025.1",
-)
-"""Deprecated: please use UnitOfMass.POUNDS"""
-
-
class UnitOfConductivity(
StrEnum,
metaclass=EnumWithDeprecatedMembers,
@@ -1278,19 +846,6 @@ class UnitOfIrradiance(StrEnum):
BTUS_PER_HOUR_SQUARE_FOOT = "BTU/(h⋅ft²)"
-# Irradiation units
-_DEPRECATED_IRRADIATION_WATTS_PER_SQUARE_METER: Final = DeprecatedConstantEnum(
- UnitOfIrradiance.WATTS_PER_SQUARE_METER,
- "2025.1",
-)
-"""Deprecated: please use UnitOfIrradiance.WATTS_PER_SQUARE_METER"""
-_DEPRECATED_IRRADIATION_BTUS_PER_HOUR_SQUARE_FOOT: Final = DeprecatedConstantEnum(
- UnitOfIrradiance.BTUS_PER_HOUR_SQUARE_FOOT,
- "2025.1",
-)
-"""Deprecated: please use UnitOfIrradiance.BTUS_PER_HOUR_SQUARE_FOOT"""
-
-
class UnitOfVolumetricFlux(StrEnum):
"""Volumetric flux, commonly used for precipitation intensity.
@@ -1328,27 +883,6 @@ class UnitOfPrecipitationDepth(StrEnum):
"""Derived from cm³/cm²"""
-# Precipitation units
-_DEPRECATED_PRECIPITATION_INCHES: Final = DeprecatedConstantEnum(
- UnitOfPrecipitationDepth.INCHES, "2025.1"
-)
-"""Deprecated: please use UnitOfPrecipitationDepth.INCHES"""
-_DEPRECATED_PRECIPITATION_MILLIMETERS: Final = DeprecatedConstantEnum(
- UnitOfPrecipitationDepth.MILLIMETERS,
- "2025.1",
-)
-"""Deprecated: please use UnitOfPrecipitationDepth.MILLIMETERS"""
-_DEPRECATED_PRECIPITATION_MILLIMETERS_PER_HOUR: Final = DeprecatedConstantEnum(
- UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR,
- "2025.1",
-)
-"""Deprecated: please use UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR"""
-_DEPRECATED_PRECIPITATION_INCHES_PER_HOUR: Final = DeprecatedConstantEnum(
- UnitOfVolumetricFlux.INCHES_PER_HOUR,
- "2025.1",
-)
-"""Deprecated: please use UnitOfVolumetricFlux.INCHES_PER_HOUR"""
-
# Concentration units
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER: Final = "µg/m³"
CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER: Final = "mg/m³"
@@ -1358,6 +892,13 @@ CONCENTRATION_PARTS_PER_MILLION: Final = "ppm"
CONCENTRATION_PARTS_PER_BILLION: Final = "ppb"
+class UnitOfBloodGlucoseConcentration(StrEnum):
+ """Blood glucose concentration units."""
+
+ MILLIGRAMS_PER_DECILITER = "mg/dL"
+ MILLIMOLE_PER_LITER = "mmol/L"
+
+
# Speed units
class UnitOfSpeed(StrEnum):
"""Speed units."""
@@ -1372,45 +913,6 @@ class UnitOfSpeed(StrEnum):
MILLIMETERS_PER_SECOND = "mm/s"
-_DEPRECATED_SPEED_FEET_PER_SECOND: Final = DeprecatedConstantEnum(
- UnitOfSpeed.FEET_PER_SECOND, "2025.1"
-)
-"""Deprecated: please use UnitOfSpeed.FEET_PER_SECOND"""
-_DEPRECATED_SPEED_METERS_PER_SECOND: Final = DeprecatedConstantEnum(
- UnitOfSpeed.METERS_PER_SECOND, "2025.1"
-)
-"""Deprecated: please use UnitOfSpeed.METERS_PER_SECOND"""
-_DEPRECATED_SPEED_KILOMETERS_PER_HOUR: Final = DeprecatedConstantEnum(
- UnitOfSpeed.KILOMETERS_PER_HOUR,
- "2025.1",
-)
-"""Deprecated: please use UnitOfSpeed.KILOMETERS_PER_HOUR"""
-_DEPRECATED_SPEED_KNOTS: Final = DeprecatedConstantEnum(UnitOfSpeed.KNOTS, "2025.1")
-"""Deprecated: please use UnitOfSpeed.KNOTS"""
-_DEPRECATED_SPEED_MILES_PER_HOUR: Final = DeprecatedConstantEnum(
- UnitOfSpeed.MILES_PER_HOUR, "2025.1"
-)
-"""Deprecated: please use UnitOfSpeed.MILES_PER_HOUR"""
-
-_DEPRECATED_SPEED_MILLIMETERS_PER_DAY: Final = DeprecatedConstantEnum(
- UnitOfVolumetricFlux.MILLIMETERS_PER_DAY,
- "2025.1",
-)
-"""Deprecated: please use UnitOfVolumetricFlux.MILLIMETERS_PER_DAY"""
-
-_DEPRECATED_SPEED_INCHES_PER_DAY: Final = DeprecatedConstantEnum(
- UnitOfVolumetricFlux.INCHES_PER_DAY,
- "2025.1",
-)
-"""Deprecated: please use UnitOfVolumetricFlux.INCHES_PER_DAY"""
-
-_DEPRECATED_SPEED_INCHES_PER_HOUR: Final = DeprecatedConstantEnum(
- UnitOfVolumetricFlux.INCHES_PER_HOUR,
- "2025.1",
-)
-"""Deprecated: please use UnitOfVolumetricFlux.INCHES_PER_HOUR"""
-
-
# Signal_strength units
SIGNAL_STRENGTH_DECIBELS: Final = "dB"
SIGNAL_STRENGTH_DECIBELS_MILLIWATT: Final = "dBm"
@@ -1443,90 +945,6 @@ class UnitOfInformation(StrEnum):
YOBIBYTES = "YiB"
-_DEPRECATED_DATA_BITS: Final = DeprecatedConstantEnum(UnitOfInformation.BITS, "2025.1")
-"""Deprecated: please use UnitOfInformation.BITS"""
-_DEPRECATED_DATA_KILOBITS: Final = DeprecatedConstantEnum(
- UnitOfInformation.KILOBITS, "2025.1"
-)
-"""Deprecated: please use UnitOfInformation.KILOBITS"""
-_DEPRECATED_DATA_MEGABITS: Final = DeprecatedConstantEnum(
- UnitOfInformation.MEGABITS, "2025.1"
-)
-"""Deprecated: please use UnitOfInformation.MEGABITS"""
-_DEPRECATED_DATA_GIGABITS: Final = DeprecatedConstantEnum(
- UnitOfInformation.GIGABITS, "2025.1"
-)
-"""Deprecated: please use UnitOfInformation.GIGABITS"""
-_DEPRECATED_DATA_BYTES: Final = DeprecatedConstantEnum(
- UnitOfInformation.BYTES, "2025.1"
-)
-"""Deprecated: please use UnitOfInformation.BYTES"""
-_DEPRECATED_DATA_KILOBYTES: Final = DeprecatedConstantEnum(
- UnitOfInformation.KILOBYTES, "2025.1"
-)
-"""Deprecated: please use UnitOfInformation.KILOBYTES"""
-_DEPRECATED_DATA_MEGABYTES: Final = DeprecatedConstantEnum(
- UnitOfInformation.MEGABYTES, "2025.1"
-)
-"""Deprecated: please use UnitOfInformation.MEGABYTES"""
-_DEPRECATED_DATA_GIGABYTES: Final = DeprecatedConstantEnum(
- UnitOfInformation.GIGABYTES, "2025.1"
-)
-"""Deprecated: please use UnitOfInformation.GIGABYTES"""
-_DEPRECATED_DATA_TERABYTES: Final = DeprecatedConstantEnum(
- UnitOfInformation.TERABYTES, "2025.1"
-)
-"""Deprecated: please use UnitOfInformation.TERABYTES"""
-_DEPRECATED_DATA_PETABYTES: Final = DeprecatedConstantEnum(
- UnitOfInformation.PETABYTES, "2025.1"
-)
-"""Deprecated: please use UnitOfInformation.PETABYTES"""
-_DEPRECATED_DATA_EXABYTES: Final = DeprecatedConstantEnum(
- UnitOfInformation.EXABYTES, "2025.1"
-)
-"""Deprecated: please use UnitOfInformation.EXABYTES"""
-_DEPRECATED_DATA_ZETTABYTES: Final = DeprecatedConstantEnum(
- UnitOfInformation.ZETTABYTES, "2025.1"
-)
-"""Deprecated: please use UnitOfInformation.ZETTABYTES"""
-_DEPRECATED_DATA_YOTTABYTES: Final = DeprecatedConstantEnum(
- UnitOfInformation.YOTTABYTES, "2025.1"
-)
-"""Deprecated: please use UnitOfInformation.YOTTABYTES"""
-_DEPRECATED_DATA_KIBIBYTES: Final = DeprecatedConstantEnum(
- UnitOfInformation.KIBIBYTES, "2025.1"
-)
-"""Deprecated: please use UnitOfInformation.KIBIBYTES"""
-_DEPRECATED_DATA_MEBIBYTES: Final = DeprecatedConstantEnum(
- UnitOfInformation.MEBIBYTES, "2025.1"
-)
-"""Deprecated: please use UnitOfInformation.MEBIBYTES"""
-_DEPRECATED_DATA_GIBIBYTES: Final = DeprecatedConstantEnum(
- UnitOfInformation.GIBIBYTES, "2025.1"
-)
-"""Deprecated: please use UnitOfInformation.GIBIBYTES"""
-_DEPRECATED_DATA_TEBIBYTES: Final = DeprecatedConstantEnum(
- UnitOfInformation.TEBIBYTES, "2025.1"
-)
-"""Deprecated: please use UnitOfInformation.TEBIBYTES"""
-_DEPRECATED_DATA_PEBIBYTES: Final = DeprecatedConstantEnum(
- UnitOfInformation.PEBIBYTES, "2025.1"
-)
-"""Deprecated: please use UnitOfInformation.PEBIBYTES"""
-_DEPRECATED_DATA_EXBIBYTES: Final = DeprecatedConstantEnum(
- UnitOfInformation.EXBIBYTES, "2025.1"
-)
-"""Deprecated: please use UnitOfInformation.EXBIBYTES"""
-_DEPRECATED_DATA_ZEBIBYTES: Final = DeprecatedConstantEnum(
- UnitOfInformation.ZEBIBYTES, "2025.1"
-)
-"""Deprecated: please use UnitOfInformation.ZEBIBYTES"""
-_DEPRECATED_DATA_YOBIBYTES: Final = DeprecatedConstantEnum(
- UnitOfInformation.YOBIBYTES, "2025.1"
-)
-"""Deprecated: please use UnitOfInformation.YOBIBYTES"""
-
-
# Data_rate units
class UnitOfDataRate(StrEnum):
"""Data rate units."""
@@ -1544,63 +962,6 @@ class UnitOfDataRate(StrEnum):
GIBIBYTES_PER_SECOND = "GiB/s"
-_DEPRECATED_DATA_RATE_BITS_PER_SECOND: Final = DeprecatedConstantEnum(
- UnitOfDataRate.BITS_PER_SECOND,
- "2025.1",
-)
-"""Deprecated: please use UnitOfDataRate.BITS_PER_SECOND"""
-_DEPRECATED_DATA_RATE_KILOBITS_PER_SECOND: Final = DeprecatedConstantEnum(
- UnitOfDataRate.KILOBITS_PER_SECOND,
- "2025.1",
-)
-"""Deprecated: please use UnitOfDataRate.KILOBITS_PER_SECOND"""
-_DEPRECATED_DATA_RATE_MEGABITS_PER_SECOND: Final = DeprecatedConstantEnum(
- UnitOfDataRate.MEGABITS_PER_SECOND,
- "2025.1",
-)
-"""Deprecated: please use UnitOfDataRate.MEGABITS_PER_SECOND"""
-_DEPRECATED_DATA_RATE_GIGABITS_PER_SECOND: Final = DeprecatedConstantEnum(
- UnitOfDataRate.GIGABITS_PER_SECOND,
- "2025.1",
-)
-"""Deprecated: please use UnitOfDataRate.GIGABITS_PER_SECOND"""
-_DEPRECATED_DATA_RATE_BYTES_PER_SECOND: Final = DeprecatedConstantEnum(
- UnitOfDataRate.BYTES_PER_SECOND,
- "2025.1",
-)
-"""Deprecated: please use UnitOfDataRate.BYTES_PER_SECOND"""
-_DEPRECATED_DATA_RATE_KILOBYTES_PER_SECOND: Final = DeprecatedConstantEnum(
- UnitOfDataRate.KILOBYTES_PER_SECOND,
- "2025.1",
-)
-"""Deprecated: please use UnitOfDataRate.KILOBYTES_PER_SECOND"""
-_DEPRECATED_DATA_RATE_MEGABYTES_PER_SECOND: Final = DeprecatedConstantEnum(
- UnitOfDataRate.MEGABYTES_PER_SECOND,
- "2025.1",
-)
-"""Deprecated: please use UnitOfDataRate.MEGABYTES_PER_SECOND"""
-_DEPRECATED_DATA_RATE_GIGABYTES_PER_SECOND: Final = DeprecatedConstantEnum(
- UnitOfDataRate.GIGABYTES_PER_SECOND,
- "2025.1",
-)
-"""Deprecated: please use UnitOfDataRate.GIGABYTES_PER_SECOND"""
-_DEPRECATED_DATA_RATE_KIBIBYTES_PER_SECOND: Final = DeprecatedConstantEnum(
- UnitOfDataRate.KIBIBYTES_PER_SECOND,
- "2025.1",
-)
-"""Deprecated: please use UnitOfDataRate.KIBIBYTES_PER_SECOND"""
-_DEPRECATED_DATA_RATE_MEBIBYTES_PER_SECOND: Final = DeprecatedConstantEnum(
- UnitOfDataRate.MEBIBYTES_PER_SECOND,
- "2025.1",
-)
-"""Deprecated: please use UnitOfDataRate.MEBIBYTES_PER_SECOND"""
-_DEPRECATED_DATA_RATE_GIBIBYTES_PER_SECOND: Final = DeprecatedConstantEnum(
- UnitOfDataRate.GIBIBYTES_PER_SECOND,
- "2025.1",
-)
-"""Deprecated: please use UnitOfDataRate.GIBIBYTES_PER_SECOND"""
-
-
# States
COMPRESSED_STATE_STATE: Final = "s"
COMPRESSED_STATE_ATTRIBUTES: Final = "a"
@@ -1696,6 +1057,7 @@ RESTART_EXIT_CODE: Final = 100
UNIT_NOT_RECOGNIZED_TEMPLATE: Final = "{} is not a recognized {} unit."
LENGTH: Final = "length"
+AREA: Final = "area"
MASS: Final = "mass"
PRESSURE: Final = "pressure"
VOLUME: Final = "volume"
@@ -1733,14 +1095,6 @@ class EntityCategory(StrEnum):
DIAGNOSTIC = "diagnostic"
-# ENTITY_CATEGOR* below are deprecated as of 2021.12
-# use the EntityCategory enum instead.
-_DEPRECATED_ENTITY_CATEGORY_CONFIG: Final = DeprecatedConstantEnum(
- EntityCategory.CONFIG, "2025.1"
-)
-_DEPRECATED_ENTITY_CATEGORY_DIAGNOSTIC: Final = DeprecatedConstantEnum(
- EntityCategory.DIAGNOSTIC, "2025.1"
-)
ENTITY_CATEGORIES: Final[list[str]] = [cls.value for cls in EntityCategory]
# The ID of the Home Assistant Media Player Cast App
diff --git a/homeassistant/core.py b/homeassistant/core.py
index cdfb5570b44..da7a206b14e 100644
--- a/homeassistant/core.py
+++ b/homeassistant/core.py
@@ -84,7 +84,6 @@ from .exceptions import (
)
from .helpers.deprecation import (
DeferredDeprecatedAlias,
- DeprecatedConstantEnum,
EnumWithDeprecatedMembers,
all_with_deprecated_constants,
check_if_deprecated_constant,
@@ -177,14 +176,6 @@ class EventStateReportedData(EventStateEventData):
old_last_reported: datetime.datetime
-# SOURCE_* are deprecated as of Home Assistant 2022.2, use ConfigSource instead
-_DEPRECATED_SOURCE_DISCOVERED = DeprecatedConstantEnum(
- ConfigSource.DISCOVERED, "2025.1"
-)
-_DEPRECATED_SOURCE_STORAGE = DeprecatedConstantEnum(ConfigSource.STORAGE, "2025.1")
-_DEPRECATED_SOURCE_YAML = DeprecatedConstantEnum(ConfigSource.YAML, "2025.1")
-
-
def _deprecated_core_config() -> Any:
# pylint: disable-next=import-outside-toplevel
from . import core_config
@@ -657,11 +648,11 @@ class HomeAssistant:
from .helpers import frame # pylint: disable=import-outside-toplevel
frame.report_usage(
- "calls `async_add_job`, which is deprecated and will be removed in Home "
- "Assistant 2025.4; Please review "
+ "calls `async_add_job`, which should be reviewed against "
"https://developers.home-assistant.io/blog/2024/03/13/deprecate_add_run_job"
" for replacement options",
core_behavior=frame.ReportBehavior.LOG,
+ breaks_in_ha_version="2025.4",
)
if target is None:
@@ -713,11 +704,11 @@ class HomeAssistant:
from .helpers import frame # pylint: disable=import-outside-toplevel
frame.report_usage(
- "calls `async_add_hass_job`, which is deprecated and will be removed in Home "
- "Assistant 2025.5; Please review "
+ "calls `async_add_hass_job`, which should be reviewed against "
"https://developers.home-assistant.io/blog/2024/04/07/deprecate_add_hass_job"
" for replacement options",
core_behavior=frame.ReportBehavior.LOG,
+ breaks_in_ha_version="2025.5",
)
return self._async_add_hass_job(hassjob, *args, background=background)
@@ -987,11 +978,11 @@ class HomeAssistant:
from .helpers import frame # pylint: disable=import-outside-toplevel
frame.report_usage(
- "calls `async_run_job`, which is deprecated and will be removed in Home "
- "Assistant 2025.4; Please review "
+ "calls `async_run_job`, which should be reviewed against "
"https://developers.home-assistant.io/blog/2024/03/13/deprecate_add_run_job"
" for replacement options",
core_behavior=frame.ReportBehavior.LOG,
+ breaks_in_ha_version="2025.4",
)
if asyncio.iscoroutine(target):
@@ -1636,9 +1627,9 @@ class EventBus:
from .helpers import frame # pylint: disable=import-outside-toplevel
frame.report_usage(
- "calls `async_listen` with run_immediately, which is"
- " deprecated and will be removed in Home Assistant 2025.5",
+ "calls `async_listen` with run_immediately",
core_behavior=frame.ReportBehavior.LOG,
+ breaks_in_ha_version="2025.5",
)
if event_filter is not None and not is_callback_check_partial(event_filter):
@@ -1706,9 +1697,9 @@ class EventBus:
from .helpers import frame # pylint: disable=import-outside-toplevel
frame.report_usage(
- "calls `async_listen_once` with run_immediately, which is "
- "deprecated and will be removed in Home Assistant 2025.5",
+ "calls `async_listen_once` with run_immediately",
core_behavior=frame.ReportBehavior.LOG,
+ breaks_in_ha_version="2025.5",
)
one_time_listener: _OneTimeListener[_DataT] = _OneTimeListener(
@@ -2441,10 +2432,11 @@ class Service:
class ServiceCall:
"""Representation of a call to a service."""
- __slots__ = ("domain", "service", "data", "context", "return_response")
+ __slots__ = ("hass", "domain", "service", "data", "context", "return_response")
def __init__(
self,
+ hass: HomeAssistant,
domain: str,
service: str,
data: dict[str, Any] | None = None,
@@ -2452,6 +2444,7 @@ class ServiceCall:
return_response: bool = False,
) -> None:
"""Initialize a service call."""
+ self.hass = hass
self.domain = domain
self.service = service
self.data = ReadOnlyDict(data or {})
@@ -2777,7 +2770,7 @@ class ServiceRegistry:
processed_data = service_data
service_call = ServiceCall(
- domain, service, processed_data, context, return_response
+ self._hass, domain, service, processed_data, context, return_response
)
self._hass.bus.async_fire_internal(
diff --git a/homeassistant/core_config.py b/homeassistant/core_config.py
index 5c773c57bc4..f080705fced 100644
--- a/homeassistant/core_config.py
+++ b/homeassistant/core_config.py
@@ -68,11 +68,11 @@ from .util.hass_dict import HassKey
from .util.package import is_docker_env
from .util.unit_system import (
_CONF_UNIT_SYSTEM_IMPERIAL,
+ _CONF_UNIT_SYSTEM_METRIC,
_CONF_UNIT_SYSTEM_US_CUSTOMARY,
METRIC_SYSTEM,
UnitSystem,
get_unit_system,
- validate_unit_system,
)
# Typing imports that create a circular dependency
@@ -188,6 +188,26 @@ _CUSTOMIZE_CONFIG_SCHEMA = vol.Schema(
)
+def _raise_issue_if_imperial_unit_system(
+ hass: HomeAssistant, config: dict[str, Any]
+) -> dict[str, Any]:
+ if config.get(CONF_UNIT_SYSTEM) == _CONF_UNIT_SYSTEM_IMPERIAL:
+ ir.async_create_issue(
+ hass,
+ HOMEASSISTANT_DOMAIN,
+ "imperial_unit_system",
+ is_fixable=False,
+ learn_more_url="homeassistant://config/general",
+ severity=ir.IssueSeverity.WARNING,
+ translation_key="imperial_unit_system",
+ )
+ config[CONF_UNIT_SYSTEM] = _CONF_UNIT_SYSTEM_US_CUSTOMARY
+ else:
+ ir.async_delete_issue(hass, HOMEASSISTANT_DOMAIN, "imperial_unit_system")
+
+ return config
+
+
def _raise_issue_if_historic_currency(hass: HomeAssistant, currency: str) -> None:
if currency not in HISTORIC_CURRENCIES:
ir.async_delete_issue(hass, HOMEASSISTANT_DOMAIN, "historic_currency")
@@ -249,7 +269,11 @@ CORE_CONFIG_SCHEMA = vol.All(
CONF_ELEVATION: vol.Coerce(int),
CONF_RADIUS: cv.positive_int,
vol.Remove(CONF_TEMPERATURE_UNIT): cv.temperature_unit,
- CONF_UNIT_SYSTEM: validate_unit_system,
+ CONF_UNIT_SYSTEM: vol.Any(
+ _CONF_UNIT_SYSTEM_METRIC,
+ _CONF_UNIT_SYSTEM_US_CUSTOMARY,
+ _CONF_UNIT_SYSTEM_IMPERIAL,
+ ),
CONF_TIME_ZONE: cv.time_zone,
vol.Optional(CONF_INTERNAL_URL): cv.url,
vol.Optional(CONF_EXTERNAL_URL): cv.url,
@@ -333,6 +357,9 @@ async def async_process_ha_core_config(hass: HomeAssistant, config: dict) -> Non
# so we need to run it in an executor job.
config = await hass.async_add_executor_job(CORE_CONFIG_SCHEMA, config)
+ # Check if we need to raise an issue for imperial unit system
+ config = _raise_issue_if_imperial_unit_system(hass, config)
+
# Only load auth during startup.
if not hasattr(hass, "auth"):
if (auth_conf := config.get(CONF_AUTH_PROVIDERS)) is None:
@@ -482,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")
@@ -696,10 +723,10 @@ class Config:
It will be removed in Home Assistant 2025.6.
"""
report_usage(
- "set the time zone using set_time_zone instead of async_set_time_zone"
- " which will stop working in Home Assistant 2025.6",
+ "sets the time zone using set_time_zone instead of async_set_time_zone",
core_integration_behavior=ReportBehavior.ERROR,
custom_integration_behavior=ReportBehavior.ERROR,
+ breaks_in_ha_version="2025.6",
)
if time_zone := dt_util.get_time_zone(time_zone_str):
self.time_zone = time_zone_str
diff --git a/homeassistant/data_entry_flow.py b/homeassistant/data_entry_flow.py
index 9d041c9b8d3..6df77443e7e 100644
--- a/homeassistant/data_entry_flow.py
+++ b/homeassistant/data_entry_flow.py
@@ -10,7 +10,6 @@ from contextlib import suppress
import copy
from dataclasses import dataclass
from enum import StrEnum
-from functools import partial
import logging
from types import MappingProxyType
from typing import Any, Generic, Required, TypedDict, cast
@@ -20,12 +19,6 @@ import voluptuous as vol
from .core import HomeAssistant, callback
from .exceptions import HomeAssistantError
-from .helpers.deprecation import (
- DeprecatedConstantEnum,
- all_with_deprecated_constants,
- check_if_deprecated_constant,
- dir_with_deprecated_constants,
-)
from .helpers.frame import ReportBehavior, report_usage
from .loader import async_suggest_report_issue
from .util import uuid as uuid_util
@@ -46,26 +39,6 @@ class FlowResultType(StrEnum):
MENU = "menu"
-# RESULT_TYPE_* is deprecated, to be removed in 2025.1
-_DEPRECATED_RESULT_TYPE_FORM = DeprecatedConstantEnum(FlowResultType.FORM, "2025.1")
-_DEPRECATED_RESULT_TYPE_CREATE_ENTRY = DeprecatedConstantEnum(
- FlowResultType.CREATE_ENTRY, "2025.1"
-)
-_DEPRECATED_RESULT_TYPE_ABORT = DeprecatedConstantEnum(FlowResultType.ABORT, "2025.1")
-_DEPRECATED_RESULT_TYPE_EXTERNAL_STEP = DeprecatedConstantEnum(
- FlowResultType.EXTERNAL_STEP, "2025.1"
-)
-_DEPRECATED_RESULT_TYPE_EXTERNAL_STEP_DONE = DeprecatedConstantEnum(
- FlowResultType.EXTERNAL_STEP_DONE, "2025.1"
-)
-_DEPRECATED_RESULT_TYPE_SHOW_PROGRESS = DeprecatedConstantEnum(
- FlowResultType.SHOW_PROGRESS, "2025.1"
-)
-_DEPRECATED_RESULT_TYPE_SHOW_PROGRESS_DONE = DeprecatedConstantEnum(
- FlowResultType.SHOW_PROGRESS_DONE, "2025.1"
-)
-_DEPRECATED_RESULT_TYPE_MENU = DeprecatedConstantEnum(FlowResultType.MENU, "2025.1")
-
# Event that is fired when a flow is progressed via external or progress source.
EVENT_DATA_ENTRY_FLOW_PROGRESSED = "data_entry_flow_progressed"
@@ -126,6 +99,7 @@ class InvalidData(vol.Invalid):
schema_errors: dict[str, Any],
**kwargs: Any,
) -> None:
+ """Initialize an invalid data exception."""
super().__init__(message, path, error_message, **kwargs)
self.schema_errors = schema_errors
@@ -155,7 +129,7 @@ class FlowResult(TypedDict, Generic[_FlowContextT, _HandlerT], total=False):
context: _FlowContextT
data_schema: vol.Schema | None
data: Mapping[str, Any]
- description_placeholders: Mapping[str, str | None] | None
+ description_placeholders: Mapping[str, str] | None
description: str | None
errors: dict[str, str] | None
extra: str
@@ -531,11 +505,9 @@ class FlowManager(abc.ABC, Generic[_FlowContextT, _FlowResultT, _HandlerT]):
if not isinstance(result["type"], FlowResultType):
result["type"] = FlowResultType(result["type"]) # type: ignore[unreachable]
report_usage(
- (
- "does not use FlowResultType enum for data entry flow result type. "
- "This is deprecated and will stop working in Home Assistant 2025.1"
- ),
+ "does not use FlowResultType enum for data entry flow result type",
core_behavior=ReportBehavior.LOG,
+ breaks_in_ha_version="2025.1",
)
if (
@@ -705,7 +677,7 @@ class FlowHandler(Generic[_FlowContextT, _FlowResultT, _HandlerT]):
step_id: str | None = None,
data_schema: vol.Schema | None = None,
errors: dict[str, str] | None = None,
- description_placeholders: Mapping[str, str | None] | None = None,
+ description_placeholders: Mapping[str, str] | None = None,
last_step: bool | None = None,
preview: str | None = None,
) -> _FlowResultT:
@@ -931,11 +903,3 @@ class section:
def __call__(self, value: Any) -> Any:
"""Validate input."""
return self.schema(value)
-
-
-# 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/exceptions.py b/homeassistant/exceptions.py
index f308cbc5cd8..85fe55277fa 100644
--- a/homeassistant/exceptions.py
+++ b/homeassistant/exceptions.py
@@ -270,6 +270,25 @@ class ServiceNotFound(ServiceValidationError):
self.generate_message = True
+class ServiceNotSupported(ServiceValidationError):
+ """Raised when an entity action is not supported."""
+
+ def __init__(self, domain: str, service: str, entity_id: str) -> None:
+ """Initialize ServiceNotSupported exception."""
+ super().__init__(
+ translation_domain="homeassistant",
+ translation_key="service_not_supported",
+ translation_placeholders={
+ "domain": domain,
+ "service": service,
+ "entity_id": entity_id,
+ },
+ )
+ self.domain = domain
+ self.service = service
+ self.generate_message = True
+
+
class MaxLengthExceeded(HomeAssistantError):
"""Raised when a property value has exceeded the max character length."""
diff --git a/homeassistant/generated/bluetooth.py b/homeassistant/generated/bluetooth.py
index c4612898cb2..e592d14405b 100644
--- a/homeassistant/generated/bluetooth.py
+++ b/homeassistant/generated/bluetooth.py
@@ -8,6 +8,26 @@ from __future__ import annotations
from typing import Final
BLUETOOTH: Final[list[dict[str, bool | str | int | list[int]]]] = [
+ {
+ "domain": "acaia",
+ "manufacturer_id": 16962,
+ },
+ {
+ "domain": "acaia",
+ "local_name": "ACAIA*",
+ },
+ {
+ "domain": "acaia",
+ "local_name": "PYXIS-*",
+ },
+ {
+ "domain": "acaia",
+ "local_name": "LUNAR-*",
+ },
+ {
+ "domain": "acaia",
+ "local_name": "PROCHBT001",
+ },
{
"domain": "airthings_ble",
"manufacturer_id": 820,
@@ -413,6 +433,10 @@ BLUETOOTH: Final[list[dict[str, bool | str | int | list[int]]]] = [
"domain": "led_ble",
"local_name": "MELK-*",
},
+ {
+ "domain": "led_ble",
+ "local_name": "LD-0003",
+ },
{
"domain": "medcom_ble",
"service_uuid": "39b31fec-b63a-4ef7-b163-a7317872007f",
diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py
index cbd30b560ce..14061d2e960 100644
--- a/homeassistant/generated/config_flows.py
+++ b/homeassistant/generated/config_flows.py
@@ -24,6 +24,7 @@ FLOWS = {
],
"integration": [
"abode",
+ "acaia",
"accuweather",
"acmeda",
"adax",
@@ -112,6 +113,7 @@ FLOWS = {
"color_extractor",
"comelit",
"control4",
+ "cookidoo",
"coolmaster",
"cpuspeed",
"crownstone",
@@ -153,6 +155,7 @@ FLOWS = {
"ecowitt",
"edl21",
"efergy",
+ "eheimdigital",
"electrasmart",
"electric_kiwi",
"elevenlabs",
@@ -252,6 +255,7 @@ FLOWS = {
"holiday",
"home_connect",
"homeassistant_sky_connect",
+ "homee",
"homekit",
"homekit_controller",
"homematicip_cloud",
@@ -275,6 +279,7 @@ FLOWS = {
"icloud",
"idasen_desk",
"ifttt",
+ "igloohome",
"imap",
"imgw_pib",
"improv_ble",
@@ -295,6 +300,7 @@ FLOWS = {
"iss",
"ista_ecotrend",
"isy994",
+ "ituran",
"izone",
"jellyfin",
"jewish_calendar",
@@ -351,6 +357,7 @@ FLOWS = {
"mailgun",
"mastodon",
"matter",
+ "mcp_server",
"mealie",
"meater",
"medcom_ble",
@@ -405,6 +412,7 @@ FLOWS = {
"nibe_heatpump",
"nice_go",
"nightscout",
+ "niko_home_control",
"nina",
"nmap_tracker",
"nobo_hub",
@@ -418,6 +426,7 @@ FLOWS = {
"nzbget",
"obihai",
"octoprint",
+ "ohme",
"ollama",
"omnilogic",
"oncue",
@@ -441,11 +450,13 @@ FLOWS = {
"otp",
"ourgroceries",
"overkiz",
+ "overseerr",
"ovo_energy",
"owntracks",
"p1_monitor",
"palazzetti",
"panasonic_viera",
+ "peblar",
"peco",
"pegel_online",
"permobil",
@@ -459,6 +470,7 @@ FLOWS = {
"plum_lightpad",
"point",
"poolsense",
+ "powerfox",
"powerwall",
"private_ble_device",
"profiler",
@@ -537,9 +549,11 @@ FLOWS = {
"simplefin",
"simplepush",
"simplisafe",
+ "sky_remote",
"skybell",
"slack",
"sleepiq",
+ "slide_local",
"slimproto",
"sma",
"smappee",
@@ -570,7 +584,6 @@ FLOWS = {
"starlink",
"steam_online",
"steamist",
- "stookalert",
"stookwijzer",
"streamlabswater",
"subaru",
@@ -662,6 +675,7 @@ FLOWS = {
"wake_on_lan",
"wallbox",
"waqi",
+ "watergate",
"watttime",
"waze_travel_time",
"weatherflow",
diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py
index cd20b88b285..67531ceced8 100644
--- a/homeassistant/generated/dhcp.py
+++ b/homeassistant/generated/dhcp.py
@@ -209,6 +209,10 @@ DHCP: Final[list[dict[str, str | bool]]] = [
"domain": "fully_kiosk",
"registered_devices": True,
},
+ {
+ "domain": "fyta",
+ "hostname": "fyta*",
+ },
{
"domain": "goalzero",
"registered_devices": True,
@@ -236,6 +240,10 @@ DHCP: Final[list[dict[str, str | bool]]] = [
"hostname": "guardian*",
"macaddress": "30AEA4*",
},
+ {
+ "domain": "homewizard",
+ "registered_devices": True,
+ },
{
"domain": "hunterdouglas_powerview",
"registered_devices": True,
@@ -276,6 +284,10 @@ DHCP: Final[list[dict[str, str | bool]]] = [
"hostname": "polisy*",
"macaddress": "000DB9*",
},
+ {
+ "domain": "lamarzocco",
+ "registered_devices": True,
+ },
{
"domain": "lamarzocco",
"hostname": "gs[0-9][0-9][0-9][0-9][0-9][0-9]",
@@ -379,6 +391,15 @@ DHCP: Final[list[dict[str, str | bool]]] = [
"hostname": "gateway*",
"macaddress": "F8811A*",
},
+ {
+ "domain": "palazzetti",
+ "hostname": "connbox*",
+ "macaddress": "40F3857*",
+ },
+ {
+ "domain": "palazzetti",
+ "registered_devices": True,
+ },
{
"domain": "powerwall",
"hostname": "1118431-*",
@@ -1098,6 +1119,10 @@ DHCP: Final[list[dict[str, str | bool]]] = [
"domain": "vicare",
"macaddress": "B87424*",
},
+ {
+ "domain": "withings",
+ "macaddress": "0024E4*",
+ },
{
"domain": "wiz",
"registered_devices": True,
diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json
index a1fdb9478f3..768443c36ee 100644
--- a/homeassistant/generated/integrations.json
+++ b/homeassistant/generated/integrations.json
@@ -9,7 +9,14 @@
"name": "Abode",
"integration_type": "hub",
"config_flow": true,
- "iot_class": "cloud_push"
+ "iot_class": "cloud_push",
+ "single_config_entry": true
+ },
+ "acaia": {
+ "name": "Acaia",
+ "integration_type": "device",
+ "config_flow": true,
+ "iot_class": "local_push"
},
"accuweather": {
"name": "AccuWeather",
@@ -599,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",
@@ -865,7 +866,8 @@
"name": "Canary",
"integration_type": "hub",
"config_flow": true,
- "iot_class": "cloud_polling"
+ "iot_class": "cloud_polling",
+ "single_config_entry": true
},
"ccm15": {
"name": "Midea ccm15 AC Controller",
@@ -1036,6 +1038,12 @@
"config_flow": true,
"iot_class": "local_polling"
},
+ "cookidoo": {
+ "name": "Cookidoo",
+ "integration_type": "service",
+ "config_flow": true,
+ "iot_class": "cloud_polling"
+ },
"coolmaster": {
"name": "CoolMasterNet",
"integration_type": "hub",
@@ -1050,7 +1058,8 @@
"cpuspeed": {
"integration_type": "device",
"config_flow": true,
- "iot_class": "local_push"
+ "iot_class": "local_push",
+ "single_config_entry": true
},
"cribl": {
"name": "Cribl",
@@ -1135,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",
@@ -1365,12 +1379,6 @@
"config_flow": true,
"iot_class": "local_push"
},
- "dte_energy_bridge": {
- "name": "DTE Energy Bridge",
- "integration_type": "hub",
- "config_flow": false,
- "iot_class": "local_polling"
- },
"dublin_bus_transport": {
"name": "Dublin Bus",
"integration_type": "hub",
@@ -1432,9 +1440,10 @@
},
"easyenergy": {
"name": "easyEnergy",
- "integration_type": "hub",
+ "integration_type": "service",
"config_flow": true,
- "iot_class": "cloud_polling"
+ "iot_class": "cloud_polling",
+ "single_config_entry": true
},
"ebox": {
"name": "EBox",
@@ -1515,6 +1524,12 @@
"config_flow": false,
"iot_class": "local_polling"
},
+ "eheimdigital": {
+ "name": "EHEIM Digital",
+ "integration_type": "hub",
+ "config_flow": true,
+ "iot_class": "local_polling"
+ },
"electrasmart": {
"name": "Electra Smart",
"integration_type": "hub",
@@ -1638,9 +1653,10 @@
},
"energyzero": {
"name": "EnergyZero",
- "integration_type": "hub",
+ "integration_type": "service",
"config_flow": true,
- "iot_class": "cloud_polling"
+ "iot_class": "cloud_polling",
+ "single_config_entry": true
},
"enigma2": {
"name": "Enigma2 (OpenWebif)",
@@ -2458,6 +2474,11 @@
"config_flow": false,
"iot_class": "local_polling"
},
+ "harvey": {
+ "name": "Harvey",
+ "integration_type": "virtual",
+ "supported_by": "aquacell"
+ },
"hassio": {
"name": "Home Assistant Supervisor",
"integration_type": "hub",
@@ -2584,6 +2605,12 @@
"integration_type": "virtual",
"supported_by": "netatmo"
},
+ "homee": {
+ "name": "Homee",
+ "integration_type": "hub",
+ "config_flow": true,
+ "iot_class": "local_push"
+ },
"homematic": {
"name": "Homematic",
"integrations": {
@@ -2773,6 +2800,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",
@@ -2801,7 +2834,7 @@
"name": "IKEA TR\u00c5DFRI"
},
"idasen_desk": {
- "integration_type": "hub",
+ "integration_type": "device",
"config_flow": true,
"iot_class": "local_push",
"name": "IKEA Idasen Desk"
@@ -2980,6 +3013,12 @@
"config_flow": true,
"iot_class": "local_push"
},
+ "ituran": {
+ "name": "Ituran",
+ "integration_type": "hub",
+ "config_flow": true,
+ "iot_class": "cloud_polling"
+ },
"izone": {
"name": "iZone",
"integration_type": "hub",
@@ -3562,6 +3601,13 @@
"config_flow": true,
"iot_class": "local_push"
},
+ "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",
@@ -3690,6 +3736,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,
@@ -4136,8 +4188,8 @@
"niko_home_control": {
"name": "Niko Home Control",
"integration_type": "hub",
- "config_flow": false,
- "iot_class": "local_polling"
+ "config_flow": true,
+ "iot_class": "local_push"
},
"nilu": {
"name": "Norwegian Institute for Air Research (NILU)",
@@ -4308,6 +4360,12 @@
"config_flow": false,
"iot_class": "cloud_polling"
},
+ "ohme": {
+ "name": "Ohme",
+ "integration_type": "device",
+ "config_flow": true,
+ "iot_class": "cloud_polling"
+ },
"ollama": {
"name": "Ollama",
"integration_type": "service",
@@ -4536,6 +4594,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",
@@ -4589,6 +4653,12 @@
"integration_type": "virtual",
"supported_by": "upb"
},
+ "peblar": {
+ "name": "Peblar",
+ "integration_type": "device",
+ "config_flow": true,
+ "iot_class": "local_polling"
+ },
"peco": {
"name": "PECO Outage Counter",
"integration_type": "hub",
@@ -4754,6 +4824,12 @@
"integration_type": "virtual",
"supported_by": "opower"
},
+ "powerfox": {
+ "name": "Powerfox",
+ "integration_type": "hub",
+ "config_flow": true,
+ "iot_class": "cloud_polling"
+ },
"private_ble_device": {
"name": "Private BLE Device",
"integration_type": "hub",
@@ -5084,7 +5160,8 @@
"name": "Refoss",
"integration_type": "hub",
"config_flow": true,
- "iot_class": "local_polling"
+ "iot_class": "local_polling",
+ "single_config_entry": true
},
"rejseplanen": {
"name": "Rejseplanen",
@@ -5111,7 +5188,7 @@
"iot_class": "local_polling"
},
"reolink": {
- "name": "Reolink IP NVR/camera",
+ "name": "Reolink",
"integration_type": "hub",
"config_flow": true,
"iot_class": "local_push"
@@ -5590,12 +5667,6 @@
"integration_type": "virtual",
"supported_by": "overkiz"
},
- "simulated": {
- "name": "Simulated",
- "integration_type": "hub",
- "config_flow": false,
- "iot_class": "local_polling"
- },
"sinch": {
"name": "Sinch SMS",
"integration_type": "hub",
@@ -5608,11 +5679,22 @@
"config_flow": false,
"iot_class": "local_push"
},
- "sky_hub": {
- "name": "Sky Hub",
- "integration_type": "hub",
- "config_flow": false,
- "iot_class": "local_polling"
+ "sky": {
+ "name": "Sky",
+ "integrations": {
+ "sky_hub": {
+ "integration_type": "hub",
+ "config_flow": false,
+ "iot_class": "local_polling",
+ "name": "Sky Hub"
+ },
+ "sky_remote": {
+ "integration_type": "device",
+ "config_flow": true,
+ "iot_class": "assumed_state",
+ "name": "Sky Remote Control"
+ }
+ }
},
"skybeacon": {
"name": "Skybeacon",
@@ -5640,9 +5722,20 @@
},
"slide": {
"name": "Slide",
- "integration_type": "hub",
- "config_flow": false,
- "iot_class": "cloud_polling"
+ "integrations": {
+ "slide": {
+ "integration_type": "hub",
+ "config_flow": false,
+ "iot_class": "cloud_polling",
+ "name": "Slide"
+ },
+ "slide_local": {
+ "integration_type": "device",
+ "config_flow": true,
+ "iot_class": "local_polling",
+ "name": "Slide Local"
+ }
+ }
},
"slimproto": {
"name": "SlimProto (Squeezebox players)",
@@ -5937,12 +6030,6 @@
"config_flow": false,
"iot_class": "local_polling"
},
- "stookalert": {
- "name": "RIVM Stookalert",
- "integration_type": "service",
- "config_flow": true,
- "iot_class": "cloud_polling"
- },
"stookwijzer": {
"name": "Stookwijzer",
"integration_type": "service",
@@ -6101,7 +6188,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",
@@ -6884,6 +6972,12 @@
"config_flow": false,
"iot_class": "cloud_polling"
},
+ "watergate": {
+ "name": "Watergate",
+ "integration_type": "hub",
+ "config_flow": true,
+ "iot_class": "local_push"
+ },
"watttime": {
"name": "WattTime",
"integration_type": "service",
@@ -7311,7 +7405,6 @@
"iot_class": "calculated"
},
"filter": {
- "name": "Filter",
"integration_type": "helper",
"config_flow": false,
"iot_class": "local_push"
@@ -7442,6 +7535,7 @@
"emulated_roku",
"energenie_power_sockets",
"filesize",
+ "filter",
"garages_amsterdam",
"generic",
"generic_hygrostat",
diff --git a/homeassistant/generated/ssdp.py b/homeassistant/generated/ssdp.py
index 9ed65bab868..89d1aa30cb8 100644
--- a/homeassistant/generated/ssdp.py
+++ b/homeassistant/generated/ssdp.py
@@ -224,6 +224,44 @@ SSDP = {
"manufacturer": "The OctoPrint Project",
},
],
+ "onkyo": [
+ {
+ "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1",
+ "manufacturer": "ONKYO",
+ },
+ {
+ "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:2",
+ "manufacturer": "ONKYO",
+ },
+ {
+ "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:3",
+ "manufacturer": "ONKYO",
+ },
+ {
+ "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1",
+ "manufacturer": "Onkyo & Pioneer Corporation",
+ },
+ {
+ "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:2",
+ "manufacturer": "Onkyo & Pioneer Corporation",
+ },
+ {
+ "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:3",
+ "manufacturer": "Onkyo & Pioneer Corporation",
+ },
+ {
+ "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1",
+ "manufacturer": "Pioneer",
+ },
+ {
+ "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:2",
+ "manufacturer": "Pioneer",
+ },
+ {
+ "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:3",
+ "manufacturer": "Pioneer",
+ },
+ ],
"openhome": [
{
"st": "urn:av-openhome-org:service:Product:1",
diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py
index 1fbd6337fdb..0766e1ce011 100644
--- a/homeassistant/generated/zeroconf.py
+++ b/homeassistant/generated/zeroconf.py
@@ -92,6 +92,10 @@ HOMEKIT = {
"always_discover": True,
"domain": "lifx",
},
+ "LIFX Colour": {
+ "always_discover": True,
+ "domain": "lifx",
+ },
"LIFX DLCOL": {
"always_discover": True,
"domain": "lifx",
@@ -140,6 +144,10 @@ HOMEKIT = {
"always_discover": True,
"domain": "lifx",
},
+ "LIFX Permanent Outdoor": {
+ "always_discover": True,
+ "domain": "lifx",
+ },
"LIFX Pls": {
"always_discover": True,
"domain": "lifx",
@@ -164,6 +172,10 @@ HOMEKIT = {
"always_discover": True,
"domain": "lifx",
},
+ "LIFX Tube": {
+ "always_discover": True,
+ "domain": "lifx",
+ },
"LIFX White": {
"always_discover": True,
"domain": "lifx",
@@ -524,6 +536,10 @@ ZEROCONF = {
"domain": "bosch_shc",
"name": "bosch shc*",
},
+ {
+ "domain": "eheimdigital",
+ "name": "eheimdigital._http._tcp.local.",
+ },
{
"domain": "lektrico",
"name": "lektrico*",
@@ -542,6 +558,14 @@ ZEROCONF = {
"manufacturer": "nettigo",
},
},
+ {
+ "domain": "peblar",
+ "name": "pblr-*",
+ },
+ {
+ "domain": "powerfox",
+ "name": "powerfox*",
+ },
{
"domain": "pure_energie",
"name": "smartbridge*",
@@ -558,6 +582,10 @@ ZEROCONF = {
"domain": "shelly",
"name": "shelly*",
},
+ {
+ "domain": "slide_local",
+ "name": "slide*",
+ },
{
"domain": "synology_dsm",
"properties": {
@@ -747,6 +775,11 @@ ZEROCONF = {
},
},
],
+ "_rio._tcp.local.": [
+ {
+ "domain": "russound_rio",
+ },
+ ],
"_sideplay._tcp.local.": [
{
"domain": "ecobee",
@@ -872,6 +905,12 @@ ZEROCONF = {
"name": "*zigate*",
},
],
+ "_zigbee-coordinator._tcp.local.": [
+ {
+ "domain": "zha",
+ "name": "*",
+ },
+ ],
"_zigstar_gw._tcp.local.": [
{
"domain": "zha",
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/condition.py b/homeassistant/helpers/condition.py
index 86965f86d40..5952e28a1eb 100644
--- a/homeassistant/helpers/condition.py
+++ b/homeassistant/helpers/condition.py
@@ -821,9 +821,15 @@ def time(
after_entity.attributes.get("minute", 59),
after_entity.attributes.get("second", 59),
)
- elif after_entity.attributes.get(
- ATTR_DEVICE_CLASS
- ) == SensorDeviceClass.TIMESTAMP and after_entity.state not in (
+ elif after_entity.domain == "time" and after_entity.state not in (
+ STATE_UNAVAILABLE,
+ STATE_UNKNOWN,
+ ):
+ after = datetime.strptime(after_entity.state, "%H:%M:%S").time()
+ elif (
+ after_entity.attributes.get(ATTR_DEVICE_CLASS)
+ == SensorDeviceClass.TIMESTAMP
+ ) and after_entity.state not in (
STATE_UNAVAILABLE,
STATE_UNKNOWN,
):
@@ -845,9 +851,15 @@ def time(
before_entity.attributes.get("minute", 59),
before_entity.attributes.get("second", 59),
)
- elif before_entity.attributes.get(
- ATTR_DEVICE_CLASS
- ) == SensorDeviceClass.TIMESTAMP and before_entity.state not in (
+ elif before_entity.domain == "time":
+ try:
+ before = datetime.strptime(before_entity.state, "%H:%M:%S").time()
+ except ValueError:
+ return False
+ elif (
+ before_entity.attributes.get(ATTR_DEVICE_CLASS)
+ == SensorDeviceClass.TIMESTAMP
+ ) and before_entity.state not in (
STATE_UNAVAILABLE,
STATE_UNKNOWN,
):
diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py
index 81ac10f86cc..3681e941eee 100644
--- a/homeassistant/helpers/config_validation.py
+++ b/homeassistant/helpers/config_validation.py
@@ -719,14 +719,14 @@ def template(value: Any | None) -> template_helper.Template:
raise vol.Invalid("template value should be a string")
if not (hass := _async_get_hass_or_none()):
# pylint: disable-next=import-outside-toplevel
- from .frame import report
+ from .frame import ReportBehavior, report_usage
- report(
+ report_usage(
(
"validates schema outside the event loop, "
"which will stop working in HA Core 2025.10"
),
- error_if_core=False,
+ core_behavior=ReportBehavior.LOG,
)
template_value = template_helper.Template(str(value), hass)
@@ -748,14 +748,14 @@ def dynamic_template(value: Any | None) -> template_helper.Template:
raise vol.Invalid("template value does not contain a dynamic template")
if not (hass := _async_get_hass_or_none()):
# pylint: disable-next=import-outside-toplevel
- from .frame import report
+ from .frame import ReportBehavior, report_usage
- report(
+ report_usage(
(
"validates schema outside the event loop, "
"which will stop working in HA Core 2025.10"
),
- error_if_core=False,
+ core_behavior=ReportBehavior.LOG,
)
template_value = template_helper.Template(str(value), hass)
@@ -1574,10 +1574,10 @@ TIME_CONDITION_SCHEMA = vol.All(
**CONDITION_BASE_SCHEMA,
vol.Required(CONF_CONDITION): "time",
vol.Optional("before"): vol.Any(
- time, vol.All(str, entity_domain(["input_datetime", "sensor"]))
+ time, vol.All(str, entity_domain(["input_datetime", "time", "sensor"]))
),
vol.Optional("after"): vol.Any(
- time, vol.All(str, entity_domain(["input_datetime", "sensor"]))
+ time, vol.All(str, entity_domain(["input_datetime", "time", "sensor"]))
),
vol.Optional("weekday"): weekdays,
}
diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py
index faf4257577d..981430f192d 100644
--- a/homeassistant/helpers/device_registry.py
+++ b/homeassistant/helpers/device_registry.py
@@ -6,7 +6,7 @@ from collections import defaultdict
from collections.abc import Mapping
from datetime import datetime
from enum import StrEnum
-from functools import lru_cache, partial
+from functools import lru_cache
import logging
import time
from typing import TYPE_CHECKING, Any, Literal, TypedDict
@@ -32,13 +32,7 @@ import homeassistant.util.uuid as uuid_util
from . import storage, translation
from .debounce import Debouncer
-from .deprecation import (
- DeprecatedConstantEnum,
- all_with_deprecated_constants,
- check_if_deprecated_constant,
- dir_with_deprecated_constants,
-)
-from .frame import report
+from .frame import ReportBehavior, report_usage
from .json import JSON_DUMP, find_paths_unserializable_data, json_bytes, json_fragment
from .registry import BaseRegistry, BaseRegistryItems, RegistryIndexType
from .singleton import singleton
@@ -86,16 +80,6 @@ class DeviceEntryDisabler(StrEnum):
USER = "user"
-# DISABLED_* are deprecated, to be removed in 2022.3
-_DEPRECATED_DISABLED_CONFIG_ENTRY = DeprecatedConstantEnum(
- DeviceEntryDisabler.CONFIG_ENTRY, "2025.1"
-)
-_DEPRECATED_DISABLED_INTEGRATION = DeprecatedConstantEnum(
- DeviceEntryDisabler.INTEGRATION, "2025.1"
-)
-_DEPRECATED_DISABLED_USER = DeprecatedConstantEnum(DeviceEntryDisabler.USER, "2025.1")
-
-
class DeviceInfo(TypedDict, total=False):
"""Entity device information for device registry."""
@@ -822,22 +806,19 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
name = default_name
if via_device is not None and via_device is not UNDEFINED:
- via = self.async_get_device(identifiers={via_device})
+ if (via := self.async_get_device(identifiers={via_device})) is None:
+ report_usage(
+ "calls `device_registry.async_get_or_create` referencing a "
+ f"non existing `via_device` {via_device}, "
+ f"with device info: {device_info}",
+ core_behavior=ReportBehavior.LOG,
+ breaks_in_ha_version="2025.12.0",
+ )
+
via_device_id: str | UndefinedType = via.id if via else UNDEFINED
else:
via_device_id = UNDEFINED
- if isinstance(entry_type, str) and not isinstance(entry_type, DeviceEntryType):
- report( # type: ignore[unreachable]
- (
- "uses str for device registry entry_type. This is deprecated and"
- " will stop working in Home Assistant 2022.3, it should be updated"
- " to use DeviceEntryType instead"
- ),
- error_if_core=False,
- )
- entry_type = DeviceEntryType(entry_type)
-
device = self.async_update_device(
device.id,
allow_collisions=True,
@@ -924,19 +905,6 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
"Cannot define both merge_identifiers and new_identifiers"
)
- if isinstance(disabled_by, str) and not isinstance(
- disabled_by, DeviceEntryDisabler
- ):
- report( # type: ignore[unreachable]
- (
- "uses str for device registry disabled_by. This is deprecated and"
- " will stop working in Home Assistant 2022.3, it should be updated"
- " to use DeviceEntryDisabler instead"
- ),
- error_if_core=False,
- )
- disabled_by = DeviceEntryDisabler(disabled_by)
-
if (
suggested_area is not None
and suggested_area is not UNDEFINED
@@ -1496,11 +1464,3 @@ def _normalize_connections(connections: set[tuple[str, str]]) -> set[tuple[str,
(key, format_mac(value)) if key == CONNECTION_NETWORK_MAC else (key, value)
for key, value in connections
}
-
-
-# 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/helpers/entity.py b/homeassistant/helpers/entity.py
index 1f77dd3f95c..19076c4edc0 100644
--- a/homeassistant/helpers/entity.py
+++ b/homeassistant/helpers/entity.py
@@ -647,6 +647,22 @@ class Entity(
f".{self.translation_key}.name"
)
+ @cached_property
+ def _unit_of_measurement_translation_key(self) -> str | None:
+ """Return translation key for unit of measurement."""
+ if self.translation_key is None:
+ return None
+ if self.platform is None:
+ raise ValueError(
+ f"Entity {type(self)} cannot have a translation key for "
+ "unit of measurement before being added to the entity platform"
+ )
+ platform = self.platform
+ return (
+ f"component.{platform.platform_name}.entity.{platform.domain}"
+ f".{self.translation_key}.unit_of_measurement"
+ )
+
def _substitute_name_placeholders(self, name: str) -> str:
"""Substitute placeholders in entity name."""
try:
diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py
index 62eed213b2a..0d7614c569c 100644
--- a/homeassistant/helpers/entity_platform.py
+++ b/homeassistant/helpers/entity_platform.py
@@ -145,6 +145,7 @@ class EntityPlatform:
self.platform_translations: dict[str, str] = {}
self.object_id_component_translations: dict[str, str] = {}
self.object_id_platform_translations: dict[str, str] = {}
+ self.default_language_platform_translations: dict[str, str] = {}
self._tasks: list[asyncio.Task[None]] = []
# Stop tracking tasks after setup is completed
self._setup_complete = False
@@ -480,6 +481,14 @@ class EntityPlatform:
self.object_id_platform_translations = await self._async_get_translations(
object_id_language, "entity", self.platform_name
)
+ if config_language == languages.DEFAULT_LANGUAGE:
+ self.default_language_platform_translations = self.platform_translations
+ else:
+ self.default_language_platform_translations = (
+ await self._async_get_translations(
+ languages.DEFAULT_LANGUAGE, "entity", self.platform_name
+ )
+ )
def _schedule_add_entities(
self, new_entities: Iterable[Entity], update_before_add: bool = False
diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py
index 9d50b7ae83b..6b6becd4dd3 100644
--- a/homeassistant/helpers/entity_registry.py
+++ b/homeassistant/helpers/entity_registry.py
@@ -648,6 +648,7 @@ def _validate_item(
domain: str,
platform: str,
*,
+ device_id: str | None | UndefinedType = None,
disabled_by: RegistryEntryDisabler | None | UndefinedType = None,
entity_category: EntityCategory | None | UndefinedType = None,
hidden_by: RegistryEntryHider | None | UndefinedType = None,
@@ -671,6 +672,10 @@ def _validate_item(
unique_id,
report_issue,
)
+ 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
@@ -859,6 +864,7 @@ class EntityRegistry(BaseRegistry):
self.hass,
domain,
platform,
+ device_id=device_id,
disabled_by=disabled_by,
entity_category=entity_category,
hidden_by=hidden_by,
@@ -1090,6 +1096,7 @@ class EntityRegistry(BaseRegistry):
self.hass,
old.domain,
old.platform,
+ 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 02ea8103192..72a4ef3c050 100644
--- a/homeassistant/helpers/event.py
+++ b/homeassistant/helpers/event.py
@@ -90,7 +90,6 @@ RANDOM_MICROSECOND_MIN = 50000
RANDOM_MICROSECOND_MAX = 500000
_TypedDictT = TypeVar("_TypedDictT", bound=Mapping[str, Any])
-_StateEventDataT = TypeVar("_StateEventDataT", bound=EventStateEventData)
@dataclass(slots=True, frozen=True)
@@ -224,10 +223,10 @@ def async_track_state_change(
Must be run within the event loop.
"""
- frame.report(
+ frame.report_usage(
"calls `async_track_state_change` instead of `async_track_state_change_event`"
" which is deprecated and will be removed in Home Assistant 2025.5",
- error_if_core=False,
+ core_behavior=frame.ReportBehavior.LOG,
)
if from_state is not None:
@@ -333,7 +332,7 @@ def async_track_state_change_event(
@callback
-def _async_dispatch_entity_id_event_soon(
+def _async_dispatch_entity_id_event_soon[_StateEventDataT: EventStateEventData](
hass: HomeAssistant,
callbacks: dict[str, list[HassJob[[Event[_StateEventDataT]], Any]]],
event: Event[_StateEventDataT],
@@ -343,7 +342,7 @@ def _async_dispatch_entity_id_event_soon(
@callback
-def _async_dispatch_entity_id_event(
+def _async_dispatch_entity_id_event[_StateEventDataT: EventStateEventData](
hass: HomeAssistant,
callbacks: dict[str, list[HassJob[[Event[_StateEventDataT]], Any]]],
event: Event[_StateEventDataT],
@@ -363,7 +362,7 @@ def _async_dispatch_entity_id_event(
@callback
-def _async_state_filter(
+def _async_state_filter[_StateEventDataT: EventStateEventData](
hass: HomeAssistant,
callbacks: dict[str, list[HassJob[[Event[_StateEventDataT]], Any]]],
event_data: _StateEventDataT,
@@ -996,15 +995,10 @@ class TrackTemplateResultInfo:
if track_template_.template.hass:
continue
- # pylint: disable-next=import-outside-toplevel
- from .frame import report
-
- report(
- (
- "calls async_track_template_result with template without hass, "
- "which will stop working in HA Core 2025.10"
- ),
- error_if_core=False,
+ frame.report_usage(
+ "calls async_track_template_result with template without hass",
+ core_behavior=frame.ReportBehavior.LOG,
+ breaks_in_ha_version="2025.10",
)
track_template_.template.hass = hass
diff --git a/homeassistant/helpers/frame.py b/homeassistant/helpers/frame.py
index eda98099713..6d03ae4ffd2 100644
--- a/homeassistant/helpers/frame.py
+++ b/homeassistant/helpers/frame.py
@@ -15,9 +15,13 @@ from typing import Any, cast
from propcache import cached_property
-from homeassistant.core import async_get_hass_or_none
+from homeassistant.core import HomeAssistant, async_get_hass_or_none
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.loader import async_suggest_report_issue
+from homeassistant.loader import (
+ Integration,
+ async_get_issue_integration,
+ async_suggest_report_issue,
+)
_LOGGER = logging.getLogger(__name__)
@@ -181,25 +185,52 @@ class ReportBehavior(enum.Enum):
def report_usage(
what: str,
*,
+ breaks_in_ha_version: str | None = None,
core_behavior: ReportBehavior = ReportBehavior.ERROR,
core_integration_behavior: ReportBehavior = ReportBehavior.LOG,
custom_integration_behavior: ReportBehavior = ReportBehavior.LOG,
exclude_integrations: set[str] | None = None,
+ integration_domain: str | None = None,
level: int = logging.WARNING,
) -> None:
"""Report incorrect code usage.
- Similar to `report` but allows more fine-grained reporting.
+ :param what: will be wrapped with "Detected that integration 'integration' {what}.
+ Please create a bug report at https://..."
+ :param breaks_in_ha_version: if set, the report will be adjusted to specify the
+ breaking version
+ :param exclude_integrations: skip specified integration when reviewing the stack.
+ If no integration is found, the core behavior will be applied
+ :param integration_domain: fallback for identifying the integration if the
+ frame is not found
"""
try:
integration_frame = get_integration_frame(
exclude_integrations=exclude_integrations
)
except MissingIntegrationFrame as err:
- msg = f"Detected code that {what}. Please report this issue."
+ if integration := async_get_issue_integration(
+ hass := async_get_hass_or_none(), integration_domain
+ ):
+ _report_integration_domain(
+ hass,
+ what,
+ breaks_in_ha_version,
+ integration,
+ core_integration_behavior,
+ custom_integration_behavior,
+ level,
+ )
+ return
+ msg = f"Detected code that {what}. Please report this issue"
if core_behavior is ReportBehavior.ERROR:
raise RuntimeError(msg) from err
if core_behavior is ReportBehavior.LOG:
+ if breaks_in_ha_version:
+ msg = (
+ f"Detected code that {what}. This will stop working in Home "
+ f"Assistant {breaks_in_ha_version}, please report this issue"
+ )
_LOGGER.warning(msg, stack_info=True)
return
@@ -208,18 +239,73 @@ def report_usage(
integration_behavior = custom_integration_behavior
if integration_behavior is not ReportBehavior.IGNORE:
- _report_integration(
- what, integration_frame, level, integration_behavior is ReportBehavior.ERROR
+ _report_integration_frame(
+ what,
+ breaks_in_ha_version,
+ integration_frame,
+ level,
+ integration_behavior is ReportBehavior.ERROR,
)
-def _report_integration(
+def _report_integration_domain(
+ hass: HomeAssistant | None,
what: str,
+ breaks_in_ha_version: str | None,
+ integration: Integration,
+ core_integration_behavior: ReportBehavior,
+ custom_integration_behavior: ReportBehavior,
+ level: int,
+) -> None:
+ """Report incorrect usage in an integration (identified via domain).
+
+ Async friendly.
+ """
+ integration_behavior = core_integration_behavior
+ if not integration.is_built_in:
+ integration_behavior = custom_integration_behavior
+
+ if integration_behavior is ReportBehavior.IGNORE:
+ return
+
+ # Keep track of integrations already reported to prevent flooding
+ key = f"{integration.domain}:{what}"
+ if (
+ integration_behavior is not ReportBehavior.ERROR
+ and key in _REPORTED_INTEGRATIONS
+ ):
+ return
+ _REPORTED_INTEGRATIONS.add(key)
+
+ report_issue = async_suggest_report_issue(hass, integration=integration)
+ integration_type = "" if integration.is_built_in else "custom "
+ _LOGGER.log(
+ level,
+ "Detected that %sintegration '%s' %s. %s %s",
+ integration_type,
+ integration.domain,
+ what,
+ f"This will stop working in Home Assistant {breaks_in_ha_version}, please"
+ if breaks_in_ha_version
+ else "Please",
+ report_issue,
+ )
+
+ if integration_behavior is ReportBehavior.ERROR:
+ raise RuntimeError(
+ f"Detected that {integration_type}integration "
+ f"'{integration.domain}' {what}. Please {report_issue}"
+ )
+
+
+def _report_integration_frame(
+ what: str,
+ breaks_in_ha_version: str | None,
integration_frame: IntegrationFrame,
level: int = logging.WARNING,
error: bool = False,
) -> None:
- """Report incorrect usage in an integration.
+ """Report incorrect usage in an integration (identified via frame).
Async friendly.
"""
@@ -237,13 +323,16 @@ def _report_integration(
integration_type = "custom " if integration_frame.custom_integration else ""
_LOGGER.log(
level,
- "Detected that %sintegration '%s' %s at %s, line %s: %s, please %s",
+ "Detected that %sintegration '%s' %s at %s, line %s: %s. %s %s",
integration_type,
integration_frame.integration,
what,
integration_frame.relative_filename,
integration_frame.line_number,
integration_frame.line,
+ f"This will stop working in Home Assistant {breaks_in_ha_version}, please"
+ if breaks_in_ha_version
+ else "Please",
report_issue,
)
if not error:
@@ -253,7 +342,7 @@ def _report_integration(
f"'{integration_frame.integration}' {what} at "
f"{integration_frame.relative_filename}, line "
f"{integration_frame.line_number}: {integration_frame.line}. "
- f"Please {report_issue}."
+ f"Please {report_issue}"
)
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/integration_platform.py b/homeassistant/helpers/integration_platform.py
index a3eb19657e8..4ded7444989 100644
--- a/homeassistant/helpers/integration_platform.py
+++ b/homeassistant/helpers/integration_platform.py
@@ -175,6 +175,9 @@ async def async_process_integration_platforms(
else:
integration_platforms = hass.data[DATA_INTEGRATION_PLATFORMS]
+ # Tell the loader that it should try to pre-load the integration
+ # for any future components that are loaded so we can reduce the
+ # amount of import executor usage.
async_register_preload_platform(hass, platform_name)
top_level_components = hass.config.top_level_components.copy()
process_job = HassJob(
@@ -187,10 +190,6 @@ async def async_process_integration_platforms(
integration_platform = IntegrationPlatform(
platform_name, process_job, top_level_components
)
- # Tell the loader that it should try to pre-load the integration
- # for any future components that are loaded so we can reduce the
- # amount of import executor usage.
- async_register_preload_platform(hass, platform_name)
integration_platforms.append(integration_platform)
if not top_level_components:
return
diff --git a/homeassistant/helpers/intent.py b/homeassistant/helpers/intent.py
index b38f769b302..468539f5a9d 100644
--- a/homeassistant/helpers/intent.py
+++ b/homeassistant/helpers/intent.py
@@ -49,6 +49,7 @@ INTENT_NEVERMIND = "HassNevermind"
INTENT_SET_POSITION = "HassSetPosition"
INTENT_START_TIMER = "HassStartTimer"
INTENT_CANCEL_TIMER = "HassCancelTimer"
+INTENT_CANCEL_ALL_TIMERS = "HassCancelAllTimers"
INTENT_INCREASE_TIMER = "HassIncreaseTimer"
INTENT_DECREASE_TIMER = "HassDecreaseTimer"
INTENT_PAUSE_TIMER = "HassPauseTimer"
diff --git a/homeassistant/helpers/llm.py b/homeassistant/helpers/llm.py
index d322810b0ef..38d80d5649d 100644
--- a/homeassistant/helpers/llm.py
+++ b/homeassistant/helpers/llm.py
@@ -22,15 +22,13 @@ from homeassistant.components.conversation import (
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
-from homeassistant.components.script import ATTR_VARIABLES, DOMAIN as SCRIPT_DOMAIN
+from homeassistant.components.script import DOMAIN as SCRIPT_DOMAIN
from homeassistant.components.weather import INTENT_GET_WEATHER
from homeassistant.const import (
ATTR_DOMAIN,
- ATTR_ENTITY_ID,
ATTR_SERVICE,
EVENT_HOMEASSISTANT_CLOSE,
EVENT_SERVICE_REMOVED,
- SERVICE_TURN_ON,
)
from homeassistant.core import Context, Event, HomeAssistant, callback, split_entity_id
from homeassistant.exceptions import HomeAssistantError
@@ -416,9 +414,7 @@ class AssistAPI(API):
):
continue
- script_tool = ScriptTool(self.hass, state.entity_id)
- if script_tool.parameters.schema:
- tools.append(script_tool)
+ tools.append(ScriptTool(self.hass, state.entity_id))
return tools
@@ -449,17 +445,13 @@ def _get_exposed_entities(
entities = {}
for state in hass.states.async_all():
- if not async_should_expose(hass, assistant, state.entity_id):
+ if (
+ not async_should_expose(hass, assistant, state.entity_id)
+ or state.domain == SCRIPT_DOMAIN
+ ):
continue
description: str | None = None
- if state.domain == SCRIPT_DOMAIN:
- description, parameters = _get_cached_script_parameters(
- hass, state.entity_id
- )
- if parameters.schema: # Only list scripts without input fields here
- continue
-
entity_entry = entity_registry.async_get(state.entity_id)
names = [state.name]
area_names = []
@@ -702,10 +694,9 @@ class ScriptTool(Tool):
script_entity_id: str,
) -> None:
"""Init the class."""
- self.name = split_entity_id(script_entity_id)[1]
+ self._object_id = self.name = split_entity_id(script_entity_id)[1]
if self.name[0].isdigit():
self.name = "_" + self.name
- self._entity_id = script_entity_id
self.description, self.parameters = _get_cached_script_parameters(
hass, script_entity_id
@@ -745,14 +736,13 @@ class ScriptTool(Tool):
floor = list(intent.find_floors(floor, floor_reg))[0].floor_id
tool_input.tool_args[field] = floor
- await hass.services.async_call(
+ result = await hass.services.async_call(
SCRIPT_DOMAIN,
- SERVICE_TURN_ON,
- {
- ATTR_ENTITY_ID: self._entity_id,
- ATTR_VARIABLES: tool_input.tool_args,
- },
+ self._object_id,
+ tool_input.tool_args,
context=llm_context.context,
+ blocking=True,
+ return_response=True,
)
- return {"success": True}
+ return {"success": True, "result": result}
diff --git a/homeassistant/helpers/restore_state.py b/homeassistant/helpers/restore_state.py
index a2b4b3a9b9a..fd1f84a85ff 100644
--- a/homeassistant/helpers/restore_state.py
+++ b/homeassistant/helpers/restore_state.py
@@ -17,7 +17,6 @@ from homeassistant.util.json import json_loads
from . import start
from .entity import Entity
from .event import async_track_time_interval
-from .frame import report
from .json import JSONEncoder
from .singleton import singleton
from .storage import Store
@@ -116,21 +115,6 @@ class RestoreStateData:
"""Dump states now."""
await async_get(hass).async_dump_states()
- @classmethod
- async def async_get_instance(cls, hass: HomeAssistant) -> RestoreStateData:
- """Return the instance of this class."""
- # Nothing should actually be calling this anymore, but we'll keep it
- # around for a while to avoid breaking custom components.
- #
- # In fact they should not be accessing this at all.
- report(
- "restore_state.RestoreStateData.async_get_instance is deprecated, "
- "and not intended to be called by custom components; Please"
- "refactor your code to use RestoreEntity instead;"
- " restore_state.async_get(hass) can be used in the meantime",
- )
- return async_get(hass)
-
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize the restore state data class."""
self.hass: HomeAssistant = hass
diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py
index 86dcd858c1b..a67ef60c799 100644
--- a/homeassistant/helpers/script.py
+++ b/homeassistant/helpers/script.py
@@ -473,13 +473,13 @@ class _ScriptRun:
script_execution_set("aborted")
except _StopScript as err:
script_execution_set("finished", err.response)
- response = err.response
# Let the _StopScript bubble up if this is a sub-script
if not self._script.top_level:
- # We already consumed the response, do not pass it on
- err.response = None
raise
+
+ response = err.response
+
except Exception:
script_execution_set("error")
raise
diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py
index 33e8f3d3d6e..35135010452 100644
--- a/homeassistant/helpers/service.py
+++ b/homeassistant/helpers/service.py
@@ -42,6 +42,7 @@ from homeassistant.core import (
)
from homeassistant.exceptions import (
HomeAssistantError,
+ ServiceNotSupported,
TemplateError,
Unauthorized,
UnknownUser,
@@ -986,9 +987,7 @@ async def entity_service_call(
):
# If entity explicitly referenced, raise an error
if referenced is not None and entity.entity_id in referenced.referenced:
- raise HomeAssistantError(
- f"Entity {entity.entity_id} does not support this service."
- )
+ raise ServiceNotSupported(call.domain, call.service, entity.entity_id)
continue
@@ -1277,14 +1276,12 @@ def async_register_entity_service(
schema = cv.make_entity_service_schema(schema)
elif not cv.is_entity_service_schema(schema):
# pylint: disable-next=import-outside-toplevel
- from .frame import report
+ from .frame import ReportBehavior, report_usage
- report(
- (
- "registers an entity service with a non entity service schema "
- "which will stop working in HA Core 2025.9"
- ),
- error_if_core=False,
+ report_usage(
+ "registers an entity service with a non entity service schema",
+ core_behavior=ReportBehavior.LOG,
+ breaks_in_ha_version="2025.9",
)
service_func: str | HassJob[..., Any]
diff --git a/homeassistant/helpers/service_info/mqtt.py b/homeassistant/helpers/service_info/mqtt.py
index 6ffc981ced1..a5284807617 100644
--- a/homeassistant/helpers/service_info/mqtt.py
+++ b/homeassistant/helpers/service_info/mqtt.py
@@ -4,7 +4,7 @@ from dataclasses import dataclass
from homeassistant.data_entry_flow import BaseServiceInfo
-type ReceivePayloadType = str | bytes
+type ReceivePayloadType = str | bytes | bytearray
@dataclass(slots=True)
diff --git a/homeassistant/helpers/system_info.py b/homeassistant/helpers/system_info.py
index df4c45cd5ed..df9679dcb08 100644
--- a/homeassistant/helpers/system_info.py
+++ b/homeassistant/helpers/system_info.py
@@ -5,7 +5,6 @@ from __future__ import annotations
from functools import cache
from getpass import getuser
import logging
-import os
import platform
from typing import TYPE_CHECKING, Any
@@ -13,6 +12,7 @@ from homeassistant.const import __version__ as current_version
from homeassistant.core import HomeAssistant
from homeassistant.loader import bind_hass
from homeassistant.util.package import is_docker_env, is_virtual_env
+from homeassistant.util.system_info import is_official_image
from .hassio import is_hassio
from .importlib import async_import_module
@@ -23,12 +23,6 @@ _LOGGER = logging.getLogger(__name__)
_DATA_MAC_VER = "system_info_mac_ver"
-@cache
-def is_official_image() -> bool:
- """Return True if Home Assistant is running in an official container."""
- return os.path.isfile("/OFFICIAL_IMAGE")
-
-
@singleton(_DATA_MAC_VER)
async def async_get_mac_ver(hass: HomeAssistant) -> str:
"""Return the macOS version."""
@@ -71,7 +65,10 @@ async def async_get_system_info(hass: HomeAssistant) -> dict[str, Any]:
try:
info_object["user"] = cached_get_user()
- except KeyError:
+ except (KeyError, OSError):
+ # OSError on python >= 3.13, KeyError on python < 3.13
+ # KeyError can be removed when 3.12 support is dropped
+ # see https://docs.python.org/3/whatsnew/3.13.html
info_object["user"] = None
if platform.system() == "Darwin":
diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py
index 753464c35d5..5b4a48bb07c 100644
--- a/homeassistant/helpers/template.py
+++ b/homeassistant/helpers/template.py
@@ -23,7 +23,16 @@ import statistics
from struct import error as StructError, pack, unpack_from
import sys
from types import CodeType, TracebackType
-from typing import Any, Concatenate, Literal, NoReturn, Self, cast, overload
+from typing import (
+ TYPE_CHECKING,
+ Any,
+ Concatenate,
+ Literal,
+ NoReturn,
+ Self,
+ cast,
+ overload,
+)
from urllib.parse import urlencode as urllib_urlencode
import weakref
@@ -88,6 +97,9 @@ from .singleton import singleton
from .translation import async_translate_state
from .typing import TemplateVarsType
+if TYPE_CHECKING:
+ from _typeshed import OptExcInfo
+
# mypy: allow-untyped-defs, no-check-untyped-defs
_LOGGER = logging.getLogger(__name__)
@@ -515,18 +527,16 @@ class Template:
will be non optional in Home Assistant Core 2025.10.
"""
# pylint: disable-next=import-outside-toplevel
- from .frame import report
+ from .frame import ReportBehavior, report_usage
if not isinstance(template, str):
raise TypeError("Expected template to be a string")
if not hass:
- report(
- (
- "creates a template object without passing hass, "
- "which will stop working in HA Core 2025.10"
- ),
- error_if_core=False,
+ report_usage(
+ "creates a template object without passing hass",
+ core_behavior=ReportBehavior.LOG,
+ breaks_in_ha_version="2025.10",
)
self.template: str = template.strip()
@@ -534,7 +544,7 @@ class Template:
self._compiled: jinja2.Template | None = None
self.hass = hass
self.is_static = not is_template_string(template)
- self._exc_info: sys._OptExcInfo | None = None
+ self._exc_info: OptExcInfo | None = None
self._limited: bool | None = None
self._strict: bool | None = None
self._log_fn: Callable[[int, str], None] | None = None
diff --git a/homeassistant/helpers/trigger_template_entity.py b/homeassistant/helpers/trigger_template_entity.py
index 7f8ad41d7bb..1486e33d6fa 100644
--- a/homeassistant/helpers/trigger_template_entity.py
+++ b/homeassistant/helpers/trigger_template_entity.py
@@ -30,7 +30,7 @@ from homeassistant.util.json import JSON_DECODE_EXCEPTIONS, json_loads
from . import config_validation as cv
from .entity import Entity
-from .template import render_complex
+from .template import TemplateStateFromEntityId, render_complex
from .typing import ConfigType
CONF_AVAILABILITY = "availability"
@@ -231,16 +231,14 @@ class ManualTriggerEntity(TriggerBaseEntity):
Ex: self._process_manual_data(payload)
"""
- self.async_write_ha_state()
- this = None
- if state := self.hass.states.get(self.entity_id):
- this = state.as_dict()
-
run_variables: dict[str, Any] = {"value": value}
# Silently try if variable is a json and store result in `value_json` if it is.
with contextlib.suppress(*JSON_DECODE_EXCEPTIONS):
run_variables["value_json"] = json_loads(run_variables["value"])
- variables = {"this": this, **(run_variables or {})}
+ variables = {
+ "this": TemplateStateFromEntityId(self.hass, self.entity_id),
+ **(run_variables or {}),
+ }
self._render_templates(variables)
diff --git a/homeassistant/helpers/update_coordinator.py b/homeassistant/helpers/update_coordinator.py
index f5c2a2a1288..6cc4584935e 100644
--- a/homeassistant/helpers/update_coordinator.py
+++ b/homeassistant/helpers/update_coordinator.py
@@ -24,12 +24,13 @@ from homeassistant.exceptions import (
ConfigEntryAuthFailed,
ConfigEntryError,
ConfigEntryNotReady,
+ HomeAssistantError,
)
from homeassistant.util.dt import utcnow
from . import entity, event
from .debounce import Debouncer
-from .frame import report
+from .frame import report_usage
from .typing import UNDEFINED, UndefinedType
REQUEST_REFRESH_DEFAULT_COOLDOWN = 10
@@ -43,7 +44,7 @@ _DataUpdateCoordinatorT = TypeVar(
)
-class UpdateFailed(Exception):
+class UpdateFailed(HomeAssistantError):
"""Raised when an update has failed."""
@@ -286,24 +287,20 @@ class DataUpdateCoordinator(BaseDataUpdateCoordinatorProtocol, Generic[_DataT]):
to ensure that multiple retries do not cause log spam.
"""
if self.config_entry is None:
- report(
+ report_usage(
"uses `async_config_entry_first_refresh`, which is only supported "
- "for coordinators with a config entry and will stop working in "
- "Home Assistant 2025.11",
- error_if_core=True,
- error_if_integration=False,
+ "for coordinators with a config entry",
+ breaks_in_ha_version="2025.11",
)
elif (
self.config_entry.state
is not config_entries.ConfigEntryState.SETUP_IN_PROGRESS
):
- report(
+ report_usage(
"uses `async_config_entry_first_refresh`, which is only supported "
f"when entry state is {config_entries.ConfigEntryState.SETUP_IN_PROGRESS}, "
- f"but it is in state {self.config_entry.state}, "
- "This will stop working in Home Assistant 2025.11",
- error_if_core=True,
- error_if_integration=False,
+ f"but it is in state {self.config_entry.state}",
+ breaks_in_ha_version="2025.11",
)
if await self.__wrap_async_setup():
await self._async_refresh(
diff --git a/homeassistant/loader.py b/homeassistant/loader.py
index d2e04df04c4..93dc7677bba 100644
--- a/homeassistant/loader.py
+++ b/homeassistant/loader.py
@@ -65,14 +65,15 @@ _LOGGER = logging.getLogger(__name__)
# This list can be extended by calling async_register_preload_platform
#
BASE_PRELOAD_PLATFORMS = [
+ "backup",
"config",
"config_flow",
"diagnostics",
"energy",
"group",
- "logbook",
"hardware",
"intent",
+ "logbook",
"media_source",
"recorder",
"repairs",
@@ -830,6 +831,9 @@ class Integration:
@cached_property
def quality_scale(self) -> str | None:
"""Return Integration Quality Scale."""
+ # Custom integrations default to "custom" quality scale.
+ if not self.is_built_in or self.overwrites_built_in:
+ return "custom"
return self.manifest.get("quality_scale")
@cached_property
@@ -1560,14 +1564,12 @@ class Components:
from .helpers.frame import ReportBehavior, report_usage
report_usage(
- (
- f"accesses hass.components.{comp_name}."
- " This is deprecated and will stop working in Home Assistant 2025.3, it"
- f" should be updated to import functions used from {comp_name} directly"
- ),
+ f"accesses hass.components.{comp_name}, which"
+ f" should be updated to import functions used from {comp_name} directly",
core_behavior=ReportBehavior.IGNORE,
core_integration_behavior=ReportBehavior.IGNORE,
custom_integration_behavior=ReportBehavior.LOG,
+ breaks_in_ha_version="2025.3",
)
wrapped = ModuleWrapper(self._hass, component)
@@ -1592,13 +1594,13 @@ class Helpers:
report_usage(
(
- f"accesses hass.helpers.{helper_name}."
- " This is deprecated and will stop working in Home Assistant 2025.5, it"
+ f"accesses hass.helpers.{helper_name}, which"
f" should be updated to import functions used from {helper_name} directly"
),
core_behavior=ReportBehavior.IGNORE,
core_integration_behavior=ReportBehavior.IGNORE,
custom_integration_behavior=ReportBehavior.LOG,
+ breaks_in_ha_version="2025.5",
)
wrapped = ModuleWrapper(self._hass, helper)
@@ -1685,6 +1687,29 @@ def is_component_module_loaded(hass: HomeAssistant, module: str) -> bool:
return module in hass.data[DATA_COMPONENTS]
+@callback
+def async_get_issue_integration(
+ hass: HomeAssistant | None,
+ integration_domain: str | None,
+) -> Integration | None:
+ """Return details of an integration for issue reporting."""
+ integration: Integration | None = None
+ if not hass or not integration_domain:
+ # We are unable to get the integration
+ return None
+
+ if (comps_or_future := hass.data.get(DATA_CUSTOM_COMPONENTS)) and not isinstance(
+ comps_or_future, asyncio.Future
+ ):
+ integration = comps_or_future.get(integration_domain)
+
+ if not integration:
+ with suppress(IntegrationNotLoaded):
+ integration = async_get_loaded_integration(hass, integration_domain)
+
+ return integration
+
+
@callback
def async_get_issue_tracker(
hass: HomeAssistant | None,
@@ -1698,20 +1723,11 @@ def async_get_issue_tracker(
"https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue"
)
if not integration and not integration_domain and not module:
- # If we know nothing about the entity, suggest opening an issue on HA core
+ # If we know nothing about the integration, suggest opening an issue on HA core
return issue_tracker
- if (
- not integration
- and (hass and integration_domain)
- and (comps_or_future := hass.data.get(DATA_CUSTOM_COMPONENTS))
- and not isinstance(comps_or_future, asyncio.Future)
- ):
- integration = comps_or_future.get(integration_domain)
-
- if not integration and (hass and integration_domain):
- with suppress(IntegrationNotLoaded):
- integration = async_get_loaded_integration(hass, integration_domain)
+ if not integration:
+ integration = async_get_issue_integration(hass, integration_domain)
if integration and not integration.is_built_in:
return integration.issue_tracker
diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt
index 99811a11bab..715e98e56e6 100644
--- a/homeassistant/package_constraints.txt
+++ b/homeassistant/package_constraints.txt
@@ -3,53 +3,56 @@
aiodhcpwatcher==1.0.2
aiodiscover==2.1.0
aiodns==3.2.0
-aiohasupervisor==0.2.1
-aiohttp-fast-zlib==0.1.1
-aiohttp==3.11.0b4
+aiohasupervisor==0.2.2b5
+aiohttp-asyncmdnsresolver==0.0.1
+aiohttp-fast-zlib==0.2.0
+aiohttp==3.11.11
aiohttp_cors==0.7.0
aiozoneinfo==0.2.1
astral==2.2
async-interrupt==1.2.0
-async-upnp-client==0.41.0
+async-upnp-client==0.42.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==0.22.3
-bluetooth-adapters==0.20.0
+bluetooth-adapters==0.20.2
bluetooth-auto-recovery==1.4.2
bluetooth-data-tools==1.20.0
cached-ipaddress==0.8.0
certifi>=2021.5.30
-ciso8601==2.3.1
-cryptography==43.0.1
-dbus-fast==2.24.3
+ciso8601==2.3.2
+cronsim==2.6
+cryptography==44.0.0
+dbus-fast==2.28.0
fnv-hash-fast==1.0.2
-go2rtc-client==0.1.0
+go2rtc-client==0.1.2
ha-ffmpeg==3.2.2
-habluetooth==3.6.0
-hass-nabucasa==0.84.0
-hassil==1.7.4
+habluetooth==3.7.0
+hass-nabucasa==0.87.0
+hassil==2.1.0
home-assistant-bluetooth==1.13.0
-home-assistant-frontend==20241106.2
-home-assistant-intents==2024.11.6
+home-assistant-frontend==20250106.0
+home-assistant-intents==2025.1.1
httpx==0.27.2
ifaddr==0.2.0
-Jinja2==3.1.4
+Jinja2==3.1.5
lru-dict==1.3.0
mutagen==1.47.0
-orjson==3.10.11
+orjson==3.10.12
packaging>=23.1
paho-mqtt==1.6.1
-Pillow==10.4.0
-propcache==0.2.0
+Pillow==11.1.0
+propcache==0.2.1
psutil-home-assistant==0.0.1
-PyJWT==2.9.0
+PyJWT==2.10.1
pymicro-vad==1.0.1
PyNaCl==1.5.0
-pyOpenSSL==24.2.1
+pyOpenSSL==24.3.0
pyserial==3.5
pyspeex-noise==1.0.2
python-slugify==8.0.4
@@ -57,18 +60,20 @@ PyTurboJPEG==1.7.5
pyudev==0.24.1
PyYAML==6.0.2
requests==2.32.3
-securetar==2024.2.1
-SQLAlchemy==2.0.31
+securetar==2024.11.0
+SQLAlchemy==2.0.36
+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
urllib3>=1.26.5,<2
-uv==0.5.0
-voluptuous-openapi==0.0.5
+uv==0.5.8
+voluptuous-openapi==0.0.6
voluptuous-serialize==2.6.0
voluptuous==0.15.2
-webrtc-models==0.2.0
-yarl==1.17.1
-zeroconf==0.136.0
+webrtc-models==0.3.0
+yarl==1.18.3
+zeroconf==0.137.2
# Constrain pycryptodome to avoid vulnerability
# see https://github.com/home-assistant/core/pull/16238
@@ -81,9 +86,9 @@ httplib2>=0.19.0
# gRPC is an implicit dependency that we want to make explicit so we manage
# upgrades intentionally. It is a large package to build from source and we
# want to ensure we have wheels built.
-grpcio==1.66.2
-grpcio-status==1.66.2
-grpcio-reflection==1.66.2
+grpcio==1.67.1
+grpcio-status==1.67.1
+grpcio-reflection==1.67.1
# This is a old unmaintained library and is replaced with pycryptodome
pycrypto==1000000000.0.0
@@ -103,7 +108,7 @@ 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.6.2.post1
+anyio==4.7.0
h11==0.14.0
httpcore==1.0.5
@@ -112,7 +117,7 @@ httpcore==1.0.5
hyperframe>=5.2.0
# Ensure we run compatible with musllinux build env
-numpy==2.1.2
+numpy==2.2.1
pandas~=2.2.3
# Constrain multidict to avoid typing issues
@@ -122,9 +127,8 @@ multidict>=6.0.2
# Version 2.0 added typing, prevent accidental fallbacks
backoff>=2.0
-# Required to avoid breaking (#101042).
-# v2 has breaking changes (#99218).
-pydantic==1.10.18
+# ensure pydantic version does not float since it might have breaking changes
+pydantic==2.10.4
# Required for Python 3.12.4 compatibility (#119223).
mashumaro>=3.13.1
@@ -143,16 +147,18 @@ pyOpenSSL>=24.0.0
# protobuf must be in package constraints for the wheel
# builder to build binary wheels
-protobuf==5.28.3
+protobuf==5.29.2
# faust-cchardet: Ensure we have a version we can build wheels
# 2.1.18 is the first version that works with our wheel builder
faust-cchardet>=2.1.18
-# websockets 11.0 is missing files in the source distribution
-# which break wheel builds so we need at least 11.0.1
-# https://github.com/aaugustin/websockets/issues/1329
-websockets>=11.0.1
+# websockets 13.1 is the first version to fully support the new
+# asyncio implementation. The legacy implementation is now
+# deprecated as of websockets 14.0.
+# https://websockets.readthedocs.io/en/13.0.1/howto/upgrade.html#missing-features
+# https://websockets.readthedocs.io/en/stable/howto/upgrade.html
+websockets>=13.1
# pysnmplib is no longer maintained and does not work with newer
# python
@@ -178,8 +184,8 @@ chacha20poly1305-reuseable>=0.13.0
# https://github.com/pycountry/pycountry/blob/ea69bab36f00df58624a0e490fdad4ccdc14268b/HISTORY.txt#L39
pycountry>=23.12.11
-# scapy<2.5.0 will not work with python3.12
-scapy>=2.5.0
+# scapy==2.6.0 causes CI failures due to a race condition
+scapy>=2.6.1
# tuf isn't updated to deal with breaking changes in securesystemslib==1.0.
# Only tuf>=4 includes a constraint to <1.0.
@@ -192,3 +198,11 @@ tenacity!=8.4.0
# 5.0.0 breaks Timeout as a context manager
# TypeError: 'Timeout' object does not support the context manager protocol
async-timeout==4.0.3
+
+# aiofiles keeps getting downgraded by custom components
+# causing newer methods to not be available and breaking
+# some integrations at startup
+# https://github.com/home-assistant/core/issues/127529
+# https://github.com/home-assistant/core/issues/122508
+# https://github.com/home-assistant/core/issues/118004
+aiofiles>=24.1.0
diff --git a/homeassistant/util/async_.py b/homeassistant/util/async_.py
index d010d8cb341..f8901d11114 100644
--- a/homeassistant/util/async_.py
+++ b/homeassistant/util/async_.py
@@ -39,7 +39,7 @@ def create_eager_task[_T](
# pylint: disable-next=import-outside-toplevel
from homeassistant.helpers import frame
- frame.report("attempted to create an asyncio task from a thread")
+ frame.report_usage("attempted to create an asyncio task from a thread")
raise
return Task(coro, loop=loop, name=name, eager_start=True)
diff --git a/homeassistant/util/color.py b/homeassistant/util/color.py
index 0745bc96dfb..18f8182650b 100644
--- a/homeassistant/util/color.py
+++ b/homeassistant/util/color.py
@@ -377,7 +377,7 @@ def color_hsv_to_RGB(iH: float, iS: float, iV: float) -> tuple[int, int, int]:
Val is scaled 0-100
"""
fRGB = colorsys.hsv_to_rgb(iH / 360, iS / 100, iV / 100)
- return (int(fRGB[0] * 255), int(fRGB[1] * 255), int(fRGB[2] * 255))
+ return (round(fRGB[0] * 255), round(fRGB[1] * 255), round(fRGB[2] * 255))
def color_hs_to_RGB(iH: float, iS: float) -> tuple[int, int, int]:
diff --git a/homeassistant/util/dt.py b/homeassistant/util/dt.py
index ee2b6c762d8..eb898e4b544 100644
--- a/homeassistant/util/dt.py
+++ b/homeassistant/util/dt.py
@@ -13,6 +13,8 @@ import zoneinfo
from aiozoneinfo import async_get_time_zone as _async_get_time_zone
import ciso8601
+from homeassistant.helpers.deprecation import deprecated_function
+
DATE_STR_FORMAT = "%Y-%m-%d"
UTC = dt.UTC
DEFAULT_TIME_ZONE: dt.tzinfo = dt.UTC
@@ -170,6 +172,7 @@ utc_from_timestamp = partial(dt.datetime.fromtimestamp, tz=UTC)
"""Return a UTC time from a timestamp."""
+@deprecated_function("datetime.timestamp", breaks_in_ha_version="2026.1")
def utc_to_timestamp(utc_dt: dt.datetime) -> float:
"""Fast conversion of a datetime in UTC to a timestamp."""
# Taken from
diff --git a/homeassistant/util/json.py b/homeassistant/util/json.py
index fa67f6b1dcc..968567ae0c9 100644
--- a/homeassistant/util/json.py
+++ b/homeassistant/util/json.py
@@ -30,32 +30,30 @@ class SerializationError(HomeAssistantError):
"""Error serializing the data to JSON."""
-def json_loads(__obj: bytes | bytearray | memoryview | str) -> JsonValueType:
+def json_loads(obj: bytes | bytearray | memoryview | str, /) -> JsonValueType:
"""Parse JSON data.
This adds a workaround for orjson not handling subclasses of str,
https://github.com/ijl/orjson/issues/445.
"""
# Avoid isinstance overhead for the common case
- if type(__obj) not in (bytes, bytearray, memoryview, str) and isinstance(
- __obj, str
- ):
- return orjson.loads(str(__obj)) # type:ignore[no-any-return]
- return orjson.loads(__obj) # type:ignore[no-any-return]
+ if type(obj) not in (bytes, bytearray, memoryview, str) and isinstance(obj, str):
+ return orjson.loads(str(obj)) # type:ignore[no-any-return]
+ return orjson.loads(obj) # type:ignore[no-any-return]
-def json_loads_array(__obj: bytes | bytearray | memoryview | str) -> JsonArrayType:
+def json_loads_array(obj: bytes | bytearray | memoryview | str, /) -> JsonArrayType:
"""Parse JSON data and ensure result is a list."""
- value: JsonValueType = json_loads(__obj)
+ value: JsonValueType = json_loads(obj)
# Avoid isinstance overhead as we are not interested in list subclasses
if type(value) is list: # noqa: E721
return value
raise ValueError(f"Expected JSON to be parsed as a list got {type(value)}")
-def json_loads_object(__obj: bytes | bytearray | memoryview | str) -> JsonObjectType:
+def json_loads_object(obj: bytes | bytearray | memoryview | str, /) -> JsonObjectType:
"""Parse JSON data and ensure result is a dictionary."""
- value: JsonValueType = json_loads(__obj)
+ value: JsonValueType = json_loads(obj)
# Avoid isinstance overhead as we are not interested in dict subclasses
if type(value) is dict: # noqa: E721
return value
diff --git a/homeassistant/util/package.py b/homeassistant/util/package.py
index da0666290a1..9720bbd4ca3 100644
--- a/homeassistant/util/package.py
+++ b/homeassistant/util/package.py
@@ -15,6 +15,8 @@ from urllib.parse import urlparse
from packaging.requirements import InvalidRequirement, Requirement
+from .system_info import is_official_image
+
_LOGGER = logging.getLogger(__name__)
@@ -28,8 +30,13 @@ def is_virtual_env() -> bool:
@cache
def is_docker_env() -> bool:
- """Return True if we run in a docker env."""
- return Path("/.dockerenv").exists()
+ """Return True if we run in a container env."""
+ return (
+ Path("/.dockerenv").exists()
+ or Path("/run/.containerenv").exists()
+ or "KUBERNETES_SERVICE_HOST" in os.environ
+ or is_official_image()
+ )
def get_installed_versions(specifiers: set[str]) -> set[str]:
diff --git a/homeassistant/util/system_info.py b/homeassistant/util/system_info.py
new file mode 100644
index 00000000000..80621bd16a5
--- /dev/null
+++ b/homeassistant/util/system_info.py
@@ -0,0 +1,12 @@
+"""Util to gather system info."""
+
+from __future__ import annotations
+
+from functools import cache
+import os
+
+
+@cache
+def is_official_image() -> bool:
+ """Return True if Home Assistant is running in an official container."""
+ return os.path.isfile("/OFFICIAL_IMAGE")
diff --git a/homeassistant/util/unit_conversion.py b/homeassistant/util/unit_conversion.py
index 289df28738a..8ea290f01d1 100644
--- a/homeassistant/util/unit_conversion.py
+++ b/homeassistant/util/unit_conversion.py
@@ -10,6 +10,8 @@ from homeassistant.const import (
CONCENTRATION_PARTS_PER_MILLION,
PERCENTAGE,
UNIT_NOT_RECOGNIZED_TEMPLATE,
+ UnitOfArea,
+ UnitOfBloodGlucoseConcentration,
UnitOfConductivity,
UnitOfDataRate,
UnitOfElectricCurrent,
@@ -41,6 +43,19 @@ _MILE_TO_M = _YARD_TO_M * 1760 # 1760 yard = 1 mile (1609.344 m)
_NAUTICAL_MILE_TO_M = 1852 # 1 nautical mile = 1852 m
+# Area constants to square meters
+_CM2_TO_M2 = _CM_TO_M**2 # 1 cm² = 0.0001 m²
+_MM2_TO_M2 = _MM_TO_M**2 # 1 mm² = 0.000001 m²
+_KM2_TO_M2 = _KM_TO_M**2 # 1 km² = 1,000,000 m²
+
+_IN2_TO_M2 = _IN_TO_M**2 # 1 in² = 0.00064516 m²
+_FT2_TO_M2 = _FOOT_TO_M**2 # 1 ft² = 0.092903 m²
+_YD2_TO_M2 = _YARD_TO_M**2 # 1 yd² = 0.836127 m²
+_MI2_TO_M2 = _MILE_TO_M**2 # 1 mi² = 2,590,000 m²
+
+_ACRE_TO_M2 = 66 * 660 * _FT2_TO_M2 # 1 acre = 4,046.86 m²
+_HECTARE_TO_M2 = 100 * 100 # 1 hectare = 10,000 m²
+
# Duration conversion constants
_MIN_TO_SEC = 60 # 1 min = 60 seconds
_HRS_TO_MINUTES = 60 # 1 hr = 60 minutes
@@ -145,6 +160,25 @@ class DataRateConverter(BaseUnitConverter):
VALID_UNITS = set(UnitOfDataRate)
+class AreaConverter(BaseUnitConverter):
+ """Utility to convert area values."""
+
+ UNIT_CLASS = "area"
+ _UNIT_CONVERSION: dict[str | None, float] = {
+ UnitOfArea.SQUARE_METERS: 1,
+ UnitOfArea.SQUARE_CENTIMETERS: 1 / _CM2_TO_M2,
+ UnitOfArea.SQUARE_MILLIMETERS: 1 / _MM2_TO_M2,
+ UnitOfArea.SQUARE_KILOMETERS: 1 / _KM2_TO_M2,
+ UnitOfArea.SQUARE_INCHES: 1 / _IN2_TO_M2,
+ UnitOfArea.SQUARE_FEET: 1 / _FT2_TO_M2,
+ UnitOfArea.SQUARE_YARDS: 1 / _YD2_TO_M2,
+ UnitOfArea.SQUARE_MILES: 1 / _MI2_TO_M2,
+ UnitOfArea.ACRES: 1 / _ACRE_TO_M2,
+ UnitOfArea.HECTARES: 1 / _HECTARE_TO_M2,
+ }
+ VALID_UNITS = set(UnitOfArea)
+
+
class DistanceConverter(BaseUnitConverter):
"""Utility to convert distance values."""
@@ -173,6 +207,17 @@ class DistanceConverter(BaseUnitConverter):
}
+class BloodGlucoseConcentrationConverter(BaseUnitConverter):
+ """Utility to convert blood glucose concentration values."""
+
+ UNIT_CLASS = "blood_glucose_concentration"
+ _UNIT_CONVERSION: dict[str | None, float] = {
+ UnitOfBloodGlucoseConcentration.MILLIGRAMS_PER_DECILITER: 18,
+ UnitOfBloodGlucoseConcentration.MILLIMOLE_PER_LITER: 1,
+ }
+ VALID_UNITS = set(UnitOfBloodGlucoseConcentration)
+
+
class ConductivityConverter(BaseUnitConverter):
"""Utility to convert electric current values."""
@@ -203,10 +248,12 @@ class ElectricPotentialConverter(BaseUnitConverter):
_UNIT_CONVERSION: dict[str | None, float] = {
UnitOfElectricPotential.VOLT: 1,
UnitOfElectricPotential.MILLIVOLT: 1e3,
+ UnitOfElectricPotential.MICROVOLT: 1e6,
}
VALID_UNITS = {
UnitOfElectricPotential.VOLT,
UnitOfElectricPotential.MILLIVOLT,
+ UnitOfElectricPotential.MICROVOLT,
}
@@ -219,6 +266,7 @@ class EnergyConverter(BaseUnitConverter):
UnitOfEnergy.KILO_JOULE: _WH_TO_J,
UnitOfEnergy.MEGA_JOULE: _WH_TO_J / 1e3,
UnitOfEnergy.GIGA_JOULE: _WH_TO_J / 1e6,
+ UnitOfEnergy.MILLIWATT_HOUR: 1e6,
UnitOfEnergy.WATT_HOUR: 1e3,
UnitOfEnergy.KILO_WATT_HOUR: 1,
UnitOfEnergy.MEGA_WATT_HOUR: 1 / 1e3,
@@ -292,6 +340,7 @@ class PowerConverter(BaseUnitConverter):
UNIT_CLASS = "power"
_UNIT_CONVERSION: dict[str | None, float] = {
+ UnitOfPower.MILLIWATT: 1 * 1000,
UnitOfPower.WATT: 1,
UnitOfPower.KILO_WATT: 1 / 1000,
UnitOfPower.MEGA_WATT: 1 / 1e6,
@@ -299,6 +348,7 @@ class PowerConverter(BaseUnitConverter):
UnitOfPower.TERA_WATT: 1 / 1e12,
}
VALID_UNITS = {
+ UnitOfPower.MILLIWATT,
UnitOfPower.WATT,
UnitOfPower.KILO_WATT,
UnitOfPower.MEGA_WATT,
@@ -619,12 +669,15 @@ class VolumeFlowRateConverter(BaseUnitConverter):
/ (_HRS_TO_MINUTES * _L_TO_CUBIC_METER),
UnitOfVolumeFlowRate.GALLONS_PER_MINUTE: 1
/ (_HRS_TO_MINUTES * _GALLON_TO_CUBIC_METER),
+ UnitOfVolumeFlowRate.MILLILITERS_PER_SECOND: 1
+ / (_HRS_TO_SECS * _ML_TO_CUBIC_METER),
}
VALID_UNITS = {
UnitOfVolumeFlowRate.CUBIC_FEET_PER_MINUTE,
UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR,
UnitOfVolumeFlowRate.LITERS_PER_MINUTE,
UnitOfVolumeFlowRate.GALLONS_PER_MINUTE,
+ UnitOfVolumeFlowRate.MILLILITERS_PER_SECOND,
}
diff --git a/homeassistant/util/unit_system.py b/homeassistant/util/unit_system.py
index 7f7c7f2b5fd..15993cbae47 100644
--- a/homeassistant/util/unit_system.py
+++ b/homeassistant/util/unit_system.py
@@ -9,6 +9,7 @@ import voluptuous as vol
from homeassistant.const import (
ACCUMULATED_PRECIPITATION,
+ AREA,
LENGTH,
MASS,
PRESSURE,
@@ -16,6 +17,7 @@ from homeassistant.const import (
UNIT_NOT_RECOGNIZED_TEMPLATE,
VOLUME,
WIND_SPEED,
+ UnitOfArea,
UnitOfLength,
UnitOfMass,
UnitOfPrecipitationDepth,
@@ -27,6 +29,7 @@ from homeassistant.const import (
)
from .unit_conversion import (
+ AreaConverter,
DistanceConverter,
PressureConverter,
SpeedConverter,
@@ -41,6 +44,8 @@ _CONF_UNIT_SYSTEM_IMPERIAL: Final = "imperial"
_CONF_UNIT_SYSTEM_METRIC: Final = "metric"
_CONF_UNIT_SYSTEM_US_CUSTOMARY: Final = "us_customary"
+AREA_UNITS = AreaConverter.VALID_UNITS
+
LENGTH_UNITS = DistanceConverter.VALID_UNITS
MASS_UNITS: set[str] = {
@@ -66,6 +71,7 @@ _VALID_BY_TYPE: dict[str, set[str] | set[str | None]] = {
MASS: MASS_UNITS,
VOLUME: VOLUME_UNITS,
PRESSURE: PRESSURE_UNITS,
+ AREA: AREA_UNITS,
}
@@ -84,6 +90,7 @@ class UnitSystem:
name: str,
*,
accumulated_precipitation: UnitOfPrecipitationDepth,
+ area: UnitOfArea,
conversions: dict[tuple[SensorDeviceClass | str | None, str | None], str],
length: UnitOfLength,
mass: UnitOfMass,
@@ -97,6 +104,7 @@ class UnitSystem:
UNIT_NOT_RECOGNIZED_TEMPLATE.format(unit, unit_type)
for unit, unit_type in (
(accumulated_precipitation, ACCUMULATED_PRECIPITATION),
+ (area, AREA),
(temperature, TEMPERATURE),
(length, LENGTH),
(wind_speed, WIND_SPEED),
@@ -112,10 +120,11 @@ class UnitSystem:
self._name = name
self.accumulated_precipitation_unit = accumulated_precipitation
- self.temperature_unit = temperature
+ self.area_unit = area
self.length_unit = length
self.mass_unit = mass
self.pressure_unit = pressure
+ self.temperature_unit = temperature
self.volume_unit = volume
self.wind_speed_unit = wind_speed
self._conversions = conversions
@@ -149,6 +158,16 @@ class UnitSystem:
precip, from_unit, self.accumulated_precipitation_unit
)
+ def area(self, area: float | None, from_unit: str) -> float:
+ """Convert the given area to this unit system."""
+ if not isinstance(area, Number):
+ raise TypeError(f"{area!s} is not a numeric value.")
+
+ # type ignore: https://github.com/python/mypy/issues/7207
+ return AreaConverter.convert( # type: ignore[unreachable]
+ area, from_unit, self.area_unit
+ )
+
def pressure(self, pressure: float | None, from_unit: str) -> float:
"""Convert the given pressure to this unit system."""
if not isinstance(pressure, Number):
@@ -184,6 +203,7 @@ class UnitSystem:
return {
LENGTH: self.length_unit,
ACCUMULATED_PRECIPITATION: self.accumulated_precipitation_unit,
+ AREA: self.area_unit,
MASS: self.mass_unit,
PRESSURE: self.pressure_unit,
TEMPERATURE: self.temperature_unit,
@@ -213,7 +233,6 @@ def _deprecated_unit_system(value: str) -> str:
"""Convert deprecated unit system."""
if value == _CONF_UNIT_SYSTEM_IMPERIAL:
- # need to add warning in 2023.1
return _CONF_UNIT_SYSTEM_US_CUSTOMARY
return value
@@ -234,6 +253,12 @@ METRIC_SYSTEM = UnitSystem(
for unit in UnitOfPressure
if unit != UnitOfPressure.HPA
},
+ # Convert non-metric area
+ ("area", UnitOfArea.SQUARE_INCHES): UnitOfArea.SQUARE_CENTIMETERS,
+ ("area", UnitOfArea.SQUARE_FEET): UnitOfArea.SQUARE_METERS,
+ ("area", UnitOfArea.SQUARE_MILES): UnitOfArea.SQUARE_KILOMETERS,
+ ("area", UnitOfArea.SQUARE_YARDS): UnitOfArea.SQUARE_METERS,
+ ("area", UnitOfArea.ACRES): UnitOfArea.HECTARES,
# Convert non-metric distances
("distance", UnitOfLength.FEET): UnitOfLength.METERS,
("distance", UnitOfLength.INCHES): UnitOfLength.MILLIMETERS,
@@ -285,6 +310,7 @@ METRIC_SYSTEM = UnitSystem(
if unit not in (UnitOfSpeed.KILOMETERS_PER_HOUR, UnitOfSpeed.KNOTS)
},
},
+ area=UnitOfArea.SQUARE_METERS,
length=UnitOfLength.KILOMETERS,
mass=UnitOfMass.GRAMS,
pressure=UnitOfPressure.PA,
@@ -303,6 +329,12 @@ US_CUSTOMARY_SYSTEM = UnitSystem(
for unit in UnitOfPressure
if unit != UnitOfPressure.INHG
},
+ # Convert non-USCS areas
+ ("area", UnitOfArea.SQUARE_METERS): UnitOfArea.SQUARE_FEET,
+ ("area", UnitOfArea.SQUARE_CENTIMETERS): UnitOfArea.SQUARE_INCHES,
+ ("area", UnitOfArea.SQUARE_MILLIMETERS): UnitOfArea.SQUARE_INCHES,
+ ("area", UnitOfArea.SQUARE_KILOMETERS): UnitOfArea.SQUARE_MILES,
+ ("area", UnitOfArea.HECTARES): UnitOfArea.ACRES,
# Convert non-USCS distances
("distance", UnitOfLength.CENTIMETERS): UnitOfLength.INCHES,
("distance", UnitOfLength.KILOMETERS): UnitOfLength.MILES,
@@ -356,6 +388,7 @@ US_CUSTOMARY_SYSTEM = UnitSystem(
if unit not in (UnitOfSpeed.KNOTS, UnitOfSpeed.MILES_PER_HOUR)
},
},
+ area=UnitOfArea.SQUARE_FEET,
length=UnitOfLength.MILES,
mass=UnitOfMass.POUNDS,
pressure=UnitOfPressure.PSI,
diff --git a/homeassistant/util/yaml/loader.py b/homeassistant/util/yaml/loader.py
index 39ac17d94f9..39d38a8f47d 100644
--- a/homeassistant/util/yaml/loader.py
+++ b/homeassistant/util/yaml/loader.py
@@ -25,7 +25,6 @@ except ImportError:
from propcache import cached_property
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.frame import report
from .const import SECRET_YAML
from .objects import Input, NodeDictClass, NodeListClass, NodeStrClass
@@ -144,37 +143,6 @@ class FastSafeLoader(FastestAvailableSafeLoader, _LoaderMixin):
self.secrets = secrets
-class SafeLoader(FastSafeLoader):
- """Provided for backwards compatibility. Logs when instantiated."""
-
- def __init__(*args: Any, **kwargs: Any) -> None:
- """Log a warning and call super."""
- SafeLoader.__report_deprecated()
- FastSafeLoader.__init__(*args, **kwargs)
-
- @classmethod
- def add_constructor(cls, tag: str, constructor: Callable) -> None:
- """Log a warning and call super."""
- SafeLoader.__report_deprecated()
- FastSafeLoader.add_constructor(tag, constructor)
-
- @classmethod
- def add_multi_constructor(
- cls, tag_prefix: str, multi_constructor: Callable
- ) -> None:
- """Log a warning and call super."""
- SafeLoader.__report_deprecated()
- FastSafeLoader.add_multi_constructor(tag_prefix, multi_constructor)
-
- @staticmethod
- def __report_deprecated() -> None:
- """Log deprecation warning."""
- report(
- "uses deprecated 'SafeLoader' instead of 'FastSafeLoader', "
- "which will stop working in HA Core 2024.6,"
- )
-
-
class PythonSafeLoader(yaml.SafeLoader, _LoaderMixin):
"""Python safe loader."""
@@ -184,37 +152,6 @@ class PythonSafeLoader(yaml.SafeLoader, _LoaderMixin):
self.secrets = secrets
-class SafeLineLoader(PythonSafeLoader):
- """Provided for backwards compatibility. Logs when instantiated."""
-
- def __init__(*args: Any, **kwargs: Any) -> None:
- """Log a warning and call super."""
- SafeLineLoader.__report_deprecated()
- PythonSafeLoader.__init__(*args, **kwargs)
-
- @classmethod
- def add_constructor(cls, tag: str, constructor: Callable) -> None:
- """Log a warning and call super."""
- SafeLineLoader.__report_deprecated()
- PythonSafeLoader.add_constructor(tag, constructor)
-
- @classmethod
- def add_multi_constructor(
- cls, tag_prefix: str, multi_constructor: Callable
- ) -> None:
- """Log a warning and call super."""
- SafeLineLoader.__report_deprecated()
- PythonSafeLoader.add_multi_constructor(tag_prefix, multi_constructor)
-
- @staticmethod
- def __report_deprecated() -> None:
- """Log deprecation warning."""
- report(
- "uses deprecated 'SafeLineLoader' instead of 'PythonSafeLoader', "
- "which will stop working in HA Core 2024.6,"
- )
-
-
type LoaderType = FastSafeLoader | PythonSafeLoader
diff --git a/mypy.ini b/mypy.ini
index 4d33f16d968..55fd0b3cd65 100644
--- a/mypy.ini
+++ b/mypy.ini
@@ -5,18 +5,18 @@
[mypy]
python_version = 3.12
platform = linux
-plugins = pydantic.mypy
+plugins = pydantic.mypy, pydantic.v1.mypy
show_error_codes = true
follow_imports = normal
local_partial_types = true
strict_equality = true
+strict_bytes = true
no_implicit_optional = true
-report_deprecated_as_error = true
warn_incomplete_stub = true
warn_redundant_casts = true
warn_unused_configs = true
warn_unused_ignores = true
-enable_error_code = ignore-without-code, redundant-self, truthy-iterable
+enable_error_code = deprecated, ignore-without-code, redundant-self, truthy-iterable
disable_error_code = annotation-unchecked, import-not-found, import-untyped
extra_checks = false
check_untyped_defs = true
@@ -165,6 +165,16 @@ disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
+[mypy-homeassistant.components.acaia.*]
+check_untyped_defs = true
+disallow_incomplete_defs = true
+disallow_subclassing_any = true
+disallow_untyped_calls = true
+disallow_untyped_decorators = true
+disallow_untyped_defs = true
+warn_return_any = true
+warn_unreachable = true
+
[mypy-homeassistant.components.accuweather.*]
check_untyped_defs = true
disallow_incomplete_defs = true
@@ -1115,6 +1125,16 @@ disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
+[mypy-homeassistant.components.cookidoo.*]
+check_untyped_defs = true
+disallow_incomplete_defs = true
+disallow_subclassing_any = true
+disallow_untyped_calls = true
+disallow_untyped_decorators = true
+disallow_untyped_defs = true
+warn_return_any = true
+warn_unreachable = true
+
[mypy-homeassistant.components.counter.*]
check_untyped_defs = true
disallow_incomplete_defs = true
@@ -1436,6 +1456,16 @@ disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
+[mypy-homeassistant.components.eheimdigital.*]
+check_untyped_defs = true
+disallow_incomplete_defs = true
+disallow_subclassing_any = true
+disallow_untyped_calls = true
+disallow_untyped_decorators = true
+disallow_untyped_defs = true
+warn_return_any = true
+warn_unreachable = true
+
[mypy-homeassistant.components.electrasmart.*]
check_untyped_defs = true
disallow_incomplete_defs = true
@@ -2436,6 +2466,16 @@ disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
+[mypy-homeassistant.components.iron_os.*]
+check_untyped_defs = true
+disallow_incomplete_defs = true
+disallow_subclassing_any = true
+disallow_untyped_calls = true
+disallow_untyped_decorators = true
+disallow_untyped_defs = true
+warn_return_any = true
+warn_unreachable = true
+
[mypy-homeassistant.components.islamic_prayer_times.*]
check_untyped_defs = true
disallow_incomplete_defs = true
@@ -2826,6 +2866,26 @@ disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
+[mypy-homeassistant.components.mcp_server.*]
+check_untyped_defs = true
+disallow_incomplete_defs = true
+disallow_subclassing_any = true
+disallow_untyped_calls = true
+disallow_untyped_decorators = true
+disallow_untyped_defs = true
+warn_return_any = true
+warn_unreachable = true
+
+[mypy-homeassistant.components.mealie.*]
+check_untyped_defs = true
+disallow_incomplete_defs = true
+disallow_subclassing_any = true
+disallow_untyped_calls = true
+disallow_untyped_decorators = true
+disallow_untyped_defs = true
+warn_return_any = true
+warn_unreachable = true
+
[mypy-homeassistant.components.media_extractor.*]
check_untyped_defs = true
disallow_incomplete_defs = true
@@ -3326,6 +3386,16 @@ disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
+[mypy-homeassistant.components.overseerr.*]
+check_untyped_defs = true
+disallow_incomplete_defs = true
+disallow_subclassing_any = true
+disallow_untyped_calls = true
+disallow_untyped_decorators = true
+disallow_untyped_defs = true
+warn_return_any = true
+warn_unreachable = true
+
[mypy-homeassistant.components.p1_monitor.*]
check_untyped_defs = true
disallow_incomplete_defs = true
@@ -3336,6 +3406,16 @@ disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
+[mypy-homeassistant.components.pandora.*]
+check_untyped_defs = true
+disallow_incomplete_defs = true
+disallow_subclassing_any = true
+disallow_untyped_calls = true
+disallow_untyped_decorators = true
+disallow_untyped_defs = true
+warn_return_any = true
+warn_unreachable = true
+
[mypy-homeassistant.components.panel_custom.*]
check_untyped_defs = true
disallow_incomplete_defs = true
@@ -3346,6 +3426,16 @@ disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
+[mypy-homeassistant.components.peblar.*]
+check_untyped_defs = true
+disallow_incomplete_defs = true
+disallow_subclassing_any = true
+disallow_untyped_calls = true
+disallow_untyped_decorators = true
+disallow_untyped_defs = true
+warn_return_any = true
+warn_unreachable = true
+
[mypy-homeassistant.components.peco.*]
check_untyped_defs = true
disallow_incomplete_defs = true
@@ -3396,6 +3486,16 @@ disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
+[mypy-homeassistant.components.powerfox.*]
+check_untyped_defs = true
+disallow_incomplete_defs = true
+disallow_subclassing_any = true
+disallow_untyped_calls = true
+disallow_untyped_decorators = true
+disallow_untyped_defs = true
+warn_return_any = true
+warn_unreachable = true
+
[mypy-homeassistant.components.powerwall.*]
check_untyped_defs = true
disallow_incomplete_defs = true
@@ -3486,6 +3586,16 @@ disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
+[mypy-homeassistant.components.python_script.*]
+check_untyped_defs = true
+disallow_incomplete_defs = true
+disallow_subclassing_any = true
+disallow_untyped_calls = true
+disallow_untyped_decorators = true
+disallow_untyped_defs = true
+warn_return_any = true
+warn_unreachable = true
+
[mypy-homeassistant.components.qnap_qsw.*]
check_untyped_defs = true
disallow_incomplete_defs = true
@@ -3606,6 +3716,16 @@ disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
+[mypy-homeassistant.components.reolink.*]
+check_untyped_defs = true
+disallow_incomplete_defs = true
+disallow_subclassing_any = true
+disallow_untyped_calls = true
+disallow_untyped_decorators = true
+disallow_untyped_defs = true
+warn_return_any = true
+warn_unreachable = true
+
[mypy-homeassistant.components.repairs.*]
check_untyped_defs = true
disallow_incomplete_defs = true
@@ -3746,6 +3866,16 @@ disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
+[mypy-homeassistant.components.russound_rio.*]
+check_untyped_defs = true
+disallow_incomplete_defs = true
+disallow_subclassing_any = true
+disallow_untyped_calls = true
+disallow_untyped_decorators = true
+disallow_untyped_defs = true
+warn_return_any = true
+warn_unreachable = true
+
[mypy-homeassistant.components.ruuvi_gateway.*]
check_untyped_defs = true
disallow_incomplete_defs = true
@@ -3796,6 +3926,16 @@ disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
+[mypy-homeassistant.components.schlage.*]
+check_untyped_defs = true
+disallow_incomplete_defs = true
+disallow_subclassing_any = true
+disallow_untyped_calls = true
+disallow_untyped_decorators = true
+disallow_untyped_defs = true
+warn_return_any = true
+warn_unreachable = true
+
[mypy-homeassistant.components.scrape.*]
check_untyped_defs = true
disallow_incomplete_defs = true
@@ -4117,7 +4257,7 @@ disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
-[mypy-homeassistant.components.stookalert.*]
+[mypy-homeassistant.components.stookwijzer.*]
check_untyped_defs = true
disallow_incomplete_defs = true
disallow_subclassing_any = true
diff --git a/pylint/plugins/hass_imports.py b/pylint/plugins/hass_imports.py
index c6a869dd7fc..194f99ae700 100644
--- a/pylint/plugins/hass_imports.py
+++ b/pylint/plugins/hass_imports.py
@@ -37,140 +37,6 @@ _OBSOLETE_IMPORT: dict[str, list[ObsoleteImportMatch]] = {
constant=re.compile(r"^cached_property$"),
),
],
- "homeassistant.components.alarm_control_panel": [
- ObsoleteImportMatch(
- reason="replaced by AlarmControlPanelEntityFeature enum",
- constant=re.compile(r"^SUPPORT_(\w*)$"),
- ),
- ObsoleteImportMatch(
- reason="replaced by CodeFormat enum",
- constant=re.compile(r"^FORMAT_(\w*)$"),
- ),
- ],
- "homeassistant.components.alarm_control_panel.const": [
- ObsoleteImportMatch(
- reason="replaced by AlarmControlPanelEntityFeature enum",
- constant=re.compile(r"^SUPPORT_(\w*)$"),
- ),
- ObsoleteImportMatch(
- reason="replaced by CodeFormat enum",
- constant=re.compile(r"^FORMAT_(\w*)$"),
- ),
- ],
- "homeassistant.components.automation": [
- ObsoleteImportMatch(
- reason="replaced by TriggerActionType from helpers.trigger",
- constant=re.compile(r"^AutomationActionType$"),
- ),
- ObsoleteImportMatch(
- reason="replaced by TriggerData from helpers.trigger",
- constant=re.compile(r"^AutomationTriggerData$"),
- ),
- ObsoleteImportMatch(
- reason="replaced by TriggerInfo from helpers.trigger",
- constant=re.compile(r"^AutomationTriggerInfo$"),
- ),
- ],
- "homeassistant.components.binary_sensor": [
- ObsoleteImportMatch(
- reason="replaced by BinarySensorDeviceClass enum",
- constant=re.compile(r"^DEVICE_CLASS_(\w*)$"),
- ),
- ],
- "homeassistant.components.camera": [
- ObsoleteImportMatch(
- reason="replaced by CameraEntityFeature enum",
- constant=re.compile(r"^SUPPORT_(\w*)$"),
- ),
- ObsoleteImportMatch(
- reason="replaced by StreamType enum",
- constant=re.compile(r"^STREAM_TYPE_(\w*)$"),
- ),
- ],
- "homeassistant.components.camera.const": [
- ObsoleteImportMatch(
- reason="replaced by StreamType enum",
- constant=re.compile(r"^STREAM_TYPE_(\w*)$"),
- ),
- ],
- "homeassistant.components.climate": [
- ObsoleteImportMatch(
- reason="replaced by HVACMode enum",
- constant=re.compile(r"^HVAC_MODE_(\w*)$"),
- ),
- ObsoleteImportMatch(
- reason="replaced by ClimateEntityFeature enum",
- constant=re.compile(r"^SUPPORT_(\w*)$"),
- ),
- ],
- "homeassistant.components.climate.const": [
- ObsoleteImportMatch(
- reason="replaced by HVACAction enum",
- constant=re.compile(r"^CURRENT_HVAC_(\w*)$"),
- ),
- ObsoleteImportMatch(
- reason="replaced by HVACMode enum",
- constant=re.compile(r"^HVAC_MODE_(\w*)$"),
- ),
- ObsoleteImportMatch(
- reason="replaced by ClimateEntityFeature enum",
- constant=re.compile(r"^SUPPORT_(\w*)$"),
- ),
- ],
- "homeassistant.components.cover": [
- ObsoleteImportMatch(
- reason="replaced by CoverDeviceClass enum",
- constant=re.compile(r"^DEVICE_CLASS_(\w*)$"),
- ),
- ObsoleteImportMatch(
- reason="replaced by CoverEntityFeature enum",
- constant=re.compile(r"^SUPPORT_(\w*)$"),
- ),
- ],
- "homeassistant.components.device_tracker": [
- ObsoleteImportMatch(
- reason="replaced by SourceType enum",
- constant=re.compile(r"^SOURCE_TYPE_\w+$"),
- ),
- ],
- "homeassistant.components.device_tracker.const": [
- ObsoleteImportMatch(
- reason="replaced by SourceType enum",
- constant=re.compile(r"^SOURCE_TYPE_\w+$"),
- ),
- ],
- "homeassistant.components.fan": [
- ObsoleteImportMatch(
- reason="replaced by FanEntityFeature enum",
- constant=re.compile(r"^SUPPORT_(\w*)$"),
- ),
- ],
- "homeassistant.components.humidifier": [
- ObsoleteImportMatch(
- reason="replaced by HumidifierDeviceClass enum",
- constant=re.compile(r"^DEVICE_CLASS_(\w*)$"),
- ),
- ObsoleteImportMatch(
- reason="replaced by HumidifierEntityFeature enum",
- constant=re.compile(r"^SUPPORT_(\w*)$"),
- ),
- ],
- "homeassistant.components.humidifier.const": [
- ObsoleteImportMatch(
- reason="replaced by HumidifierDeviceClass enum",
- constant=re.compile(r"^DEVICE_CLASS_(\w*)$"),
- ),
- ObsoleteImportMatch(
- reason="replaced by HumidifierEntityFeature enum",
- constant=re.compile(r"^SUPPORT_(\w*)$"),
- ),
- ],
- "homeassistant.components.lock": [
- ObsoleteImportMatch(
- reason="replaced by LockEntityFeature enum",
- constant=re.compile(r"^SUPPORT_(\w*)$"),
- ),
- ],
"homeassistant.components.light": [
ObsoleteImportMatch(
reason="replaced by ColorMode enum",
@@ -225,52 +91,12 @@ _OBSOLETE_IMPORT: dict[str, list[ObsoleteImportMatch]] = {
constant=re.compile(r"^REPEAT_MODE(\w*)$"),
),
],
- "homeassistant.components.remote": [
- ObsoleteImportMatch(
- reason="replaced by RemoteEntityFeature enum",
- constant=re.compile(r"^SUPPORT_(\w*)$"),
- ),
- ],
- "homeassistant.components.sensor": [
- ObsoleteImportMatch(
- reason="replaced by SensorDeviceClass enum",
- constant=re.compile(r"^DEVICE_CLASS_(?!STATE_CLASSES)$"),
- ),
- ObsoleteImportMatch(
- reason="replaced by SensorStateClass enum",
- constant=re.compile(r"^STATE_CLASS_(\w*)$"),
- ),
- ],
- "homeassistant.components.siren": [
- ObsoleteImportMatch(
- reason="replaced by SirenEntityFeature enum",
- constant=re.compile(r"^SUPPORT_(\w*)$"),
- ),
- ],
- "homeassistant.components.siren.const": [
- ObsoleteImportMatch(
- reason="replaced by SirenEntityFeature enum",
- constant=re.compile(r"^SUPPORT_(\w*)$"),
- ),
- ],
- "homeassistant.components.switch": [
- ObsoleteImportMatch(
- reason="replaced by SwitchDeviceClass enum",
- constant=re.compile(r"^DEVICE_CLASS_(\w*)$"),
- ),
- ],
"homeassistant.components.vacuum": [
ObsoleteImportMatch(
reason="replaced by VacuumEntityFeature enum",
constant=re.compile(r"^SUPPORT_(\w*)$"),
),
],
- "homeassistant.components.water_heater": [
- ObsoleteImportMatch(
- reason="replaced by WaterHeaterEntityFeature enum",
- constant=re.compile(r"^SUPPORT_(\w*)$"),
- ),
- ],
"homeassistant.config_entries": [
ObsoleteImportMatch(
reason="replaced by ConfigEntryDisabler enum",
@@ -282,86 +108,6 @@ _OBSOLETE_IMPORT: dict[str, list[ObsoleteImportMatch]] = {
reason="replaced by local constants",
constant=re.compile(r"^CONF_UNIT_SYSTEM_(\w+)$"),
),
- ObsoleteImportMatch(
- reason="replaced by unit enums",
- constant=re.compile(r"^DATA_(\w+)$"),
- ),
- ObsoleteImportMatch(
- reason="replaced by ***DeviceClass enum",
- constant=re.compile(r"^DEVICE_CLASS_(\w+)$"),
- ),
- ObsoleteImportMatch(
- reason="replaced by unit enums",
- constant=re.compile(r"^ELECTRIC_(\w+)$"),
- ),
- ObsoleteImportMatch(
- reason="replaced by unit enums",
- constant=re.compile(r"^ENERGY_(\w+)$"),
- ),
- ObsoleteImportMatch(
- reason="replaced by EntityCategory enum",
- constant=re.compile(r"^(ENTITY_CATEGORY_(\w+))|(ENTITY_CATEGORIES)$"),
- ),
- ObsoleteImportMatch(
- reason="replaced by unit enums",
- constant=re.compile(r"^FREQUENCY_(\w+)$"),
- ),
- ObsoleteImportMatch(
- reason="replaced by unit enums",
- constant=re.compile(r"^IRRADIATION_(\w+)$"),
- ),
- ObsoleteImportMatch(
- reason="replaced by unit enums",
- constant=re.compile(r"^LENGTH_(\w+)$"),
- ),
- ObsoleteImportMatch(
- reason="replaced by unit enums",
- constant=re.compile(r"^MASS_(\w+)$"),
- ),
- ObsoleteImportMatch(
- reason="replaced by unit enums",
- constant=re.compile(r"^POWER_(?!VOLT_AMPERE_REACTIVE)(\w+)$"),
- ),
- ObsoleteImportMatch(
- reason="replaced by unit enums",
- constant=re.compile(r"^PRECIPITATION_(\w+)$"),
- ),
- ObsoleteImportMatch(
- reason="replaced by unit enums",
- constant=re.compile(r"^PRESSURE_(\w+)$"),
- ),
- ObsoleteImportMatch(
- reason="replaced by unit enums",
- constant=re.compile(r"^SOUND_PRESSURE_(\w+)$"),
- ),
- ObsoleteImportMatch(
- reason="replaced by unit enums",
- constant=re.compile(r"^SPEED_(\w+)$"),
- ),
- ObsoleteImportMatch(
- reason="replaced by unit enums",
- constant=re.compile(r"^TEMP_(\w+)$"),
- ),
- ObsoleteImportMatch(
- reason="replaced by unit enums",
- constant=re.compile(r"^TIME_(\w+)$"),
- ),
- ObsoleteImportMatch(
- reason="replaced by unit enums",
- constant=re.compile(r"^VOLUME_(\w+)$"),
- ),
- ],
- "homeassistant.core": [
- ObsoleteImportMatch(
- reason="replaced by ConfigSource enum",
- constant=re.compile(r"^SOURCE_(\w*)$"),
- ),
- ],
- "homeassistant.data_entry_flow": [
- ObsoleteImportMatch(
- reason="replaced by FlowResultType enum",
- constant=re.compile(r"^RESULT_TYPE_(\w*)$"),
- ),
],
"homeassistant.helpers.config_validation": [
ObsoleteImportMatch(
@@ -369,12 +115,6 @@ _OBSOLETE_IMPORT: dict[str, list[ObsoleteImportMatch]] = {
constant=re.compile(r"^PLATFORM_SCHEMA(_BASE)?$"),
),
],
- "homeassistant.helpers.device_registry": [
- ObsoleteImportMatch(
- reason="replaced by DeviceEntryDisabler enum",
- constant=re.compile(r"^DISABLED_(\w*)$"),
- ),
- ],
"homeassistant.helpers.json": [
ObsoleteImportMatch(
reason="moved to homeassistant.util.json",
@@ -383,12 +123,6 @@ _OBSOLETE_IMPORT: dict[str, list[ObsoleteImportMatch]] = {
),
),
],
- "homeassistant.util": [
- ObsoleteImportMatch(
- reason="replaced by unit_conversion.***Converter",
- constant=re.compile(r"^(distance|pressure|speed|temperature|volume)$"),
- ),
- ],
"homeassistant.util.unit_system": [
ObsoleteImportMatch(
reason="replaced by US_CUSTOMARY_SYSTEM",
diff --git a/pyproject.toml b/pyproject.toml
index 7855a6671cc..3889dadff74 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "homeassistant"
-version = "2024.12.0.dev0"
+version = "2025.2.0.dev0"
license = {text = "Apache-2.0"}
description = "Open-source home automation platform running on Python 3."
readme = "README.rst"
@@ -19,6 +19,7 @@ classifiers = [
"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"
@@ -27,56 +28,62 @@ dependencies = [
# 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.1",
- "aiohttp==3.11.0b4",
+ "aiohasupervisor==0.2.2b5",
+ "aiohttp==3.11.11",
"aiohttp_cors==0.7.0",
- "aiohttp-fast-zlib==0.1.1",
+ "aiohttp-fast-zlib==0.2.0",
+ "aiohttp-asyncmdnsresolver==0.0.1",
"aiozoneinfo==0.2.1",
"astral==2.2",
"async-interrupt==1.2.0",
"attrs==24.2.0",
"atomicwrites-homeassistant==1.4.1",
+ "audioop-lts==0.2.1;python_version>='3.13'",
"awesomeversion==24.6.0",
"bcrypt==4.2.0",
"certifi>=2021.5.30",
- "ciso8601==2.3.1",
+ "ciso8601==2.3.2",
+ "cronsim==2.6",
"fnv-hash-fast==1.0.2",
# hass-nabucasa is imported by helpers which don't depend on the cloud
# integration
- "hass-nabucasa==0.84.0",
+ "hass-nabucasa==0.87.0",
# When bumping httpx, please check the version pins of
# httpcore, anyio, and h11 in gen_requirements_all
"httpx==0.27.2",
"home-assistant-bluetooth==1.13.0",
"ifaddr==0.2.0",
- "Jinja2==3.1.4",
+ "Jinja2==3.1.5",
"lru-dict==1.3.0",
- "PyJWT==2.9.0",
+ "PyJWT==2.10.1",
# PyJWT has loose dependency. We want the latest one.
- "cryptography==43.0.1",
- "Pillow==10.4.0",
- "propcache==0.2.0",
- "pyOpenSSL==24.2.1",
- "orjson==3.10.11",
+ "cryptography==44.0.0",
+ "Pillow==11.1.0",
+ "propcache==0.2.1",
+ "pyOpenSSL==24.3.0",
+ "orjson==3.10.12",
"packaging>=23.1",
"psutil-home-assistant==0.0.1",
"python-slugify==8.0.4",
"PyYAML==6.0.2",
"requests==2.32.3",
- "securetar==2024.2.1",
- "SQLAlchemy==2.0.31",
+ "securetar==2024.11.0",
+ "SQLAlchemy==2.0.36",
+ "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",
# 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.0",
+ "uv==0.5.8",
"voluptuous==0.15.2",
"voluptuous-serialize==2.6.0",
- "voluptuous-openapi==0.0.5",
- "yarl==1.17.1",
- "webrtc-models==0.2.0",
+ "voluptuous-openapi==0.0.6",
+ "yarl==1.18.3",
+ "webrtc-models==0.3.0",
+ "zeroconf==0.137.2"
]
[project.urls]
@@ -91,8 +98,6 @@ dependencies = [
hass = "homeassistant.__main__:main"
[tool.setuptools]
-platforms = ["any"]
-zip-safe = false
include-package-data = true
[tool.setuptools.packages.find]
@@ -525,12 +530,8 @@ filterwarnings = [
# https://github.com/rytilahti/python-miio/pull/1809 - >=0.6.0.dev0
"ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:miio.protocol",
"ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:miio.miioprotocol",
- # https://github.com/hunterjm/python-onvif-zeep-async/pull/51 - >3.1.12
- "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:onvif.client",
# https://github.com/okunishinishi/python-stringcase/commit/6a5c5bbd3fe5337862abc7fd0853a0f36e18b2e1 - >1.2.0
"ignore:invalid escape sequence:SyntaxWarning:.*stringcase",
- # https://github.com/cereal2nd/velbus-aio/pull/126 - >2024.10.0
- "ignore:pkg_resources is deprecated as an API:DeprecationWarning:velbusaio.handler",
# -- fixed for Python 3.13
# https://github.com/rhasspy/wyoming/commit/e34af30d455b6f2bb9e5cfb25fad8d276914bc54 - >=1.4.2
@@ -617,6 +618,17 @@ filterwarnings = [
# https://github.com/ssaenger/pyws66i/blob/v1.1/pyws66i/__init__.py#L2
"ignore:'telnetlib' is deprecated and slated for removal in Python 3.13:DeprecationWarning:pyws66i",
+ # -- New in Python 3.13
+ # https://github.com/kurtmckee/feedparser/pull/389 - >6.0.11
+ # https://github.com/kurtmckee/feedparser/issues/481
+ "ignore:'count' is passed as positional argument:DeprecationWarning:feedparser.html",
+ # https://github.com/youknowone/python-deadlib - Backports for aifc, telnetlib
+ "ignore:aifc was removed in Python 3.13.*'standard-aifc':DeprecationWarning:speech_recognition",
+ "ignore:telnetlib was removed in Python 3.13.*'standard-telnetlib':DeprecationWarning:homeassistant.components.hddtemp.sensor",
+ "ignore:telnetlib was removed in Python 3.13.*'standard-telnetlib':DeprecationWarning:ndms2_client.connection",
+ "ignore:telnetlib was removed in Python 3.13.*'standard-telnetlib':DeprecationWarning:plumlightpad.lightpad",
+ "ignore:telnetlib was removed in Python 3.13.*'standard-telnetlib':DeprecationWarning:pyws66i",
+
# -- unmaintained projects, last release about 2+ years
# https://pypi.org/project/agent-py/ - v0.0.23 - 2020-06-04
"ignore:with timeout\\(\\) is deprecated:DeprecationWarning:agent.a",
@@ -689,7 +701,7 @@ exclude_lines = [
]
[tool.ruff]
-required-version = ">=0.6.8"
+required-version = ">=0.8.0"
[tool.ruff.lint]
select = [
@@ -772,7 +784,7 @@ select = [
"SLOT", # flake8-slots
"T100", # Trace found: {name} used
"T20", # flake8-print
- "TCH", # flake8-type-checking
+ "TC", # flake8-type-checking
"TID", # Tidy imports
"TRY", # tryceratops
"UP", # pyupgrade
@@ -796,7 +808,6 @@ ignore = [
"PLR0915", # Too many statements ({statements} > {max_statements})
"PLR2004", # Magic value used in comparison, consider replacing {value} with a constant variable
"PLW2901", # Outer {outer_kind} variable {name} overwritten by inner {inner_kind} target
- "PT004", # Fixture {fixture} does not return anything, add leading underscore
"PT011", # pytest.raises({exception}) is too broad, set the `match` parameter or use a more specific exception
"PT018", # Assertion should be broken down into multiple parts
"RUF001", # String contains ambiguous unicode character.
@@ -809,9 +820,9 @@ ignore = [
"SIM115", # Use context handler for opening files
# Moving imports into type-checking blocks can mess with pytest.patch()
- "TCH001", # Move application import {} into a type-checking block
- "TCH002", # Move third-party import {} into a type-checking block
- "TCH003", # Move standard library import {} into a type-checking block
+ "TC001", # Move application import {} into a type-checking block
+ "TC002", # Move third-party import {} into a type-checking block
+ "TC003", # Move standard library import {} into a type-checking block
"TRY003", # Avoid specifying long messages outside the exception class
"TRY400", # Use `logging.exception` instead of `logging.error`
@@ -895,6 +906,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
diff --git a/requirements.txt b/requirements.txt
index c7436cab5b8..9aaef2a6b79 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -4,45 +4,51 @@
# Home Assistant Core
aiodns==3.2.0
-aiohasupervisor==0.2.1
-aiohttp==3.11.0b4
+aiohasupervisor==0.2.2b5
+aiohttp==3.11.11
aiohttp_cors==0.7.0
-aiohttp-fast-zlib==0.1.1
+aiohttp-fast-zlib==0.2.0
+aiohttp-asyncmdnsresolver==0.0.1
aiozoneinfo==0.2.1
astral==2.2
async-interrupt==1.2.0
attrs==24.2.0
atomicwrites-homeassistant==1.4.1
+audioop-lts==0.2.1;python_version>='3.13'
awesomeversion==24.6.0
bcrypt==4.2.0
certifi>=2021.5.30
-ciso8601==2.3.1
+ciso8601==2.3.2
+cronsim==2.6
fnv-hash-fast==1.0.2
-hass-nabucasa==0.84.0
+hass-nabucasa==0.87.0
httpx==0.27.2
home-assistant-bluetooth==1.13.0
ifaddr==0.2.0
-Jinja2==3.1.4
+Jinja2==3.1.5
lru-dict==1.3.0
-PyJWT==2.9.0
-cryptography==43.0.1
-Pillow==10.4.0
-propcache==0.2.0
-pyOpenSSL==24.2.1
-orjson==3.10.11
+PyJWT==2.10.1
+cryptography==44.0.0
+Pillow==11.1.0
+propcache==0.2.1
+pyOpenSSL==24.3.0
+orjson==3.10.12
packaging>=23.1
psutil-home-assistant==0.0.1
python-slugify==8.0.4
PyYAML==6.0.2
requests==2.32.3
-securetar==2024.2.1
-SQLAlchemy==2.0.31
+securetar==2024.11.0
+SQLAlchemy==2.0.36
+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
urllib3>=1.26.5,<2
-uv==0.5.0
+uv==0.5.8
voluptuous==0.15.2
voluptuous-serialize==2.6.0
-voluptuous-openapi==0.0.5
-yarl==1.17.1
-webrtc-models==0.2.0
+voluptuous-openapi==0.0.6
+yarl==1.18.3
+webrtc-models==0.3.0
+zeroconf==0.137.2
diff --git a/requirements_all.txt b/requirements_all.txt
index f883405070c..ba7f5883a45 100644
--- a/requirements_all.txt
+++ b/requirements_all.txt
@@ -4,10 +4,10 @@
-r requirements.txt
# homeassistant.components.aemet
-AEMET-OpenData==0.5.4
+AEMET-OpenData==0.6.4
# homeassistant.components.honeywell
-AIOSomecomfort==0.0.25
+AIOSomecomfort==0.0.28
# 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==10.4.0
+Pillow==11.1.0
# homeassistant.components.plex
PlexAPI==4.15.16
@@ -48,7 +48,7 @@ ProgettiHWSW==0.1.3
PyChromecast==14.0.5
# homeassistant.components.flick_electric
-PyFlick==0.0.2
+PyFlick==1.1.2
# homeassistant.components.flume
PyFlume==0.6.5
@@ -60,7 +60,7 @@ PyFronius==0.7.3
PyLoadAPI==1.3.2
# homeassistant.components.met_eireann
-PyMetEireann==2021.8.0
+PyMetEireann==2024.11.0
# homeassistant.components.met
# homeassistant.components.norway_air
@@ -70,7 +70,7 @@ PyMetno==0.13.0
PyMicroBot==0.0.17
# homeassistant.components.nina
-PyNINA==0.3.3
+PyNINA==0.3.4
# homeassistant.components.mobile_app
# homeassistant.components.owntracks
@@ -84,13 +84,13 @@ PyQRCode==1.2.1
PyRMVtransport==0.3.3
# homeassistant.components.switchbot
-PySwitchbot==0.51.0
+PySwitchbot==0.55.4
# homeassistant.components.switchmate
PySwitchmate==0.5.1
# homeassistant.components.syncthru
-PySyncThru==0.7.10
+PySyncThru==0.8.0
# homeassistant.components.transport_nsw
PyTransportNSW==0.1.1
@@ -100,7 +100,7 @@ PyTransportNSW==0.1.1
PyTurboJPEG==1.7.5
# homeassistant.components.vicare
-PyViCare==2.35.0
+PyViCare==2.39.1
# homeassistant.components.xiaomi_aqara
PyXiaomiGateway==0.14.3
@@ -116,7 +116,7 @@ RtmAPI==0.7.2
# homeassistant.components.recorder
# homeassistant.components.sql
-SQLAlchemy==2.0.31
+SQLAlchemy==2.0.36
# homeassistant.components.tami4
Tami4EdgeAPI==3.0
@@ -131,7 +131,7 @@ TwitterAPI==2.7.12
WSDiscovery==2.0.0
# homeassistant.components.accuweather
-accuweather==3.0.0
+accuweather==4.0.0
# homeassistant.components.adax
adax==0.4.0
@@ -155,7 +155,7 @@ afsapi==0.2.7
agent-py==0.0.24
# homeassistant.components.geo_json_events
-aio-geojson-generic-client==0.4
+aio-geojson-generic-client==0.5
# homeassistant.components.geonetnz_quakes
aio-geojson-geonetnz-quakes==0.16
@@ -172,14 +172,17 @@ aio-geojson-usgs-earthquakes==0.3
# homeassistant.components.gdacs
aio-georss-gdacs==0.10
+# homeassistant.components.acaia
+aioacaia==0.1.13
+
# homeassistant.components.airq
-aioairq==0.3.2
+aioairq==0.4.3
# homeassistant.components.airzone_cloud
aioairzone-cloud==0.6.10
# homeassistant.components.airzone
-aioairzone==0.9.5
+aioairzone==0.9.7
# homeassistant.components.ambient_network
# homeassistant.components.ambient_station
@@ -198,7 +201,7 @@ aioaseko==1.0.0
aioasuswrt==1.4.0
# homeassistant.components.husqvarna_automower
-aioautomower==2024.10.3
+aioautomower==2025.1.0
# homeassistant.components.azure_devops
aioazuredevops==2.2.1
@@ -210,7 +213,7 @@ aiobafi6==0.9.0
aiobotocore==2.13.1
# homeassistant.components.comelit
-aiocomelit==0.9.1
+aiocomelit==0.10.1
# homeassistant.components.dhcp
aiodhcpwatcher==1.0.2
@@ -240,7 +243,7 @@ aioelectricitymaps==0.4.0
aioemonitor==1.0.5
# homeassistant.components.esphome
-aioesphomeapi==27.0.1
+aioesphomeapi==28.0.0
# homeassistant.components.flo
aioflo==2021.11.0
@@ -249,7 +252,6 @@ aioflo==2021.11.0
aioftp==0.21.3
# homeassistant.components.github
-# homeassistant.components.iron_os
aiogithubapi==24.6.0
# homeassistant.components.guardian
@@ -259,10 +261,13 @@ aioguardian==2022.07.0
aioharmony==0.2.10
# homeassistant.components.hassio
-aiohasupervisor==0.2.1
+aiohasupervisor==0.2.2b5
# homeassistant.components.homekit_controller
-aiohomekit==3.2.6
+aiohomekit==3.2.7
+
+# homeassistant.components.mcp_server
+aiohttp_sse==2.2.0
# homeassistant.components.hue
aiohue==4.7.3
@@ -280,13 +285,10 @@ aiokef==0.2.16
aiolifx-effects==0.3.2
# homeassistant.components.lifx
-aiolifx-themes==0.5.5
+aiolifx-themes==0.6.0
# homeassistant.components.lifx
-aiolifx==1.1.1
-
-# homeassistant.components.livisi
-aiolivisi==0.0.19
+aiolifx==1.1.2
# homeassistant.components.lookin
aiolookin==1.0.0
@@ -295,7 +297,7 @@ aiolookin==1.0.0
aiolyric==2.0.1
# homeassistant.components.mealie
-aiomealie==0.9.3
+aiomealie==0.9.5
# homeassistant.components.modern_forms
aiomodernforms==0.1.8
@@ -322,13 +324,13 @@ aioopenexchangerates==0.6.8
aiooui==0.1.7
# homeassistant.components.pegel_online
-aiopegelonline==0.0.10
+aiopegelonline==0.1.1
# homeassistant.components.acmeda
aiopulse==0.4.6
# homeassistant.components.purpleair
-aiopurpleair==2022.12.1
+aiopurpleair==2023.12.0
# homeassistant.components.hunterdouglas_powerview
aiopvapi==3.1.1
@@ -354,10 +356,10 @@ aiorecollect==2023.09.0
aioridwell==2024.01.0
# homeassistant.components.ruckus_unleashed
-aioruckus==0.41
+aioruckus==0.42
# homeassistant.components.russound_rio
-aiorussound==4.0.5
+aiorussound==4.4.0
# homeassistant.components.ruuvi_gateway
aioruuvigateway==0.1.0
@@ -366,7 +368,7 @@ aioruuvigateway==0.1.0
aiosenz==1.0.0
# homeassistant.components.shelly
-aioshelly==12.0.1
+aioshelly==12.2.0
# homeassistant.components.skybell
aioskybell==22.7.0
@@ -381,10 +383,10 @@ aiosolaredge==0.2.0
aiosteamist==1.0.0
# homeassistant.components.cambridge_audio
-aiostreammagic==2.8.4
+aiostreammagic==2.10.0
# homeassistant.components.switcher_kis
-aioswitcher==4.4.0
+aioswitcher==6.0.0
# homeassistant.components.syncthing
aiosyncthing==0.5.1
@@ -392,11 +394,14 @@ aiosyncthing==0.5.1
# homeassistant.components.tankerkoenig
aiotankerkoenig==0.4.2
+# homeassistant.components.tedee
+aiotedee==0.2.20
+
# homeassistant.components.tractive
aiotractive==0.6.0
# homeassistant.components.unifi
-aiounifi==80
+aiounifi==81
# homeassistant.components.vlc_telnet
aiovlc==0.5.1
@@ -414,7 +419,7 @@ aiowatttime==0.1.1
aiowebostv==0.4.2
# homeassistant.components.withings
-aiowithings==3.1.1
+aiowithings==3.1.4
# homeassistant.components.yandex_transport
aioymaps==1.2.5
@@ -435,19 +440,19 @@ airthings-cloud==0.2.0
airtouch4pyapi==1.0.5
# homeassistant.components.airtouch5
-airtouch5py==0.2.10
+airtouch5py==0.2.11
# homeassistant.components.alpha_vantage
alpha-vantage==2.3.1
# homeassistant.components.amberelectric
-amberelectric==1.1.1
+amberelectric==2.0.12
# homeassistant.components.amcrest
amcrest==1.9.8
# homeassistant.components.androidtv
-androidtv[async]==0.0.73
+androidtv[async]==0.0.75
# homeassistant.components.androidtv_remote
androidtvremote2==0.1.2
@@ -464,23 +469,26 @@ anthemav==1.4.1
# homeassistant.components.anthropic
anthropic==0.31.2
+# homeassistant.components.mcp_server
+anyio==4.7.0
+
# homeassistant.components.weatherkit
apple_weatherkit==1.1.3
# homeassistant.components.apprise
-apprise==1.9.0
+apprise==1.9.1
# homeassistant.components.aprs
aprslib==0.7.2
# homeassistant.components.apsystems
-apsystems-ez1==2.2.1
+apsystems-ez1==2.4.0
# homeassistant.components.aqualogic
aqualogic==2.6
# homeassistant.components.aranet
-aranet4==2.4.0
+aranet4==2.5.0
# homeassistant.components.arcam_fmj
arcam-fmj==1.5.2
@@ -497,7 +505,7 @@ asmog==0.0.6
# homeassistant.components.ssdp
# homeassistant.components.upnp
# homeassistant.components.yeelight
-async-upnp-client==0.41.0
+async-upnp-client==0.42.0
# homeassistant.components.arve
asyncarve==0.1.1
@@ -537,10 +545,10 @@ av==13.1.0
# avion==0.10
# homeassistant.components.axis
-axis==63
+axis==64
# homeassistant.components.fujitsu_fglair
-ayla-iot-unofficial==1.4.3
+ayla-iot-unofficial==1.4.4
# homeassistant.components.azure_event_hub
azure-eventhub==5.11.1
@@ -576,14 +584,14 @@ beautifulsoup4==4.12.3
# beewi-smartclim==0.0.10
# homeassistant.components.bmw_connected_drive
-bimmer-connected[china]==0.16.4
+bimmer-connected[china]==0.17.2
# homeassistant.components.bizkaibus
bizkaibus==0.1.1
# homeassistant.components.eq3btsmart
# homeassistant.components.esphome
-bleak-esphome==1.1.0
+bleak-esphome==2.0.0
# homeassistant.components.bluetooth
bleak-retry-connector==3.6.0
@@ -611,7 +619,7 @@ bluemaestro-ble==0.2.3
# bluepy==1.3.0
# homeassistant.components.bluetooth
-bluetooth-adapters==0.20.0
+bluetooth-adapters==0.20.2
# homeassistant.components.bluetooth
bluetooth-auto-recovery==1.4.2
@@ -701,6 +709,10 @@ connect-box==0.3.1
# homeassistant.components.xiaomi_miio
construct==2.10.68
+# homeassistant.components.cookidoo
+cookidoo-api==0.12.2
+
+# homeassistant.components.backup
# homeassistant.components.utility_meter
cronsim==2.6
@@ -720,10 +732,10 @@ datadog==0.15.0
datapoint==0.9.9
# homeassistant.components.bluetooth
-dbus-fast==2.24.3
+dbus-fast==2.28.0
# homeassistant.components.debugpy
-debugpy==1.8.6
+debugpy==1.8.11
# homeassistant.components.decora_wifi
# decora-wifi==1.4
@@ -732,7 +744,7 @@ debugpy==1.8.6
# decora==0.6
# homeassistant.components.ecovacs
-deebot-client==8.4.0
+deebot-client==10.1.0
# homeassistant.components.ihc
# homeassistant.components.namecheapdns
@@ -743,10 +755,10 @@ defusedxml==0.7.1
deluge-client==1.10.2
# homeassistant.components.lametric
-demetriek==0.4.0
+demetriek==1.1.1
# homeassistant.components.denonavr
-denonavr==1.0.0
+denonavr==1.0.1
# homeassistant.components.devialet
devialet==1.4.5
@@ -802,11 +814,14 @@ ebusdpy==0.0.17
# homeassistant.components.ecoal_boiler
ecoaliface==0.4.0
+# homeassistant.components.eheimdigital
+eheimdigital==1.0.3
+
# homeassistant.components.electric_kiwi
electrickiwi-api==0.8.5
# homeassistant.components.elevenlabs
-elevenlabs==1.6.1
+elevenlabs==1.9.0
# homeassistant.components.elgato
elgato==5.1.2
@@ -818,7 +833,7 @@ eliqonline==1.2.2
elkm1-lib==2.2.10
# homeassistant.components.elmax
-elmax-api==0.0.5
+elmax-api==0.0.6.4rc0
# homeassistant.components.elvia
elvia==0.1.0
@@ -857,7 +872,7 @@ epion==0.0.3
epson-projector==0.5.1
# homeassistant.components.eq3btsmart
-eq3btsmart==1.2.0
+eq3btsmart==1.4.1
# homeassistant.components.esphome
esphome-dashboard-api==1.2.3
@@ -872,7 +887,7 @@ eufylife-ble-client==0.1.8
# evdev==1.6.1
# homeassistant.components.evohome
-evohome-async==0.4.20
+evohome-async==0.4.21
# homeassistant.components.bryant_evolution
evolutionhttp==0.0.18
@@ -906,7 +921,7 @@ fivem-api==0.1.2
fixerio==1.0.0a0
# homeassistant.components.fjaraskupan
-fjaraskupan==2.3.0
+fjaraskupan==2.3.2
# homeassistant.components.flexit_bacnet
flexit_bacnet==2.2.1
@@ -915,7 +930,7 @@ flexit_bacnet==2.2.1
flipr-api==1.6.1
# homeassistant.components.flux_led
-flux-led==1.0.4
+flux-led==1.1.0
# homeassistant.components.homekit
# homeassistant.components.recorder
@@ -925,13 +940,13 @@ fnv-hash-fast==1.0.2
foobot_async==1.0.0
# homeassistant.components.forecast_solar
-forecast-solar==3.1.0
+forecast-solar==4.0.0
# homeassistant.components.fortios
fortiosapi==1.0.5
# homeassistant.components.freebox
-freebox-api==1.1.0
+freebox-api==1.2.1
# homeassistant.components.free_mobile
freesms==0.2.0
@@ -941,19 +956,19 @@ freesms==0.2.0
fritzconnection[qr]==1.14.0
# homeassistant.components.fyta
-fyta_cli==0.6.10
+fyta_cli==0.7.0
# homeassistant.components.google_translate
gTTS==2.2.4
# homeassistant.components.gardena_bluetooth
-gardena-bluetooth==1.4.4
+gardena-bluetooth==1.5.0
# homeassistant.components.google_assistant_sdk
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
@@ -990,7 +1005,7 @@ gitterpy==0.1.7
glances-api==0.8.0
# homeassistant.components.go2rtc
-go2rtc-client==0.1.0
+go2rtc-client==0.1.2
# homeassistant.components.goalzero
goalzero==0.2.2
@@ -1015,7 +1030,7 @@ google-cloud-texttospeech==2.17.2
google-generativeai==0.8.2
# homeassistant.components.nest
-google-nest-sdm==6.1.4
+google-nest-sdm==7.0.0
# homeassistant.components.google_photos
google-photos-library-api==0.12.1
@@ -1024,10 +1039,11 @@ google-photos-library-api==0.12.1
googlemaps==2.5.1
# homeassistant.components.slide
+# homeassistant.components.slide_local
goslide-api==0.7.0
# homeassistant.components.tailwind
-gotailwind==0.2.4
+gotailwind==0.3.0
# homeassistant.components.govee_ble
govee-ble==0.40.0
@@ -1063,7 +1079,7 @@ gspread==5.5.0
gstreamer-player==1.1.2
# homeassistant.components.profiler
-guppy3==3.1.4.post1
+guppy3==3.1.5
# homeassistant.components.iaqualink
h2==4.1.0
@@ -1078,22 +1094,22 @@ ha-iotawattpy==0.1.2
ha-philipsjs==3.2.2
# homeassistant.components.habitica
-habitipy==0.3.3
+habiticalib==0.3.2
# homeassistant.components.bluetooth
-habluetooth==3.6.0
+habluetooth==3.7.0
# homeassistant.components.cloud
-hass-nabucasa==0.84.0
+hass-nabucasa==0.87.0
# homeassistant.components.splunk
hass-splunk==0.1.1
# homeassistant.components.conversation
-hassil==1.7.4
+hassil==2.1.0
# homeassistant.components.jewish_calendar
-hdate==0.10.9
+hdate==0.11.1
# homeassistant.components.heatmiser
heatmiserV3==2.0.3
@@ -1121,19 +1137,19 @@ hole==0.8.0
# homeassistant.components.holiday
# homeassistant.components.workday
-holidays==0.60
+holidays==0.64
# homeassistant.components.frontend
-home-assistant-frontend==20241106.2
+home-assistant-frontend==20250106.0
# homeassistant.components.conversation
-home-assistant-intents==2024.11.6
+home-assistant-intents==2025.1.1
# homeassistant.components.home_connect
homeconnect==0.8.0
# homeassistant.components.homematicip_cloud
-homematicip==1.1.2
+homematicip==1.1.5
# homeassistant.components.horizon
horimote==0.4.1
@@ -1145,7 +1161,7 @@ httplib2==0.20.4
huawei-lte-api==1.10.0
# homeassistant.components.huum
-huum==0.7.11
+huum==0.7.12
# homeassistant.components.hyperion
hyperion-py==0.7.5
@@ -1165,13 +1181,16 @@ 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
# homeassistant.components.ping
icmplib==3.0
# homeassistant.components.idasen_desk
-idasen-ha==2.6.2
+idasen-ha==2.6.3
# homeassistant.components.network
ifaddr==0.2.0
@@ -1179,14 +1198,17 @@ ifaddr==0.2.0
# homeassistant.components.iglo
iglo==1.2.7
+# homeassistant.components.igloohome
+igloohome-api==0.0.6
+
# homeassistant.components.ihc
ihcsdk==2.8.5
# homeassistant.components.imgw_pib
-imgw_pib==1.0.6
+imgw_pib==1.0.7
# homeassistant.components.incomfort
-incomfort-client==0.6.3-1
+incomfort-client==0.6.4
# homeassistant.components.influxdb
influxdb-client==1.24.0
@@ -1204,7 +1226,7 @@ insteon-frontend-home-assistant==0.5.0
intellifire4py==4.1.9
# homeassistant.components.iotty
-iottycloud==0.2.1
+iottycloud==0.3.0
# homeassistant.components.iperf3
iperf3==0.1.11
@@ -1235,7 +1257,7 @@ justnimbus==0.7.4
kaiterra-async-client==1.0.0
# homeassistant.components.keba
-keba-kecontact==1.1.0
+keba-kecontact==1.3.0
# homeassistant.components.kegtron
kegtron-ble==0.4.0
@@ -1244,10 +1266,10 @@ kegtron-ble==0.4.0
kiwiki-client==0.1.1
# homeassistant.components.knocki
-knocki==0.3.5
+knocki==0.4.2
# homeassistant.components.knx
-knx-frontend==2024.9.10.221729
+knx-frontend==2024.12.26.233449
# homeassistant.components.konnected
konnected==1.2.0
@@ -1265,7 +1287,7 @@ lakeside==0.13
laundrify-aio==1.2.2
# homeassistant.components.lcn
-lcn-frontend==0.2.1
+lcn-frontend==0.2.2
# homeassistant.components.ld2410_ble
ld2410-ble==0.1.1
@@ -1274,7 +1296,7 @@ ld2410-ble==0.1.1
leaone-ble==0.1.0
# homeassistant.components.led_ble
-led-ble==1.0.2
+led-ble==1.1.1
# homeassistant.components.lektrico
lektricowifi==0.0.43
@@ -1306,8 +1328,8 @@ linear-garage-door==0.2.9
# homeassistant.components.linode
linode-api==4.1.9b1
-# homeassistant.components.lamarzocco
-lmcloud==1.2.3
+# homeassistant.components.livisi
+livisi==0.0.24
# homeassistant.components.google_maps
locationsharinglib==5.0.1
@@ -1339,6 +1361,9 @@ maxcube-api==0.4.3
# homeassistant.components.mythicbeastsdns
mbddns==0.1.2
+# homeassistant.components.mcp_server
+mcp==1.1.2
+
# homeassistant.components.minecraft_server
mcstatus==11.1.1
@@ -1367,13 +1392,13 @@ mficlient==0.5.0
micloud==0.5
# homeassistant.components.microbees
-microBeesPy==0.3.2
+microBeesPy==0.3.5
# homeassistant.components.mill
mill-local==0.3.0
# homeassistant.components.mill
-millheater==0.11.8
+millheater==0.12.2
# homeassistant.components.minio
minio==7.1.12
@@ -1394,19 +1419,19 @@ mopeka-iot-ble==0.8.0
motionblinds==0.6.25
# homeassistant.components.motionblinds_ble
-motionblindsble==0.1.2
+motionblindsble==0.1.3
# homeassistant.components.motioneye
motioneye-client==0.3.14
# homeassistant.components.bang_olufsen
-mozart-api==4.1.1.116.0
+mozart-api==4.1.1.116.4
# homeassistant.components.mullvad
mullvad-api==1.0.0
# homeassistant.components.music_assistant
-music-assistant-client==1.0.5
+music-assistant-client==1.0.8
# homeassistant.components.tts
mutagen==1.47.0
@@ -1430,13 +1455,13 @@ ndms2-client==0.1.2
nessclient==1.1.2
# homeassistant.components.netdata
-netdata==1.1.0
+netdata==1.3.0
# homeassistant.components.nmap_tracker
netmap==0.7.0.2
# homeassistant.components.nam
-nettigo-air-monitor==3.3.0
+nettigo-air-monitor==4.0.0
# homeassistant.components.neurio_energy
neurio==0.3.1
@@ -1451,16 +1476,16 @@ nextcloudmonitor==1.5.1
nextcord==2.6.0
# homeassistant.components.nextdns
-nextdns==3.3.0
-
-# homeassistant.components.nibe_heatpump
-nibe==2.11.0
-
-# homeassistant.components.nice_go
-nice-go==0.3.10
+nextdns==4.0.0
# homeassistant.components.niko_home_control
-niko-home-control==0.2.1
+nhc==0.3.2
+
+# homeassistant.components.nibe_heatpump
+nibe==2.14.0
+
+# homeassistant.components.nice_go
+nice-go==1.0.0
# homeassistant.components.nilu
niluclient==0.1.2
@@ -1475,7 +1500,7 @@ notifications-android-tv==0.1.5
notify-events==1.0.4
# homeassistant.components.nederlandse_spoorwegen
-nsapi==3.0.5
+nsapi==3.1.2
# homeassistant.components.nsw_fuel_station
nsw-fuel-api-client==1.1.0
@@ -1491,7 +1516,7 @@ numato-gpio==0.13.0
# homeassistant.components.stream
# homeassistant.components.tensorflow
# homeassistant.components.trend
-numpy==2.1.2
+numpy==2.2.1
# homeassistant.components.nyt_games
nyt_games==0.4.4
@@ -1511,8 +1536,11 @@ odp-amsterdam==6.0.2
# homeassistant.components.oem
oemthermostat==1.1.1
+# homeassistant.components.ohme
+ohme==1.2.3
+
# homeassistant.components.ollama
-ollama==0.3.3
+ollama==0.4.5
# homeassistant.components.omnilogic
omnilogic==0.4.5
@@ -1521,13 +1549,13 @@ omnilogic==0.4.5
ondilo==0.5.0
# homeassistant.components.onvif
-onvif-zeep-async==3.1.12
+onvif-zeep-async==3.1.13
# homeassistant.components.opengarage
open-garage==0.2.0
# homeassistant.components.open_meteo
-open-meteo==0.3.1
+open-meteo==0.3.2
# homeassistant.components.openai_conversation
openai==1.35.7
@@ -1545,7 +1573,7 @@ openhomedevice==2.2.0
opensensemap-api==0.2.0
# homeassistant.components.enigma2
-openwebifpy==4.2.7
+openwebifpy==4.3.1
# homeassistant.components.luci
openwrt-luci-rpc==1.1.17
@@ -1554,7 +1582,7 @@ openwrt-luci-rpc==1.1.17
openwrt-ubus-rpc==0.0.2
# homeassistant.components.opower
-opower==0.8.6
+opower==0.8.7
# homeassistant.components.oralb
oralb-ble==0.17.6
@@ -1586,6 +1614,9 @@ panasonic-viera==0.4.2
# homeassistant.components.dunehd
pdunehd==1.3.2
+# homeassistant.components.peblar
+peblar==0.3.3
+
# homeassistant.components.peco
peco==0.0.30
@@ -1598,7 +1629,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
@@ -1619,7 +1650,7 @@ plexauth==0.0.6
plexwebsocket==0.0.14
# homeassistant.components.plugwise
-plugwise==1.5.0
+plugwise==1.6.4
# homeassistant.components.plum_lightpad
plumlightpad==0.0.11
@@ -1630,6 +1661,9 @@ pmsensor==0.4
# homeassistant.components.poolsense
poolsense==0.0.8
+# homeassistant.components.powerfox
+powerfox==1.2.0
+
# homeassistant.components.reddit
praw==7.5.0
@@ -1651,14 +1685,11 @@ proxmoxer==2.0.1
psutil-home-assistant==0.0.1
# homeassistant.components.systemmonitor
-psutil==6.0.0
+psutil==6.1.1
# homeassistant.components.pulseaudio_loopback
pulsectl==23.5.2
-# homeassistant.components.androidtv
-pure-python-adb[async]==0.3.0.dev0
-
# homeassistant.components.pushbullet
pushbullet.py==0.11.0
@@ -1666,10 +1697,10 @@ pushbullet.py==0.11.0
pushover_complete==1.1.1
# homeassistant.components.pvoutput
-pvo==2.1.1
+pvo==2.2.0
# homeassistant.components.aosmith
-py-aosmith==1.0.10
+py-aosmith==1.0.12
# homeassistant.components.canary
py-canary==0.5.4
@@ -1705,10 +1736,7 @@ py-schluter==0.1.7
py-sucks==0.9.10
# homeassistant.components.synology_dsm
-py-synologydsm-api==2.5.3
-
-# homeassistant.components.zabbix
-py-zabbix==1.1.7
+py-synologydsm-api==2.6.0
# homeassistant.components.atome
pyAtome==0.1.1
@@ -1731,6 +1759,9 @@ pyEmby==1.10
# homeassistant.components.hikvision
pyHik==0.3.2
+# homeassistant.components.homee
+pyHomee==1.2.0
+
# homeassistant.components.rfxtrx
pyRFXtrx==0.31.1
@@ -1738,7 +1769,7 @@ pyRFXtrx==0.31.1
pySDCP==1
# homeassistant.components.tibber
-pyTibber==0.30.4
+pyTibber==0.30.8
# homeassistant.components.dlink
pyW215==0.7.0
@@ -1763,7 +1794,7 @@ pyairnow==1.2.1
pyairvisual==2023.08.1
# homeassistant.components.aprilaire
-pyaprilaire==0.7.4
+pyaprilaire==0.7.7
# homeassistant.components.asuswrt
pyasuswrt==0.1.21
@@ -1775,10 +1806,10 @@ pyatag==0.3.5.3
pyatmo==8.1.0
# homeassistant.components.apple_tv
-pyatv==0.15.1
+pyatv==0.16.0
# homeassistant.components.aussie_broadband
-pyaussiebb==0.0.15
+pyaussiebb==0.1.5
# homeassistant.components.balboa
pybalboa==1.0.2
@@ -1790,7 +1821,7 @@ pybbox==0.0.5-alpha
pyblackbird==0.6
# homeassistant.components.bluesound
-pyblu==1.0.4
+pyblu==2.0.0
# homeassistant.components.neato
pybotvac==0.0.25
@@ -1829,16 +1860,16 @@ pycountry==24.6.1
pycsspeechtts==1.0.8
# homeassistant.components.cups
-# pycups==1.9.73
+# pycups==2.0.4
# homeassistant.components.daikin
-pydaikin==2.13.7
+pydaikin==2.13.8
# homeassistant.components.danfoss_air
pydanfossair==0.1.0
# homeassistant.components.deako
-pydeako==0.5.4
+pydeako==0.6.0
# homeassistant.components.deconz
pydeconz==118
@@ -1856,7 +1887,7 @@ pydiscovergy==3.0.2
pydoods==1.0.2
# homeassistant.components.hydrawise
-pydrawise==2024.9.0
+pydrawise==2024.12.0
# homeassistant.components.android_ip_webcam
pydroid-ipcam==2.0.0
@@ -1889,7 +1920,7 @@ pyeiscp==0.0.7
pyemoncms==0.1.1
# homeassistant.components.enphase_envoy
-pyenphase==1.22.0
+pyenphase==1.23.0
# homeassistant.components.envisalink
pyenvisalink==4.7
@@ -1946,7 +1977,7 @@ pygti==0.9.4
pyhaversion==22.8.0
# homeassistant.components.heos
-pyheos==0.7.2
+pyheos==0.9.0
# homeassistant.components.hive
pyhiveapi==0.5.16
@@ -1970,7 +2001,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
@@ -1993,6 +2024,9 @@ pyisy==3.1.14
# homeassistant.components.itach
pyitachip2ir==0.0.7
+# homeassistant.components.ituran
+pyituran==0.1.4
+
# homeassistant.components.jvc_projector
pyjvcprojector==1.1.2
@@ -2009,7 +2043,7 @@ pykmtronic==0.3.0
pykodi==0.2.7
# homeassistant.components.kostal_plenticore
-pykoplenti==1.2.2
+pykoplenti==1.3.0
# homeassistant.components.kraken
pykrakenapi==0.1.8
@@ -2023,6 +2057,9 @@ pykwb==0.0.8
# homeassistant.components.lacrosse
pylacrosse==0.4
+# homeassistant.components.lamarzocco
+pylamarzocco==1.4.6
+
# homeassistant.components.lastfm
pylast==5.1.0
@@ -2042,7 +2079,7 @@ pylitejet==0.6.3
pylitterbot==2023.5.0
# homeassistant.components.lutron_caseta
-pylutron-caseta==0.21.1
+pylutron-caseta==0.23.0
# homeassistant.components.lutron
pylutron==0.2.16
@@ -2072,7 +2109,7 @@ pymitv==1.4.3
pymochad==0.2.0
# homeassistant.components.modbus
-pymodbus==3.6.9
+pymodbus==3.8.3
# homeassistant.components.monoprice
pymonoprice==0.4
@@ -2084,7 +2121,7 @@ pymsteams==0.1.12
pymysensors==0.24.0
# homeassistant.components.iron_os
-pynecil==0.2.1
+pynecil==4.0.1
# homeassistant.components.netgear
pynetgear==0.10.10
@@ -2096,7 +2133,7 @@ pynetio==0.1.9.1
pynobo==1.8.1
# homeassistant.components.nordpool
-pynordpool==0.2.1
+pynordpool==0.2.4
# homeassistant.components.nuki
pynuki==1.6.3
@@ -2143,25 +2180,25 @@ pyotgw==2.2.2
pyotp==2.8.0
# homeassistant.components.overkiz
-pyoverkiz==1.14.1
+pyoverkiz==1.15.5
# homeassistant.components.onewire
pyownet==0.10.0.post1
# homeassistant.components.palazzetti
-pypalazzetti==0.1.10
+pypalazzetti==0.1.15
# homeassistant.components.elv
pypca==0.0.7
# homeassistant.components.lcn
-pypck==0.7.24
+pypck==0.8.1
# homeassistant.components.pjlink
pypjlink2==1.2.1
# homeassistant.components.plaato
-pyplaato==0.0.18
+pyplaato==0.0.19
# homeassistant.components.point
pypoint==3.0.0
@@ -2197,7 +2234,7 @@ pyrecswitch==1.0.2
pyrepetierng==0.1.0
# homeassistant.components.risco
-pyrisco==0.6.4
+pyrisco==0.6.5
# homeassistant.components.rituals_perfume_genie
pyrituals==0.0.6
@@ -2215,13 +2252,13 @@ pysabnzbd==1.1.1
pysaj==0.0.16
# homeassistant.components.schlage
-pyschlage==2024.8.0
+pyschlage==2024.11.0
# homeassistant.components.sensibo
pysensibo==1.1.0
# homeassistant.components.serial
-pyserial-asyncio-fast==0.13
+pyserial-asyncio-fast==0.14
# homeassistant.components.acer_projector
# homeassistant.components.crownstone
@@ -2263,7 +2300,7 @@ pysmarty2==0.10.1
pysml==0.0.12
# homeassistant.components.smlight
-pysmlight==0.1.3
+pysmlight==0.1.4
# homeassistant.components.snmp
pysnmp==6.2.6
@@ -2281,13 +2318,13 @@ 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
# homeassistant.components.suez_water
-pysuezV2==1.3.1
+pysuezV2==2.0.3
# homeassistant.components.switchbee
pyswitchbee==1.8.3
@@ -2295,9 +2332,6 @@ pyswitchbee==1.8.3
# homeassistant.components.tautulli
pytautulli==23.1.1
-# homeassistant.components.tedee
-pytedee-async==0.2.20
-
# homeassistant.components.thinkingcleaner
pythinkingcleaner==0.0.3
@@ -2341,10 +2375,10 @@ python-gc100==1.0.3a0
python-gitlab==1.6.0
# homeassistant.components.analytics_insights
-python-homeassistant-analytics==0.8.0
+python-homeassistant-analytics==0.8.1
# homeassistant.components.homewizard
-python-homewizard-energy==v6.3.0
+python-homewizard-energy==v7.0.1
# homeassistant.components.hp_ilo
python-hpilo==4.4.3
@@ -2359,16 +2393,16 @@ python-join-api==0.0.9
python-juicenet==1.1.0
# homeassistant.components.tplink
-python-kasa[speedups]==0.7.7
+python-kasa[speedups]==0.9.1
# homeassistant.components.linkplay
-python-linkplay==0.0.18
+python-linkplay==0.1.1
# 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
@@ -2389,6 +2423,9 @@ python-opensky==1.0.1
# homeassistant.components.thread
python-otbr-api==2.6.0
+# homeassistant.components.overseerr
+python-overseerr==0.5.0
+
# homeassistant.components.picnic
python-picnic-api==1.1.0
@@ -2399,16 +2436,16 @@ python-rabbitair==0.0.8
python-ripple-api==0.0.3
# homeassistant.components.roborock
-python-roborock==2.7.2
+python-roborock==2.8.4
# homeassistant.components.smarttub
-python-smarttub==0.0.36
+python-smarttub==0.0.38
# homeassistant.components.songpal
python-songpal==0.16.2
# homeassistant.components.tado
-python-tado==0.17.7
+python-tado==0.18.5
# homeassistant.components.technove
python-technove==1.3.1
@@ -2423,7 +2460,7 @@ python-vlc==3.0.18122
pythonegardia==1.0.52
# homeassistant.components.tile
-pytile==2023.12.0
+pytile==2024.12.0
# homeassistant.components.tomorrowio
pytomorrowio==0.3.6
@@ -2432,7 +2469,7 @@ pytomorrowio==0.3.6
pytouchline==0.7
# homeassistant.components.touchline_sl
-pytouchlinesl==0.1.8
+pytouchlinesl==0.3.0
# homeassistant.components.traccar
# homeassistant.components.traccar_server
@@ -2445,7 +2482,7 @@ pytradfri[async]==9.0.1
# homeassistant.components.trafikverket_ferry
# homeassistant.components.trafikverket_train
# homeassistant.components.trafikverket_weatherstation
-pytrafikverket==1.0.0
+pytrafikverket==1.1.1
# homeassistant.components.v2c
pytrydan==0.8.0
@@ -2466,13 +2503,13 @@ pyvera==0.3.15
pyversasense==0.0.6
# homeassistant.components.vesync
-pyvesync==2.1.12
+pyvesync==2.1.15
# homeassistant.components.vizio
pyvizio==0.1.61
# homeassistant.components.velux
-pyvlx==0.2.21
+pyvlx==0.2.26
# homeassistant.components.volumio
pyvolumio==0.1.5
@@ -2541,19 +2578,19 @@ rapt-ble==0.1.2
raspyrfm-client==1.2.8
# homeassistant.components.refoss
-refoss-ha==1.2.4
+refoss-ha==1.2.5
# homeassistant.components.rainmachine
regenmaschine==2024.03.0
# homeassistant.components.renault
-renault-api==0.2.7
+renault-api==0.2.9
# homeassistant.components.renson
-renson-endura-delta==1.7.1
+renson-endura-delta==1.7.2
# homeassistant.components.reolink
-reolink-aio==0.10.4
+reolink-aio==0.11.6
# homeassistant.components.idteck_prox
rfk101py==0.0.1
@@ -2562,7 +2599,7 @@ rfk101py==0.0.1
rflink==0.0.66
# homeassistant.components.ring
-ring-doorbell==0.9.9
+ring-doorbell==0.9.13
# homeassistant.components.fleetgo
ritassist==0.9.2
@@ -2607,7 +2644,7 @@ rxv==0.7.0
samsungctl[websocket]==0.7.1
# homeassistant.components.samsungtv
-samsungtvws[async,encrypted]==2.6.0
+samsungtvws[async,encrypted]==2.7.2
# homeassistant.components.sanix
sanix==1.0.6
@@ -2622,14 +2659,14 @@ screenlogicpy==0.10.0
scsgate==0.1.0
# homeassistant.components.backup
-securetar==2024.2.1
+securetar==2024.11.0
# homeassistant.components.sendgrid
sendgrid==6.8.2
# homeassistant.components.emulated_kasa
# homeassistant.components.sense
-sense-energy==0.13.3
+sense-energy==0.13.4
# homeassistant.components.sensirion_ble
sensirion-ble==0.1.1
@@ -2673,6 +2710,9 @@ simplisafe-python==2024.01.0
# homeassistant.components.sisyphus
sisyphus-control==3.1.4
+# homeassistant.components.sky_remote
+skyboxremote==0.0.6
+
# homeassistant.components.slack
slackclient==2.5.0
@@ -2689,16 +2729,16 @@ smhi-pkg==1.0.18
snapcast==2.3.6
# homeassistant.components.sonos
-soco==0.30.4
+soco==0.30.6
# homeassistant.components.solaredge_local
solaredge-local==0.2.3
# homeassistant.components.solarlog
-solarlog_cli==0.3.2
+solarlog_cli==0.4.0
# homeassistant.components.solax
-solax==3.1.1
+solax==3.2.3
# homeassistant.components.somfy_mylink
somfy-mylink-synergy==1.0.6
@@ -2713,7 +2753,7 @@ speak2mary==1.4.0
speedtest-cli==2.1.3
# homeassistant.components.spotify
-spotifyaio==0.8.7
+spotifyaio==0.8.11
# homeassistant.components.sql
sqlparse==0.5.0
@@ -2728,7 +2768,7 @@ starline==0.1.5
starlingbank==3.2
# homeassistant.components.starlink
-starlink-grpc-core==1.1.3
+starlink-grpc-core==1.2.2
# homeassistant.components.statsd
statsd==3.2.1
@@ -2736,11 +2776,8 @@ statsd==3.2.1
# homeassistant.components.steam_online
steamodd==4.21
-# homeassistant.components.stookalert
-stookalert==0.1.4
-
# homeassistant.components.stookwijzer
-stookwijzer==1.3.0
+stookwijzer==1.5.1
# homeassistant.components.streamlabswater
streamlabswater==1.0.1
@@ -2751,7 +2788,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
@@ -2804,7 +2841,7 @@ temperusb==1.6.1
# homeassistant.components.tesla_fleet
# homeassistant.components.teslemetry
# homeassistant.components.tessie
-tesla-fleet-api==0.8.4
+tesla-fleet-api==0.9.2
# homeassistant.components.powerwall
tesla-powerwall==0.5.2
@@ -2831,7 +2868,7 @@ thermopro-ble==0.10.0
thingspeak==1.0.0
# homeassistant.components.lg_thinq
-thinqconnect==1.0.0
+thinqconnect==1.0.2
# homeassistant.components.tikteck
tikteck==0.4
@@ -2852,13 +2889,13 @@ tololib==1.1.0
toonapi==0.3.0
# homeassistant.components.totalconnect
-total-connect-client==2024.5
+total-connect-client==2024.12
# homeassistant.components.tplink_lte
tp-connected==0.0.4
# homeassistant.components.tplink_omada
-tplink-omada-client==1.4.2
+tplink-omada-client==1.4.3
# homeassistant.components.transmission
transmission-rpc==7.0.3
@@ -2873,10 +2910,10 @@ ttls==1.8.3
ttn_client==1.2.0
# homeassistant.components.tuya
-tuya-device-sharing-sdk==0.1.9
+tuya-device-sharing-sdk==0.2.1
# homeassistant.components.twentemilieu
-twentemilieu==2.0.1
+twentemilieu==2.2.1
# homeassistant.components.twilio
twilio==6.32.0
@@ -2891,7 +2928,7 @@ typedmonarchmoney==0.3.1
uasiren==0.0.1
# homeassistant.components.unifiprotect
-uiprotect==6.4.0
+uiprotect==7.4.1
# homeassistant.components.landisgyr_heat_meter
ultraheat-api==0.5.7
@@ -2900,16 +2937,16 @@ ultraheat-api==0.5.7
unifi-discovery==1.2.0
# homeassistant.components.unifi_direct
-unifi_ap==0.0.1
+unifi_ap==0.0.2
# homeassistant.components.unifiled
unifiled==0.11
-# homeassistant.components.zha
-universal-silabs-flasher==0.0.24
+# homeassistant.components.homeassistant_hardware
+universal-silabs-flasher==0.0.25
# homeassistant.components.upb
-upb-lib==0.5.8
+upb-lib==0.5.9
# homeassistant.components.upcloud
upcloud-api==2.6.0
@@ -2932,7 +2969,7 @@ vallox-websocket-api==5.3.0
vehicle==2.2.2
# homeassistant.components.velbus
-velbus-aio==2024.10.0
+velbus-aio==2024.12.3
# homeassistant.components.venstar
venstarcolortouch==0.19
@@ -2941,7 +2978,7 @@ venstarcolortouch==0.19
vilfo-api-client==0.5.0
# homeassistant.components.voip
-voip-utils==0.1.0
+voip-utils==0.2.2
# homeassistant.components.volkszaehler
volkszaehler==0.4.0
@@ -2969,11 +3006,14 @@ wakeonlan==2.1.0
wallbox==0.7.0
# homeassistant.components.folder_watcher
-watchdog==2.3.1
+watchdog==6.0.0
# homeassistant.components.waterfurnace
waterfurnace==1.1.0
+# homeassistant.components.watergate
+watergate-local-api==2024.4.1
+
# homeassistant.components.weatherflow_cloud
weatherflow4py==1.0.6
@@ -2981,16 +3021,16 @@ weatherflow4py==1.0.6
webexpythonsdk==2.0.1
# homeassistant.components.nasweb
-webio-api==0.1.8
+webio-api==0.1.11
# homeassistant.components.webmin
webmin-xmlrpc==0.0.2
# homeassistant.components.weheat
-weheat==2024.09.23
+weheat==2024.12.22
# homeassistant.components.whirlpool
-whirlpool-sixth-sense==0.18.8
+whirlpool-sixth-sense==0.18.11
# homeassistant.components.whois
whois==0.9.27
@@ -3002,7 +3042,7 @@ wiffi==1.1.2
wirelesstagpy==0.8.1
# homeassistant.components.wled
-wled==0.20.2
+wled==0.21.0
# homeassistant.components.wolflink
wolf-comm==0.0.15
@@ -3011,13 +3051,13 @@ wolf-comm==0.0.15
wyoming==1.5.4
# homeassistant.components.xbox
-xbox-webapi==2.0.11
+xbox-webapi==2.1.0
# homeassistant.components.xiaomi_ble
xiaomi-ble==0.33.0
# homeassistant.components.knx
-xknx==3.3.0
+xknx==3.4.0
# homeassistant.components.knx
xknxproject==3.8.1
@@ -3038,7 +3078,7 @@ yalesmartalarmclient==0.4.3
# homeassistant.components.august
# homeassistant.components.yale
# homeassistant.components.yalexs_ble
-yalexs-ble==2.5.0
+yalexs-ble==2.5.6
# homeassistant.components.august
# homeassistant.components.yale
@@ -3060,7 +3100,10 @@ youless-api==2.1.2
youtubeaio==1.1.5
# homeassistant.components.media_extractor
-yt-dlp[default]==2024.11.04
+yt-dlp[default]==2024.12.23
+
+# homeassistant.components.zabbix
+zabbix-utils==2.0.2
# homeassistant.components.zamg
zamg==0.3.6
@@ -3069,13 +3112,13 @@ zamg==0.3.6
zengge==0.2
# homeassistant.components.zeroconf
-zeroconf==0.136.0
+zeroconf==0.137.2
# homeassistant.components.zeversolar
zeversolar==0.3.2
# homeassistant.components.zha
-zha==0.0.37
+zha==0.0.45
# homeassistant.components.zhong_hong
zhong-hong-hvac==1.0.13
@@ -3087,7 +3130,7 @@ ziggo-mediabox-xl==1.1.0
zm-py==0.5.4
# homeassistant.components.zwave_js
-zwave-js-server-python==0.59.0
+zwave-js-server-python==0.60.0
# homeassistant.components.zwave_me
zwave-me-ws==0.4.3
diff --git a/requirements_test.txt b/requirements_test.txt
index 241fff89ac3..b3a50bd96a6 100644
--- a/requirements_test.txt
+++ b/requirements_test.txt
@@ -7,20 +7,20 @@
-c homeassistant/package_constraints.txt
-r requirements_test_pre_commit.txt
-astroid==3.3.5
-coverage==7.6.1
+astroid==3.3.6
+coverage==7.6.8
freezegun==1.5.1
license-expression==30.4.0
mock-open==1.4.0
-mypy-dev==1.14.0a2
+mypy-dev==1.15.0a1
pre-commit==4.0.0
-pydantic==1.10.18
-pylint==3.3.1
+pydantic==2.10.4
+pylint==3.3.2
pylint-per-file-ignores==1.3.2
pipdeptree==2.23.4
pytest-asyncio==0.24.0
pytest-aiohttp==1.0.5
-pytest-cov==5.0.0
+pytest-cov==6.0.0
pytest-freezer==0.4.8
pytest-github-actions-annotate-failures==0.2.0
pytest-socket==0.7.0
@@ -29,25 +29,27 @@ pytest-timeout==2.3.1
pytest-unordered==0.6.1
pytest-picked==0.5.0
pytest-xdist==3.6.1
-pytest==8.3.3
+pytest==8.3.4
requests-mock==1.12.1
respx==0.21.1
-syrupy==4.7.2
+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==2.0.0.20240423
-types-beautifulsoup4==4.12.0.20240907
-types-caldav==1.3.0.20240824
+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.28.0.20240924
-types-psutil==6.0.0.20240901
-types-python-dateutil==2.9.0.20241003
+types-protobuf==5.29.1.20241207
+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 a4d7dd7f85b..3457fd666a3 100644
--- a/requirements_test_all.txt
+++ b/requirements_test_all.txt
@@ -4,10 +4,10 @@
-r requirements_test.txt
# homeassistant.components.aemet
-AEMET-OpenData==0.5.4
+AEMET-OpenData==0.6.4
# homeassistant.components.honeywell
-AIOSomecomfort==0.0.25
+AIOSomecomfort==0.0.28
# 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==10.4.0
+Pillow==11.1.0
# homeassistant.components.plex
PlexAPI==4.15.16
@@ -45,7 +45,7 @@ ProgettiHWSW==0.1.3
PyChromecast==14.0.5
# homeassistant.components.flick_electric
-PyFlick==0.0.2
+PyFlick==1.1.2
# homeassistant.components.flume
PyFlume==0.6.5
@@ -57,7 +57,7 @@ PyFronius==0.7.3
PyLoadAPI==1.3.2
# homeassistant.components.met_eireann
-PyMetEireann==2021.8.0
+PyMetEireann==2024.11.0
# homeassistant.components.met
# homeassistant.components.norway_air
@@ -67,7 +67,7 @@ PyMetno==0.13.0
PyMicroBot==0.0.17
# homeassistant.components.nina
-PyNINA==0.3.3
+PyNINA==0.3.4
# homeassistant.components.mobile_app
# homeassistant.components.owntracks
@@ -81,10 +81,10 @@ PyQRCode==1.2.1
PyRMVtransport==0.3.3
# homeassistant.components.switchbot
-PySwitchbot==0.51.0
+PySwitchbot==0.55.4
# homeassistant.components.syncthru
-PySyncThru==0.7.10
+PySyncThru==0.8.0
# homeassistant.components.transport_nsw
PyTransportNSW==0.1.1
@@ -94,7 +94,7 @@ PyTransportNSW==0.1.1
PyTurboJPEG==1.7.5
# homeassistant.components.vicare
-PyViCare==2.35.0
+PyViCare==2.39.1
# homeassistant.components.xiaomi_aqara
PyXiaomiGateway==0.14.3
@@ -110,7 +110,7 @@ RtmAPI==0.7.2
# homeassistant.components.recorder
# homeassistant.components.sql
-SQLAlchemy==2.0.31
+SQLAlchemy==2.0.36
# homeassistant.components.tami4
Tami4EdgeAPI==3.0
@@ -119,7 +119,7 @@ Tami4EdgeAPI==3.0
WSDiscovery==2.0.0
# homeassistant.components.accuweather
-accuweather==3.0.0
+accuweather==4.0.0
# homeassistant.components.adax
adax==0.4.0
@@ -143,7 +143,7 @@ afsapi==0.2.7
agent-py==0.0.24
# homeassistant.components.geo_json_events
-aio-geojson-generic-client==0.4
+aio-geojson-generic-client==0.5
# homeassistant.components.geonetnz_quakes
aio-geojson-geonetnz-quakes==0.16
@@ -160,14 +160,17 @@ aio-geojson-usgs-earthquakes==0.3
# homeassistant.components.gdacs
aio-georss-gdacs==0.10
+# homeassistant.components.acaia
+aioacaia==0.1.13
+
# homeassistant.components.airq
-aioairq==0.3.2
+aioairq==0.4.3
# homeassistant.components.airzone_cloud
aioairzone-cloud==0.6.10
# homeassistant.components.airzone
-aioairzone==0.9.5
+aioairzone==0.9.7
# homeassistant.components.ambient_network
# homeassistant.components.ambient_station
@@ -186,7 +189,7 @@ aioaseko==1.0.0
aioasuswrt==1.4.0
# homeassistant.components.husqvarna_automower
-aioautomower==2024.10.3
+aioautomower==2025.1.0
# homeassistant.components.azure_devops
aioazuredevops==2.2.1
@@ -198,7 +201,7 @@ aiobafi6==0.9.0
aiobotocore==2.13.1
# homeassistant.components.comelit
-aiocomelit==0.9.1
+aiocomelit==0.10.1
# homeassistant.components.dhcp
aiodhcpwatcher==1.0.2
@@ -228,13 +231,12 @@ aioelectricitymaps==0.4.0
aioemonitor==1.0.5
# homeassistant.components.esphome
-aioesphomeapi==27.0.1
+aioesphomeapi==28.0.0
# homeassistant.components.flo
aioflo==2021.11.0
# homeassistant.components.github
-# homeassistant.components.iron_os
aiogithubapi==24.6.0
# homeassistant.components.guardian
@@ -244,10 +246,13 @@ aioguardian==2022.07.0
aioharmony==0.2.10
# homeassistant.components.hassio
-aiohasupervisor==0.2.1
+aiohasupervisor==0.2.2b5
# homeassistant.components.homekit_controller
-aiohomekit==3.2.6
+aiohomekit==3.2.7
+
+# homeassistant.components.mcp_server
+aiohttp_sse==2.2.0
# homeassistant.components.hue
aiohue==4.7.3
@@ -262,13 +267,10 @@ aiokafka==0.10.0
aiolifx-effects==0.3.2
# homeassistant.components.lifx
-aiolifx-themes==0.5.5
+aiolifx-themes==0.6.0
# homeassistant.components.lifx
-aiolifx==1.1.1
-
-# homeassistant.components.livisi
-aiolivisi==0.0.19
+aiolifx==1.1.2
# homeassistant.components.lookin
aiolookin==1.0.0
@@ -277,7 +279,7 @@ aiolookin==1.0.0
aiolyric==2.0.1
# homeassistant.components.mealie
-aiomealie==0.9.3
+aiomealie==0.9.5
# homeassistant.components.modern_forms
aiomodernforms==0.1.8
@@ -304,13 +306,13 @@ aioopenexchangerates==0.6.8
aiooui==0.1.7
# homeassistant.components.pegel_online
-aiopegelonline==0.0.10
+aiopegelonline==0.1.1
# homeassistant.components.acmeda
aiopulse==0.4.6
# homeassistant.components.purpleair
-aiopurpleair==2022.12.1
+aiopurpleair==2023.12.0
# homeassistant.components.hunterdouglas_powerview
aiopvapi==3.1.1
@@ -336,10 +338,10 @@ aiorecollect==2023.09.0
aioridwell==2024.01.0
# homeassistant.components.ruckus_unleashed
-aioruckus==0.41
+aioruckus==0.42
# homeassistant.components.russound_rio
-aiorussound==4.0.5
+aiorussound==4.4.0
# homeassistant.components.ruuvi_gateway
aioruuvigateway==0.1.0
@@ -348,7 +350,7 @@ aioruuvigateway==0.1.0
aiosenz==1.0.0
# homeassistant.components.shelly
-aioshelly==12.0.1
+aioshelly==12.2.0
# homeassistant.components.skybell
aioskybell==22.7.0
@@ -363,10 +365,10 @@ aiosolaredge==0.2.0
aiosteamist==1.0.0
# homeassistant.components.cambridge_audio
-aiostreammagic==2.8.4
+aiostreammagic==2.10.0
# homeassistant.components.switcher_kis
-aioswitcher==4.4.0
+aioswitcher==6.0.0
# homeassistant.components.syncthing
aiosyncthing==0.5.1
@@ -374,11 +376,14 @@ aiosyncthing==0.5.1
# homeassistant.components.tankerkoenig
aiotankerkoenig==0.4.2
+# homeassistant.components.tedee
+aiotedee==0.2.20
+
# homeassistant.components.tractive
aiotractive==0.6.0
# homeassistant.components.unifi
-aiounifi==80
+aiounifi==81
# homeassistant.components.vlc_telnet
aiovlc==0.5.1
@@ -396,7 +401,7 @@ aiowatttime==0.1.1
aiowebostv==0.4.2
# homeassistant.components.withings
-aiowithings==3.1.1
+aiowithings==3.1.4
# homeassistant.components.yandex_transport
aioymaps==1.2.5
@@ -417,13 +422,13 @@ airthings-cloud==0.2.0
airtouch4pyapi==1.0.5
# homeassistant.components.airtouch5
-airtouch5py==0.2.10
+airtouch5py==0.2.11
# homeassistant.components.amberelectric
-amberelectric==1.1.1
+amberelectric==2.0.12
# homeassistant.components.androidtv
-androidtv[async]==0.0.73
+androidtv[async]==0.0.75
# homeassistant.components.androidtv_remote
androidtvremote2==0.1.2
@@ -437,20 +442,23 @@ anthemav==1.4.1
# homeassistant.components.anthropic
anthropic==0.31.2
+# homeassistant.components.mcp_server
+anyio==4.7.0
+
# homeassistant.components.weatherkit
apple_weatherkit==1.1.3
# homeassistant.components.apprise
-apprise==1.9.0
+apprise==1.9.1
# homeassistant.components.aprs
aprslib==0.7.2
# homeassistant.components.apsystems
-apsystems-ez1==2.2.1
+apsystems-ez1==2.4.0
# homeassistant.components.aranet
-aranet4==2.4.0
+aranet4==2.5.0
# homeassistant.components.arcam_fmj
arcam-fmj==1.5.2
@@ -461,7 +469,7 @@ arcam-fmj==1.5.2
# homeassistant.components.ssdp
# homeassistant.components.upnp
# homeassistant.components.yeelight
-async-upnp-client==0.41.0
+async-upnp-client==0.42.0
# homeassistant.components.arve
asyncarve==0.1.1
@@ -486,10 +494,10 @@ automower-ble==0.2.0
av==13.1.0
# homeassistant.components.axis
-axis==63
+axis==64
# homeassistant.components.fujitsu_fglair
-ayla-iot-unofficial==1.4.3
+ayla-iot-unofficial==1.4.4
# homeassistant.components.azure_event_hub
azure-eventhub==5.11.1
@@ -510,11 +518,11 @@ base36==0.1.1
beautifulsoup4==4.12.3
# homeassistant.components.bmw_connected_drive
-bimmer-connected[china]==0.16.4
+bimmer-connected[china]==0.17.2
# homeassistant.components.eq3btsmart
# homeassistant.components.esphome
-bleak-esphome==1.1.0
+bleak-esphome==2.0.0
# homeassistant.components.bluetooth
bleak-retry-connector==3.6.0
@@ -535,7 +543,7 @@ bluecurrent-api==1.2.3
bluemaestro-ble==0.2.3
# homeassistant.components.bluetooth
-bluetooth-adapters==0.20.0
+bluetooth-adapters==0.20.2
# homeassistant.components.bluetooth
bluetooth-auto-recovery==1.4.2
@@ -597,6 +605,10 @@ colorthief==0.2.1
# homeassistant.components.xiaomi_miio
construct==2.10.68
+# homeassistant.components.cookidoo
+cookidoo-api==0.12.2
+
+# homeassistant.components.backup
# homeassistant.components.utility_meter
cronsim==2.6
@@ -616,13 +628,13 @@ datadog==0.15.0
datapoint==0.9.9
# homeassistant.components.bluetooth
-dbus-fast==2.24.3
+dbus-fast==2.28.0
# homeassistant.components.debugpy
-debugpy==1.8.6
+debugpy==1.8.11
# homeassistant.components.ecovacs
-deebot-client==8.4.0
+deebot-client==10.1.0
# homeassistant.components.ihc
# homeassistant.components.namecheapdns
@@ -633,10 +645,10 @@ defusedxml==0.7.1
deluge-client==1.10.2
# homeassistant.components.lametric
-demetriek==0.4.0
+demetriek==1.1.1
# homeassistant.components.denonavr
-denonavr==1.0.0
+denonavr==1.0.1
# homeassistant.components.devialet
devialet==1.4.5
@@ -680,11 +692,14 @@ eagle100==0.1.1
# homeassistant.components.easyenergy
easyenergy==2.1.2
+# homeassistant.components.eheimdigital
+eheimdigital==1.0.3
+
# homeassistant.components.electric_kiwi
electrickiwi-api==0.8.5
# homeassistant.components.elevenlabs
-elevenlabs==1.6.1
+elevenlabs==1.9.0
# homeassistant.components.elgato
elgato==5.1.2
@@ -693,7 +708,7 @@ elgato==5.1.2
elkm1-lib==2.2.10
# homeassistant.components.elmax
-elmax-api==0.0.5
+elmax-api==0.0.6.4rc0
# homeassistant.components.elvia
elvia==0.1.0
@@ -726,7 +741,7 @@ epion==0.0.3
epson-projector==0.5.1
# homeassistant.components.eq3btsmart
-eq3btsmart==1.2.0
+eq3btsmart==1.4.1
# homeassistant.components.esphome
esphome-dashboard-api==1.2.3
@@ -738,7 +753,7 @@ eternalegypt==0.0.16
eufylife-ble-client==0.1.8
# homeassistant.components.evohome
-evohome-async==0.4.20
+evohome-async==0.4.21
# homeassistant.components.bryant_evolution
evolutionhttp==0.0.18
@@ -765,7 +780,7 @@ fitbit==0.3.1
fivem-api==0.1.2
# homeassistant.components.fjaraskupan
-fjaraskupan==2.3.0
+fjaraskupan==2.3.2
# homeassistant.components.flexit_bacnet
flexit_bacnet==2.2.1
@@ -774,7 +789,7 @@ flexit_bacnet==2.2.1
flipr-api==1.6.1
# homeassistant.components.flux_led
-flux-led==1.0.4
+flux-led==1.1.0
# homeassistant.components.homekit
# homeassistant.components.recorder
@@ -784,29 +799,29 @@ fnv-hash-fast==1.0.2
foobot_async==1.0.0
# homeassistant.components.forecast_solar
-forecast-solar==3.1.0
+forecast-solar==4.0.0
# homeassistant.components.freebox
-freebox-api==1.1.0
+freebox-api==1.2.1
# homeassistant.components.fritz
# homeassistant.components.fritzbox_callmonitor
fritzconnection[qr]==1.14.0
# homeassistant.components.fyta
-fyta_cli==0.6.10
+fyta_cli==0.7.0
# homeassistant.components.google_translate
gTTS==2.2.4
# homeassistant.components.gardena_bluetooth
-gardena-bluetooth==1.4.4
+gardena-bluetooth==1.5.0
# homeassistant.components.google_assistant_sdk
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 +855,7 @@ gios==5.0.0
glances-api==0.8.0
# homeassistant.components.go2rtc
-go2rtc-client==0.1.0
+go2rtc-client==0.1.2
# homeassistant.components.goalzero
goalzero==0.2.2
@@ -865,7 +880,7 @@ google-cloud-texttospeech==2.17.2
google-generativeai==0.8.2
# homeassistant.components.nest
-google-nest-sdm==6.1.4
+google-nest-sdm==7.0.0
# homeassistant.components.google_photos
google-photos-library-api==0.12.1
@@ -873,8 +888,12 @@ google-photos-library-api==0.12.1
# homeassistant.components.google_travel_time
googlemaps==2.5.1
+# homeassistant.components.slide
+# homeassistant.components.slide_local
+goslide-api==0.7.0
+
# homeassistant.components.tailwind
-gotailwind==0.2.4
+gotailwind==0.3.0
# homeassistant.components.govee_ble
govee-ble==0.40.0
@@ -901,7 +920,7 @@ growattServer==1.5.0
gspread==5.5.0
# homeassistant.components.profiler
-guppy3==3.1.4.post1
+guppy3==3.1.5
# homeassistant.components.iaqualink
h2==4.1.0
@@ -916,19 +935,19 @@ ha-iotawattpy==0.1.2
ha-philipsjs==3.2.2
# homeassistant.components.habitica
-habitipy==0.3.3
+habiticalib==0.3.2
# homeassistant.components.bluetooth
-habluetooth==3.6.0
+habluetooth==3.7.0
# homeassistant.components.cloud
-hass-nabucasa==0.84.0
+hass-nabucasa==0.87.0
# homeassistant.components.conversation
-hassil==1.7.4
+hassil==2.1.0
# homeassistant.components.jewish_calendar
-hdate==0.10.9
+hdate==0.11.1
# homeassistant.components.here_travel_time
here-routing==1.0.1
@@ -947,19 +966,19 @@ hole==0.8.0
# homeassistant.components.holiday
# homeassistant.components.workday
-holidays==0.60
+holidays==0.64
# homeassistant.components.frontend
-home-assistant-frontend==20241106.2
+home-assistant-frontend==20250106.0
# homeassistant.components.conversation
-home-assistant-intents==2024.11.6
+home-assistant-intents==2025.1.1
# homeassistant.components.home_connect
homeconnect==0.8.0
# homeassistant.components.homematicip_cloud
-homematicip==1.1.2
+homematicip==1.1.5
# homeassistant.components.remember_the_milk
httplib2==0.20.4
@@ -968,7 +987,7 @@ httplib2==0.20.4
huawei-lte-api==1.10.0
# homeassistant.components.huum
-huum==0.7.11
+huum==0.7.12
# homeassistant.components.hyperion
hyperion-py==0.7.5
@@ -982,22 +1001,28 @@ 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
# homeassistant.components.ping
icmplib==3.0
# homeassistant.components.idasen_desk
-idasen-ha==2.6.2
+idasen-ha==2.6.3
# homeassistant.components.network
ifaddr==0.2.0
+# homeassistant.components.igloohome
+igloohome-api==0.0.6
+
# homeassistant.components.imgw_pib
-imgw_pib==1.0.6
+imgw_pib==1.0.7
# homeassistant.components.incomfort
-incomfort-client==0.6.3-1
+incomfort-client==0.6.4
# homeassistant.components.influxdb
influxdb-client==1.24.0
@@ -1015,7 +1040,7 @@ insteon-frontend-home-assistant==0.5.0
intellifire4py==4.1.9
# homeassistant.components.iotty
-iottycloud==0.2.1
+iottycloud==0.3.0
# homeassistant.components.isal
isal==1.7.1
@@ -1043,10 +1068,10 @@ justnimbus==0.7.4
kegtron-ble==0.4.0
# homeassistant.components.knocki
-knocki==0.3.5
+knocki==0.4.2
# homeassistant.components.knx
-knx-frontend==2024.9.10.221729
+knx-frontend==2024.12.26.233449
# homeassistant.components.konnected
konnected==1.2.0
@@ -1061,7 +1086,7 @@ lacrosse-view==1.0.3
laundrify-aio==1.2.2
# homeassistant.components.lcn
-lcn-frontend==0.2.1
+lcn-frontend==0.2.2
# homeassistant.components.ld2410_ble
ld2410-ble==0.1.1
@@ -1070,7 +1095,7 @@ ld2410-ble==0.1.1
leaone-ble==0.1.0
# homeassistant.components.led_ble
-led-ble==1.0.2
+led-ble==1.1.1
# homeassistant.components.lektrico
lektricowifi==0.0.43
@@ -1087,8 +1112,8 @@ libsoundtouch==0.8
# homeassistant.components.linear_garage_door
linear-garage-door==0.2.9
-# homeassistant.components.lamarzocco
-lmcloud==1.2.3
+# homeassistant.components.livisi
+livisi==0.0.24
# homeassistant.components.london_underground
london-tube-status==0.5
@@ -1114,6 +1139,9 @@ maxcube-api==0.4.3
# homeassistant.components.mythicbeastsdns
mbddns==0.1.2
+# homeassistant.components.mcp_server
+mcp==1.1.2
+
# homeassistant.components.minecraft_server
mcstatus==11.1.1
@@ -1136,13 +1164,13 @@ mficlient==0.5.0
micloud==0.5
# homeassistant.components.microbees
-microBeesPy==0.3.2
+microBeesPy==0.3.5
# homeassistant.components.mill
mill-local==0.3.0
# homeassistant.components.mill
-millheater==0.11.8
+millheater==0.12.2
# homeassistant.components.minio
minio==7.1.12
@@ -1163,19 +1191,19 @@ mopeka-iot-ble==0.8.0
motionblinds==0.6.25
# homeassistant.components.motionblinds_ble
-motionblindsble==0.1.2
+motionblindsble==0.1.3
# homeassistant.components.motioneye
motioneye-client==0.3.14
# homeassistant.components.bang_olufsen
-mozart-api==4.1.1.116.0
+mozart-api==4.1.1.116.4
# homeassistant.components.mullvad
mullvad-api==1.0.0
# homeassistant.components.music_assistant
-music-assistant-client==1.0.5
+music-assistant-client==1.0.8
# homeassistant.components.tts
mutagen==1.47.0
@@ -1199,7 +1227,7 @@ nessclient==1.1.2
netmap==0.7.0.2
# homeassistant.components.nam
-nettigo-air-monitor==3.3.0
+nettigo-air-monitor==4.0.0
# homeassistant.components.nexia
nexia==2.0.8
@@ -1211,13 +1239,16 @@ nextcloudmonitor==1.5.1
nextcord==2.6.0
# homeassistant.components.nextdns
-nextdns==3.3.0
+nextdns==4.0.0
+
+# homeassistant.components.niko_home_control
+nhc==0.3.2
# homeassistant.components.nibe_heatpump
-nibe==2.11.0
+nibe==2.14.0
# homeassistant.components.nice_go
-nice-go==0.3.10
+nice-go==1.0.0
# homeassistant.components.nfandroidtv
notifications-android-tv==0.1.5
@@ -1239,7 +1270,7 @@ numato-gpio==0.13.0
# homeassistant.components.stream
# homeassistant.components.tensorflow
# homeassistant.components.trend
-numpy==2.1.2
+numpy==2.2.1
# homeassistant.components.nyt_games
nyt_games==0.4.4
@@ -1253,8 +1284,11 @@ objgraph==3.5.0
# homeassistant.components.garages_amsterdam
odp-amsterdam==6.0.2
+# homeassistant.components.ohme
+ohme==1.2.3
+
# homeassistant.components.ollama
-ollama==0.3.3
+ollama==0.4.5
# homeassistant.components.omnilogic
omnilogic==0.4.5
@@ -1263,13 +1297,13 @@ omnilogic==0.4.5
ondilo==0.5.0
# homeassistant.components.onvif
-onvif-zeep-async==3.1.12
+onvif-zeep-async==3.1.13
# homeassistant.components.opengarage
open-garage==0.2.0
# homeassistant.components.open_meteo
-open-meteo==0.3.1
+open-meteo==0.3.2
# homeassistant.components.openai_conversation
openai==1.35.7
@@ -1281,10 +1315,10 @@ openerz-api==0.3.0
openhomedevice==2.2.0
# homeassistant.components.enigma2
-openwebifpy==4.2.7
+openwebifpy==4.3.1
# homeassistant.components.opower
-opower==0.8.6
+opower==0.8.7
# homeassistant.components.oralb
oralb-ble==0.17.6
@@ -1307,6 +1341,9 @@ panasonic-viera==0.4.2
# homeassistant.components.dunehd
pdunehd==1.3.2
+# homeassistant.components.peblar
+peblar==0.3.3
+
# homeassistant.components.peco
peco==0.0.30
@@ -1326,7 +1363,7 @@ plexauth==0.0.6
plexwebsocket==0.0.14
# homeassistant.components.plugwise
-plugwise==1.5.0
+plugwise==1.6.4
# homeassistant.components.plum_lightpad
plumlightpad==0.0.11
@@ -1334,6 +1371,9 @@ plumlightpad==0.0.11
# homeassistant.components.poolsense
poolsense==0.0.8
+# homeassistant.components.powerfox
+powerfox==1.2.0
+
# homeassistant.components.reddit
praw==7.5.0
@@ -1349,10 +1389,7 @@ prometheus-client==0.21.0
psutil-home-assistant==0.0.1
# homeassistant.components.systemmonitor
-psutil==6.0.0
-
-# homeassistant.components.androidtv
-pure-python-adb[async]==0.3.0.dev0
+psutil==6.1.1
# homeassistant.components.pushbullet
pushbullet.py==0.11.0
@@ -1361,10 +1398,10 @@ pushbullet.py==0.11.0
pushover_complete==1.1.1
# homeassistant.components.pvoutput
-pvo==2.1.1
+pvo==2.2.0
# homeassistant.components.aosmith
-py-aosmith==1.0.10
+py-aosmith==1.0.12
# homeassistant.components.canary
py-canary==0.5.4
@@ -1397,7 +1434,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
@@ -1411,11 +1448,14 @@ pyDuotecno==2024.10.1
# homeassistant.components.electrasmart
pyElectra==1.2.4
+# homeassistant.components.homee
+pyHomee==1.2.0
+
# homeassistant.components.rfxtrx
pyRFXtrx==0.31.1
# homeassistant.components.tibber
-pyTibber==0.30.4
+pyTibber==0.30.8
# homeassistant.components.dlink
pyW215==0.7.0
@@ -1434,7 +1474,7 @@ pyairnow==1.2.1
pyairvisual==2023.08.1
# homeassistant.components.aprilaire
-pyaprilaire==0.7.4
+pyaprilaire==0.7.7
# homeassistant.components.asuswrt
pyasuswrt==0.1.21
@@ -1446,10 +1486,10 @@ pyatag==0.3.5.3
pyatmo==8.1.0
# homeassistant.components.apple_tv
-pyatv==0.15.1
+pyatv==0.16.0
# homeassistant.components.aussie_broadband
-pyaussiebb==0.0.15
+pyaussiebb==0.1.5
# homeassistant.components.balboa
pybalboa==1.0.2
@@ -1458,7 +1498,7 @@ pybalboa==1.0.2
pyblackbird==0.6
# homeassistant.components.bluesound
-pyblu==1.0.4
+pyblu==2.0.0
# homeassistant.components.neato
pybotvac==0.0.25
@@ -1482,10 +1522,10 @@ pycountry==24.6.1
pycsspeechtts==1.0.8
# homeassistant.components.daikin
-pydaikin==2.13.7
+pydaikin==2.13.8
# homeassistant.components.deako
-pydeako==0.5.4
+pydeako==0.6.0
# homeassistant.components.deconz
pydeconz==118
@@ -1497,7 +1537,7 @@ pydexcom==0.2.3
pydiscovergy==3.0.2
# homeassistant.components.hydrawise
-pydrawise==2024.9.0
+pydrawise==2024.12.0
# homeassistant.components.android_ip_webcam
pydroid-ipcam==2.0.0
@@ -1524,7 +1564,7 @@ pyeiscp==0.0.7
pyemoncms==0.1.1
# homeassistant.components.enphase_envoy
-pyenphase==1.22.0
+pyenphase==1.23.0
# homeassistant.components.everlights
pyeverlights==0.1.0
@@ -1566,7 +1606,7 @@ pygti==0.9.4
pyhaversion==22.8.0
# homeassistant.components.heos
-pyheos==0.7.2
+pyheos==0.9.0
# homeassistant.components.hive
pyhiveapi==0.5.16
@@ -1587,7 +1627,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
@@ -1604,6 +1644,9 @@ pyiss==1.0.1
# homeassistant.components.isy994
pyisy==3.1.14
+# homeassistant.components.ituran
+pyituran==0.1.4
+
# homeassistant.components.jvc_projector
pyjvcprojector==1.1.2
@@ -1620,7 +1663,7 @@ pykmtronic==0.3.0
pykodi==0.2.7
# homeassistant.components.kostal_plenticore
-pykoplenti==1.2.2
+pykoplenti==1.3.0
# homeassistant.components.kraken
pykrakenapi==0.1.8
@@ -1628,6 +1671,9 @@ pykrakenapi==0.1.8
# homeassistant.components.kulersky
pykulersky==0.5.2
+# homeassistant.components.lamarzocco
+pylamarzocco==1.4.6
+
# homeassistant.components.lastfm
pylast==5.1.0
@@ -1647,7 +1693,7 @@ pylitejet==0.6.3
pylitterbot==2023.5.0
# homeassistant.components.lutron_caseta
-pylutron-caseta==0.21.1
+pylutron-caseta==0.23.0
# homeassistant.components.lutron
pylutron==0.2.16
@@ -1671,7 +1717,7 @@ pymicro-vad==1.0.1
pymochad==0.2.0
# homeassistant.components.modbus
-pymodbus==3.6.9
+pymodbus==3.8.3
# homeassistant.components.monoprice
pymonoprice==0.4
@@ -1680,7 +1726,7 @@ pymonoprice==0.4
pymysensors==0.24.0
# homeassistant.components.iron_os
-pynecil==0.2.1
+pynecil==4.0.1
# homeassistant.components.netgear
pynetgear==0.10.10
@@ -1689,7 +1735,7 @@ pynetgear==0.10.10
pynobo==1.8.1
# homeassistant.components.nordpool
-pynordpool==0.2.1
+pynordpool==0.2.4
# homeassistant.components.nuki
pynuki==1.6.3
@@ -1730,22 +1776,22 @@ pyotgw==2.2.2
pyotp==2.8.0
# homeassistant.components.overkiz
-pyoverkiz==1.14.1
+pyoverkiz==1.15.5
# homeassistant.components.onewire
pyownet==0.10.0.post1
# homeassistant.components.palazzetti
-pypalazzetti==0.1.10
+pypalazzetti==0.1.15
# homeassistant.components.lcn
-pypck==0.7.24
+pypck==0.8.1
# homeassistant.components.pjlink
pypjlink2==1.2.1
# homeassistant.components.plaato
-pyplaato==0.0.18
+pyplaato==0.0.19
# homeassistant.components.point
pypoint==3.0.0
@@ -1769,7 +1815,7 @@ pyqwikswitch==0.93
pyrainbird==6.0.1
# homeassistant.components.risco
-pyrisco==0.6.4
+pyrisco==0.6.5
# homeassistant.components.rituals_perfume_genie
pyrituals==0.0.6
@@ -1784,7 +1830,7 @@ pyrympro==0.0.8
pysabnzbd==1.1.1
# homeassistant.components.schlage
-pyschlage==2024.8.0
+pyschlage==2024.11.0
# homeassistant.components.sensibo
pysensibo==1.1.0
@@ -1823,7 +1869,7 @@ pysmarty2==0.10.1
pysml==0.0.12
# homeassistant.components.smlight
-pysmlight==0.1.3
+pysmlight==0.1.4
# homeassistant.components.snmp
pysnmp==6.2.6
@@ -1841,10 +1887,10 @@ 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==1.3.1
+pysuezV2==2.0.3
# homeassistant.components.switchbee
pyswitchbee==1.8.3
@@ -1852,9 +1898,6 @@ pyswitchbee==1.8.3
# homeassistant.components.tautulli
pytautulli==23.1.1
-# homeassistant.components.tedee
-pytedee-async==0.2.20
-
# homeassistant.components.motionmount
python-MotionMount==2.2.0
@@ -1874,10 +1917,10 @@ python-fullykiosk==0.0.14
# python-gammu==3.2.4
# homeassistant.components.analytics_insights
-python-homeassistant-analytics==0.8.0
+python-homeassistant-analytics==0.8.1
# homeassistant.components.homewizard
-python-homewizard-energy==v6.3.0
+python-homewizard-energy==v7.0.1
# homeassistant.components.izone
python-izone==1.2.9
@@ -1886,13 +1929,13 @@ python-izone==1.2.9
python-juicenet==1.1.0
# homeassistant.components.tplink
-python-kasa[speedups]==0.7.7
+python-kasa[speedups]==0.9.1
# homeassistant.components.linkplay
-python-linkplay==0.0.18
+python-linkplay==0.1.1
# homeassistant.components.matter
-python-matter-server==6.6.0
+python-matter-server==7.0.0
# homeassistant.components.xiaomi_miio
python-miio==0.5.12
@@ -1913,6 +1956,9 @@ python-opensky==1.0.1
# homeassistant.components.thread
python-otbr-api==2.6.0
+# homeassistant.components.overseerr
+python-overseerr==0.5.0
+
# homeassistant.components.picnic
python-picnic-api==1.1.0
@@ -1920,16 +1966,16 @@ python-picnic-api==1.1.0
python-rabbitair==0.0.8
# homeassistant.components.roborock
-python-roborock==2.7.2
+python-roborock==2.8.4
# homeassistant.components.smarttub
-python-smarttub==0.0.36
+python-smarttub==0.0.38
# homeassistant.components.songpal
python-songpal==0.16.2
# homeassistant.components.tado
-python-tado==0.17.7
+python-tado==0.18.5
# homeassistant.components.technove
python-technove==1.3.1
@@ -1938,13 +1984,13 @@ python-technove==1.3.1
python-telegram-bot[socks]==21.5
# homeassistant.components.tile
-pytile==2023.12.0
+pytile==2024.12.0
# homeassistant.components.tomorrowio
pytomorrowio==0.3.6
# homeassistant.components.touchline_sl
-pytouchlinesl==0.1.8
+pytouchlinesl==0.3.0
# homeassistant.components.traccar
# homeassistant.components.traccar_server
@@ -1957,7 +2003,7 @@ pytradfri[async]==9.0.1
# homeassistant.components.trafikverket_ferry
# homeassistant.components.trafikverket_train
# homeassistant.components.trafikverket_weatherstation
-pytrafikverket==1.0.0
+pytrafikverket==1.1.1
# homeassistant.components.v2c
pytrydan==0.8.0
@@ -1972,13 +2018,13 @@ pyuptimerobot==22.2.0
pyvera==0.3.15
# homeassistant.components.vesync
-pyvesync==2.1.12
+pyvesync==2.1.15
# homeassistant.components.vizio
pyvizio==0.1.61
# homeassistant.components.velux
-pyvlx==0.2.21
+pyvlx==0.2.26
# homeassistant.components.volumio
pyvolumio==0.1.5
@@ -2032,25 +2078,25 @@ radiotherm==2.1.0
rapt-ble==0.1.2
# homeassistant.components.refoss
-refoss-ha==1.2.4
+refoss-ha==1.2.5
# homeassistant.components.rainmachine
regenmaschine==2024.03.0
# homeassistant.components.renault
-renault-api==0.2.7
+renault-api==0.2.9
# homeassistant.components.renson
-renson-endura-delta==1.7.1
+renson-endura-delta==1.7.2
# homeassistant.components.reolink
-reolink-aio==0.10.4
+reolink-aio==0.11.6
# homeassistant.components.rflink
rflink==0.0.66
# homeassistant.components.ring
-ring-doorbell==0.9.9
+ring-doorbell==0.9.13
# homeassistant.components.roku
rokuecp==0.19.3
@@ -2083,7 +2129,7 @@ rxv==0.7.0
samsungctl[websocket]==0.7.1
# homeassistant.components.samsungtv
-samsungtvws[async,encrypted]==2.6.0
+samsungtvws[async,encrypted]==2.7.2
# homeassistant.components.sanix
sanix==1.0.6
@@ -2092,11 +2138,11 @@ sanix==1.0.6
screenlogicpy==0.10.0
# homeassistant.components.backup
-securetar==2024.2.1
+securetar==2024.11.0
# homeassistant.components.emulated_kasa
# homeassistant.components.sense
-sense-energy==0.13.3
+sense-energy==0.13.4
# homeassistant.components.sensirion_ble
sensirion-ble==0.1.1
@@ -2131,6 +2177,9 @@ simplepush==2.2.3
# homeassistant.components.simplisafe
simplisafe-python==2024.01.0
+# homeassistant.components.sky_remote
+skyboxremote==0.0.6
+
# homeassistant.components.slack
slackclient==2.5.0
@@ -2144,13 +2193,13 @@ smhi-pkg==1.0.18
snapcast==2.3.6
# homeassistant.components.sonos
-soco==0.30.4
+soco==0.30.6
# homeassistant.components.solarlog
-solarlog_cli==0.3.2
+solarlog_cli==0.4.0
# homeassistant.components.solax
-solax==3.1.1
+solax==3.2.3
# homeassistant.components.somfy_mylink
somfy-mylink-synergy==1.0.6
@@ -2165,7 +2214,7 @@ speak2mary==1.4.0
speedtest-cli==2.1.3
# homeassistant.components.spotify
-spotifyaio==0.8.7
+spotifyaio==0.8.11
# homeassistant.components.sql
sqlparse==0.5.0
@@ -2177,7 +2226,7 @@ srpenergy==1.3.6
starline==0.1.5
# homeassistant.components.starlink
-starlink-grpc-core==1.1.3
+starlink-grpc-core==1.2.2
# homeassistant.components.statsd
statsd==3.2.1
@@ -2185,11 +2234,8 @@ statsd==3.2.1
# homeassistant.components.steam_online
steamodd==4.21
-# homeassistant.components.stookalert
-stookalert==0.1.4
-
# homeassistant.components.stookwijzer
-stookwijzer==1.3.0
+stookwijzer==1.5.1
# homeassistant.components.streamlabswater
streamlabswater==1.0.1
@@ -2200,7 +2246,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
@@ -2232,7 +2278,7 @@ temperusb==1.6.1
# homeassistant.components.tesla_fleet
# homeassistant.components.teslemetry
# homeassistant.components.tessie
-tesla-fleet-api==0.8.4
+tesla-fleet-api==0.9.2
# homeassistant.components.powerwall
tesla-powerwall==0.5.2
@@ -2253,7 +2299,7 @@ thermobeacon-ble==0.7.0
thermopro-ble==0.10.0
# homeassistant.components.lg_thinq
-thinqconnect==1.0.0
+thinqconnect==1.0.2
# homeassistant.components.tilt_ble
tilt-ble==0.2.3
@@ -2268,10 +2314,10 @@ tololib==1.1.0
toonapi==0.3.0
# homeassistant.components.totalconnect
-total-connect-client==2024.5
+total-connect-client==2024.12
# homeassistant.components.tplink_omada
-tplink-omada-client==1.4.2
+tplink-omada-client==1.4.3
# homeassistant.components.transmission
transmission-rpc==7.0.3
@@ -2286,10 +2332,10 @@ ttls==1.8.3
ttn_client==1.2.0
# homeassistant.components.tuya
-tuya-device-sharing-sdk==0.1.9
+tuya-device-sharing-sdk==0.2.1
# homeassistant.components.twentemilieu
-twentemilieu==2.0.1
+twentemilieu==2.2.1
# homeassistant.components.twilio
twilio==6.32.0
@@ -2304,7 +2350,7 @@ typedmonarchmoney==0.3.1
uasiren==0.0.1
# homeassistant.components.unifiprotect
-uiprotect==6.4.0
+uiprotect==7.4.1
# homeassistant.components.landisgyr_heat_meter
ultraheat-api==0.5.7
@@ -2312,11 +2358,8 @@ ultraheat-api==0.5.7
# homeassistant.components.unifiprotect
unifi-discovery==1.2.0
-# homeassistant.components.zha
-universal-silabs-flasher==0.0.24
-
# homeassistant.components.upb
-upb-lib==0.5.8
+upb-lib==0.5.9
# homeassistant.components.upcloud
upcloud-api==2.6.0
@@ -2339,7 +2382,7 @@ vallox-websocket-api==5.3.0
vehicle==2.2.2
# homeassistant.components.velbus
-velbus-aio==2024.10.0
+velbus-aio==2024.12.3
# homeassistant.components.venstar
venstarcolortouch==0.19
@@ -2348,7 +2391,7 @@ venstarcolortouch==0.19
vilfo-api-client==0.5.0
# homeassistant.components.voip
-voip-utils==0.1.0
+voip-utils==0.2.2
# homeassistant.components.volvooncall
volvooncall==0.10.3
@@ -2370,22 +2413,25 @@ wakeonlan==2.1.0
wallbox==0.7.0
# homeassistant.components.folder_watcher
-watchdog==2.3.1
+watchdog==6.0.0
+
+# homeassistant.components.watergate
+watergate-local-api==2024.4.1
# homeassistant.components.weatherflow_cloud
weatherflow4py==1.0.6
# homeassistant.components.nasweb
-webio-api==0.1.8
+webio-api==0.1.11
# homeassistant.components.webmin
webmin-xmlrpc==0.0.2
# homeassistant.components.weheat
-weheat==2024.09.23
+weheat==2024.12.22
# homeassistant.components.whirlpool
-whirlpool-sixth-sense==0.18.8
+whirlpool-sixth-sense==0.18.11
# homeassistant.components.whois
whois==0.9.27
@@ -2394,7 +2440,7 @@ whois==0.9.27
wiffi==1.1.2
# homeassistant.components.wled
-wled==0.20.2
+wled==0.21.0
# homeassistant.components.wolflink
wolf-comm==0.0.15
@@ -2403,13 +2449,13 @@ wolf-comm==0.0.15
wyoming==1.5.4
# homeassistant.components.xbox
-xbox-webapi==2.0.11
+xbox-webapi==2.1.0
# homeassistant.components.xiaomi_ble
xiaomi-ble==0.33.0
# homeassistant.components.knx
-xknx==3.3.0
+xknx==3.4.0
# homeassistant.components.knx
xknxproject==3.8.1
@@ -2427,7 +2473,7 @@ yalesmartalarmclient==0.4.3
# homeassistant.components.august
# homeassistant.components.yale
# homeassistant.components.yalexs_ble
-yalexs-ble==2.5.0
+yalexs-ble==2.5.6
# homeassistant.components.august
# homeassistant.components.yale
@@ -2446,22 +2492,22 @@ youless-api==2.1.2
youtubeaio==1.1.5
# homeassistant.components.media_extractor
-yt-dlp[default]==2024.11.04
+yt-dlp[default]==2024.12.23
# homeassistant.components.zamg
zamg==0.3.6
# homeassistant.components.zeroconf
-zeroconf==0.136.0
+zeroconf==0.137.2
# homeassistant.components.zeversolar
zeversolar==0.3.2
# homeassistant.components.zha
-zha==0.0.37
+zha==0.0.45
# homeassistant.components.zwave_js
-zwave-js-server-python==0.59.0
+zwave-js-server-python==0.60.0
# homeassistant.components.zwave_me
zwave-me-ws==0.4.3
diff --git a/requirements_test_pre_commit.txt b/requirements_test_pre_commit.txt
index bab89d20584..7760beef113 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.7.2
+ruff==0.8.6
yamllint==1.35.1
diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py
index 02dad3aef3f..59ecec939f3 100755
--- a/script/gen_requirements_all.py
+++ b/script/gen_requirements_all.py
@@ -117,9 +117,9 @@ httplib2>=0.19.0
# gRPC is an implicit dependency that we want to make explicit so we manage
# upgrades intentionally. It is a large package to build from source and we
# want to ensure we have wheels built.
-grpcio==1.66.2
-grpcio-status==1.66.2
-grpcio-reflection==1.66.2
+grpcio==1.67.1
+grpcio-status==1.67.1
+grpcio-reflection==1.67.1
# This is a old unmaintained library and is replaced with pycryptodome
pycrypto==1000000000.0.0
@@ -139,7 +139,7 @@ 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.6.2.post1
+anyio==4.7.0
h11==0.14.0
httpcore==1.0.5
@@ -148,7 +148,7 @@ httpcore==1.0.5
hyperframe>=5.2.0
# Ensure we run compatible with musllinux build env
-numpy==2.1.2
+numpy==2.2.1
pandas~=2.2.3
# Constrain multidict to avoid typing issues
@@ -158,9 +158,8 @@ multidict>=6.0.2
# Version 2.0 added typing, prevent accidental fallbacks
backoff>=2.0
-# Required to avoid breaking (#101042).
-# v2 has breaking changes (#99218).
-pydantic==1.10.18
+# ensure pydantic version does not float since it might have breaking changes
+pydantic==2.10.4
# Required for Python 3.12.4 compatibility (#119223).
mashumaro>=3.13.1
@@ -179,16 +178,18 @@ pyOpenSSL>=24.0.0
# protobuf must be in package constraints for the wheel
# builder to build binary wheels
-protobuf==5.28.3
+protobuf==5.29.2
# faust-cchardet: Ensure we have a version we can build wheels
# 2.1.18 is the first version that works with our wheel builder
faust-cchardet>=2.1.18
-# websockets 11.0 is missing files in the source distribution
-# which break wheel builds so we need at least 11.0.1
-# https://github.com/aaugustin/websockets/issues/1329
-websockets>=11.0.1
+# websockets 13.1 is the first version to fully support the new
+# asyncio implementation. The legacy implementation is now
+# deprecated as of websockets 14.0.
+# https://websockets.readthedocs.io/en/13.0.1/howto/upgrade.html#missing-features
+# https://websockets.readthedocs.io/en/stable/howto/upgrade.html
+websockets>=13.1
# pysnmplib is no longer maintained and does not work with newer
# python
@@ -214,8 +215,8 @@ chacha20poly1305-reuseable>=0.13.0
# https://github.com/pycountry/pycountry/blob/ea69bab36f00df58624a0e490fdad4ccdc14268b/HISTORY.txt#L39
pycountry>=23.12.11
-# scapy<2.5.0 will not work with python3.12
-scapy>=2.5.0
+# scapy==2.6.0 causes CI failures due to a race condition
+scapy>=2.6.1
# tuf isn't updated to deal with breaking changes in securesystemslib==1.0.
# Only tuf>=4 includes a constraint to <1.0.
@@ -228,6 +229,14 @@ tenacity!=8.4.0
# 5.0.0 breaks Timeout as a context manager
# TypeError: 'Timeout' object does not support the context manager protocol
async-timeout==4.0.3
+
+# aiofiles keeps getting downgraded by custom components
+# causing newer methods to not be available and breaking
+# some integrations at startup
+# https://github.com/home-assistant/core/issues/127529
+# https://github.com/home-assistant/core/issues/122508
+# https://github.com/home-assistant/core/issues/118004
+aiofiles>=24.1.0
"""
GENERATED_MESSAGE = (
@@ -348,8 +357,8 @@ def gather_modules() -> dict[str, list[str]] | None:
gather_requirements_from_manifests(errors, reqs)
gather_requirements_from_modules(errors, reqs)
- for key in reqs:
- reqs[key] = sorted(reqs[key], key=lambda name: (len(name.split(".")), name))
+ for value in reqs.values():
+ value = sorted(value, key=lambda name: (len(name.split(".")), name))
if errors:
print("******* ERROR")
@@ -619,7 +628,6 @@ def _get_hassfest_config() -> Config:
specific_integrations=None,
action="validate",
requirements=True,
- core_integrations_path=Path("homeassistant/components"),
)
diff --git a/script/hassfest/__init__.py b/script/hassfest/__init__.py
index 2fa7997162f..c8c9aa9ef39 100644
--- a/script/hassfest/__init__.py
+++ b/script/hassfest/__init__.py
@@ -1 +1,14 @@
"""Manifest validator."""
+
+import ast
+from functools import lru_cache
+from pathlib import Path
+
+
+@lru_cache
+def ast_parse_module(file_path: Path) -> ast.Module:
+ """Parse a module.
+
+ Cached to avoid parsing the same file for each plugin.
+ """
+ return ast.parse(file_path.read_text())
diff --git a/script/hassfest/__main__.py b/script/hassfest/__main__.py
index f0b9ad25dd0..c93d8fd4499 100644
--- a/script/hassfest/__main__.py
+++ b/script/hassfest/__main__.py
@@ -23,6 +23,7 @@ from . import (
metadata,
mqtt,
mypy_config,
+ quality_scale,
requirements,
services,
ssdp,
@@ -43,6 +44,7 @@ INTEGRATION_PLUGINS = [
json,
manifest,
mqtt,
+ quality_scale,
requirements,
services,
ssdp,
@@ -108,10 +110,10 @@ def get_config() -> Config:
help="Comma-separate list of plugins to run. Valid plugin names: %(default)s",
)
parser.add_argument(
- "--core-integrations-path",
+ "--core-path",
type=Path,
- default=Path("homeassistant/components"),
- help="Path to core integrations",
+ default=Path(),
+ help="Path to core",
)
parsed = parser.parse_args()
@@ -123,16 +125,18 @@ def get_config() -> Config:
"Generate is not allowed when limiting to specific integrations"
)
- if not parsed.integration_path and not Path("requirements_all.txt").is_file():
+ if (
+ not parsed.integration_path
+ and not (parsed.core_path / "requirements_all.txt").is_file()
+ ):
raise RuntimeError("Run from Home Assistant root")
return Config(
- root=Path().absolute(),
+ root=parsed.core_path.absolute(),
specific_integrations=parsed.integration_path,
action=parsed.action,
requirements=parsed.requirements,
plugins=set(parsed.plugins),
- core_integrations_path=parsed.core_integrations_path,
)
diff --git a/script/hassfest/config_schema.py b/script/hassfest/config_schema.py
index 6b863ab9ecd..70dff1194bc 100644
--- a/script/hassfest/config_schema.py
+++ b/script/hassfest/config_schema.py
@@ -6,6 +6,7 @@ import ast
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN
+from . import ast_parse_module
from .model import Config, Integration
CONFIG_SCHEMA_IGNORE = {
@@ -60,7 +61,7 @@ def _validate_integration(config: Config, integration: Integration) -> None:
# Virtual integrations don't have any implementation
return
- init = ast.parse(init_file.read_text())
+ init = ast_parse_module(init_file)
# No YAML Support
if not _has_function(
@@ -81,7 +82,7 @@ def _validate_integration(config: Config, integration: Integration) -> None:
config_file = integration.path / "config.py"
if config_file.is_file():
- config_module = ast.parse(config_file.read_text())
+ config_module = ast_parse_module(config_file)
if _has_function(config_module, ast.AsyncFunctionDef, "async_validate_config"):
return
diff --git a/script/hassfest/dependencies.py b/script/hassfest/dependencies.py
index 0c7f4f11a8c..d29571eaa83 100644
--- a/script/hassfest/dependencies.py
+++ b/script/hassfest/dependencies.py
@@ -10,6 +10,7 @@ from pathlib import Path
from homeassistant.const import Platform
from homeassistant.requirements import DISCOVERY_INTEGRATIONS
+from . import ast_parse_module
from .model import Config, Integration
@@ -33,7 +34,7 @@ class ImportCollector(ast.NodeVisitor):
self._cur_fil_dir = fil.relative_to(self.integration.path)
self.referenced[self._cur_fil_dir] = set()
try:
- self.visit(ast.parse(fil.read_text()))
+ self.visit(ast_parse_module(fil))
except SyntaxError as e:
e.add_note(f"File: {fil}")
raise
@@ -167,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 083cdaba1a9..022caee30cd 100644
--- a/script/hassfest/docker.py
+++ b/script/hassfest/docker.py
@@ -4,6 +4,7 @@ from dataclasses import dataclass
from pathlib import Path
from homeassistant import core
+from homeassistant.components.go2rtc.const import RECOMMENDED_VERSION as GO2RTC_VERSION
from homeassistant.const import Platform
from homeassistant.util import executor, thread
from script.gen_requirements_all import gather_recursive_requirements
@@ -79,7 +80,7 @@ WORKDIR /config
_HASSFEST_TEMPLATE = r"""# Automatically generated by hassfest.
#
# To update, run python3 -m script.hassfest -p docker
-FROM python:3.12-alpine
+FROM python:3.13-alpine
ENV \
UV_SYSTEM_PYTHON=true \
@@ -112,8 +113,6 @@ LABEL "com.github.actions.icon"="terminal"
LABEL "com.github.actions.color"="gray-dark"
"""
-_GO2RTC_VERSION = "1.9.6"
-
def _get_package_versions(file: Path, packages: set[str]) -> dict[str, str]:
package_versions: dict[str, str] = {}
@@ -162,6 +161,8 @@ def _generate_hassfest_dockerimage(
packages.update(
gather_recursive_requirements(platform.value, already_checked_domains)
)
+ # Add go2rtc requirements as this file needs the go2rtc integration
+ packages.update(gather_recursive_requirements("go2rtc", already_checked_domains))
return File(
_HASSFEST_TEMPLATE.format(
@@ -184,12 +185,12 @@ def _generate_files(config: Config) -> list[File]:
+ 10
) * 1000
- package_versions = _get_package_versions(Path("requirements.txt"), {"uv"})
+ package_versions = _get_package_versions(config.root / "requirements.txt", {"uv"})
package_versions |= _get_package_versions(
- Path("requirements_test.txt"), {"pipdeptree", "tqdm"}
+ config.root / "requirements_test.txt", {"pipdeptree", "tqdm"}
)
package_versions |= _get_package_versions(
- Path("requirements_test_pre_commit.txt"), {"ruff"}
+ config.root / "requirements_test_pre_commit.txt", {"ruff"}
)
return [
@@ -197,7 +198,7 @@ def _generate_files(config: Config) -> list[File]:
DOCKERFILE_TEMPLATE.format(
timeout=timeout,
**package_versions,
- go2rtc=_GO2RTC_VERSION,
+ go2rtc=GO2RTC_VERSION,
),
config.root / "Dockerfile",
),
diff --git a/script/hassfest/docker/Dockerfile b/script/hassfest/docker/Dockerfile
index 745159d61d3..3da4eb386de 100644
--- a/script/hassfest/docker/Dockerfile
+++ b/script/hassfest/docker/Dockerfile
@@ -1,7 +1,7 @@
# Automatically generated by hassfest.
#
# To update, run python3 -m script.hassfest -p docker
-FROM python:3.12-alpine
+FROM python:3.13-alpine
ENV \
UV_SYSTEM_PYTHON=true \
@@ -14,7 +14,7 @@ WORKDIR "/github/workspace"
COPY . /usr/src/homeassistant
# Uv is only needed during build
-RUN --mount=from=ghcr.io/astral-sh/uv:0.5.0,source=/uv,target=/bin/uv \
+RUN --mount=from=ghcr.io/astral-sh/uv:0.5.8,source=/uv,target=/bin/uv \
# Required for PyTurboJPEG
apk add --no-cache libturbojpeg \
&& uv pip install \
@@ -22,8 +22,8 @@ RUN --mount=from=ghcr.io/astral-sh/uv:0.5.0,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.7.2 \
- PyTurboJPEG==1.7.5 ha-ffmpeg==3.2.2 hassil==1.7.4 home-assistant-intents==2024.11.6 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.8.6 \
+ 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
LABEL "name"="hassfest"
LABEL "maintainer"="Home Assistant "
diff --git a/script/hassfest/docker/entrypoint.sh b/script/hassfest/docker/entrypoint.sh
index 7b75eb186d2..eabc08a9499 100755
--- a/script/hassfest/docker/entrypoint.sh
+++ b/script/hassfest/docker/entrypoint.sh
@@ -2,16 +2,28 @@
integrations=""
integration_path=""
+core_path_provided=false
-# Enable recursive globbing using find
-for manifest in $(find . -name "manifest.json"); do
- manifest_path=$(realpath "${manifest}")
- integrations="$integrations --integration-path ${manifest_path%/*}"
+for arg in "$@"; do
+ case "$arg" in
+ --core-path=*)
+ core_path_provided=true
+ break
+ ;;
+ esac
done
-if [ -z "$integrations" ]; then
- echo "Error: No integrations found!"
- exit 1
+if [ "$core_path_provided" = false ]; then
+ # Enable recursive globbing using find
+ for manifest in $(find . -name "manifest.json"); do
+ manifest_path=$(realpath "${manifest}")
+ integrations="$integrations --integration-path ${manifest_path%/*}"
+ done
+
+ if [ -z "$integrations" ]; then
+ echo "Error: No integrations found!"
+ exit 1
+ fi
fi
cd /usr/src/homeassistant || exit 1
diff --git a/script/hassfest/manifest.py b/script/hassfest/manifest.py
index 6d2f4087f59..fdbcf5bcb78 100644
--- a/script/hassfest/manifest.py
+++ b/script/hassfest/manifest.py
@@ -2,7 +2,7 @@
from __future__ import annotations
-from enum import IntEnum
+from enum import StrEnum, auto
import json
from pathlib import Path
import subprocess
@@ -20,7 +20,7 @@ from voluptuous.humanize import humanize_error
from homeassistant.const import Platform
from homeassistant.helpers import config_validation as cv
-from .model import Config, Integration
+from .model import Config, Integration, ScaledQualityScaleTiers
DOCUMENTATION_URL_SCHEMA = "https"
DOCUMENTATION_URL_HOST = "www.home-assistant.io"
@@ -28,16 +28,20 @@ DOCUMENTATION_URL_PATH_PREFIX = "/integrations/"
DOCUMENTATION_URL_EXCEPTIONS = {"https://www.home-assistant.io/hassio"}
-class QualityScale(IntEnum):
+class NonScaledQualityScaleTiers(StrEnum):
"""Supported manifest quality scales."""
- INTERNAL = -1
- SILVER = 1
- GOLD = 2
- PLATINUM = 3
+ CUSTOM = auto()
+ NO_SCORE = auto()
+ INTERNAL = auto()
+ LEGACY = auto()
-SUPPORTED_QUALITY_SCALES = [enum.name.lower() for enum in QualityScale]
+SUPPORTED_QUALITY_SCALES = [
+ value.name.lower()
+ for enum in [ScaledQualityScaleTiers, NonScaledQualityScaleTiers]
+ for value in enum
+]
SUPPORTED_IOT_CLASSES = [
"assumed_state",
"calculated",
@@ -111,19 +115,6 @@ NO_IOT_CLASS = [
"websocket_api",
"zone",
]
-# Grandfather rule for older integrations
-# https://github.com/home-assistant/developers.home-assistant/pull/1512
-NO_DIAGNOSTICS = [
- "dlna_dms",
- "hyperion",
- "nightscout",
- "pvpc_hourly_pricing",
- "risco",
- "smarttub",
- "songpal",
- "vizio",
- "yeelight",
-]
def documentation_url(value: str) -> str:
@@ -268,7 +259,6 @@ INTEGRATION_MANIFEST_SCHEMA = vol.Schema(
)
],
vol.Required("documentation"): vol.All(vol.Url(), documentation_url),
- vol.Optional("issue_tracker"): vol.Url(),
vol.Optional("quality_scale"): vol.In(SUPPORTED_QUALITY_SCALES),
vol.Optional("requirements"): [str],
vol.Optional("dependencies"): [str],
@@ -304,6 +294,7 @@ def manifest_schema(value: dict[str, Any]) -> vol.Schema:
CUSTOM_INTEGRATION_MANIFEST_SCHEMA = INTEGRATION_MANIFEST_SCHEMA.extend(
{
vol.Optional("version"): vol.All(str, verify_version),
+ vol.Optional("issue_tracker"): vol.Url(),
vol.Optional("import_executor"): bool,
}
)
@@ -359,36 +350,17 @@ def validate_manifest(integration: Integration, core_components_dir: Path) -> No
"Virtual integration points to non-existing supported_by integration",
)
- if (quality_scale := integration.manifest.get("quality_scale")) and QualityScale[
- quality_scale.upper()
- ] > QualityScale.SILVER:
+ if (
+ (quality_scale := integration.manifest.get("quality_scale"))
+ and quality_scale.upper() in ScaledQualityScaleTiers
+ and ScaledQualityScaleTiers[quality_scale.upper()]
+ >= ScaledQualityScaleTiers.SILVER
+ ):
if not integration.manifest.get("codeowners"):
integration.add_error(
"manifest",
f"{quality_scale} integration does not have a code owner",
)
- if (
- domain not in NO_DIAGNOSTICS
- and not (integration.path / "diagnostics.py").exists()
- ):
- integration.add_error(
- "manifest",
- f"{quality_scale} integration does not implement diagnostics",
- )
-
- if domain in NO_DIAGNOSTICS:
- if quality_scale and QualityScale[quality_scale.upper()] < QualityScale.GOLD:
- integration.add_error(
- "manifest",
- "{quality_scale} integration should be "
- "removed from NO_DIAGNOSTICS in script/hassfest/manifest.py",
- )
- elif (integration.path / "diagnostics.py").exists():
- integration.add_error(
- "manifest",
- "Implements diagnostics and can be "
- "removed from NO_DIAGNOSTICS in script/hassfest/manifest.py",
- )
if not integration.core:
validate_version(integration)
diff --git a/script/hassfest/model.py b/script/hassfest/model.py
index 63e9b025ed4..08ded687096 100644
--- a/script/hassfest/model.py
+++ b/script/hassfest/model.py
@@ -3,6 +3,7 @@
from __future__ import annotations
from dataclasses import dataclass, field
+from enum import IntEnum
import json
import pathlib
from typing import Any, Literal
@@ -29,11 +30,15 @@ class Config:
root: pathlib.Path
action: Literal["validate", "generate"]
requirements: bool
- core_integrations_path: pathlib.Path
+ core_integrations_path: pathlib.Path = field(init=False)
errors: list[Error] = field(default_factory=list)
cache: dict[str, Any] = field(default_factory=dict)
plugins: set[str] = field(default_factory=set)
+ def __post_init__(self) -> None:
+ """Post init."""
+ self.core_integrations_path = self.root / "homeassistant/components"
+
def add_error(self, *args: Any, **kwargs: Any) -> None:
"""Add an error."""
self.errors.append(Error(*args, **kwargs))
@@ -230,3 +235,12 @@ class Integration:
self._manifest = manifest
self.manifest_path = manifest_path
+
+
+class ScaledQualityScaleTiers(IntEnum):
+ """Supported manifest quality scales."""
+
+ BRONZE = 1
+ SILVER = 2
+ GOLD = 3
+ PLATINUM = 4
diff --git a/script/hassfest/mypy_config.py b/script/hassfest/mypy_config.py
index 25fe875e437..1d7f2b5ed88 100644
--- a/script/hassfest/mypy_config.py
+++ b/script/hassfest/mypy_config.py
@@ -33,7 +33,12 @@ HEADER: Final = """
GENERAL_SETTINGS: Final[dict[str, str]] = {
"python_version": ".".join(str(x) for x in REQUIRED_PYTHON_VER[:2]),
"platform": "linux",
- "plugins": "pydantic.mypy",
+ "plugins": ", ".join( # noqa: FLY002
+ [
+ "pydantic.mypy",
+ "pydantic.v1.mypy",
+ ]
+ ),
"show_error_codes": "true",
"follow_imports": "normal",
# "enable_incomplete_feature": ", ".join( # noqa: FLY002
@@ -42,14 +47,15 @@ GENERAL_SETTINGS: Final[dict[str, str]] = {
# Enable some checks globally.
"local_partial_types": "true",
"strict_equality": "true",
+ "strict_bytes": "true",
"no_implicit_optional": "true",
- "report_deprecated_as_error": "true",
"warn_incomplete_stub": "true",
"warn_redundant_casts": "true",
"warn_unused_configs": "true",
"warn_unused_ignores": "true",
"enable_error_code": ", ".join( # noqa: FLY002
[
+ "deprecated",
"ignore-without-code",
"redundant-self",
"truthy-iterable",
diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py
new file mode 100644
index 00000000000..4876ab225e9
--- /dev/null
+++ b/script/hassfest/quality_scale.py
@@ -0,0 +1,2489 @@
+"""Validate integration quality scale files."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+import voluptuous as vol
+from voluptuous.humanize import humanize_error
+
+from homeassistant.const import Platform
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.util.yaml import load_yaml_dict
+
+from .model import Config, Integration, ScaledQualityScaleTiers
+from .quality_scale_validation import (
+ RuleValidationProtocol,
+ config_entry_unloading,
+ config_flow,
+ diagnostics,
+ discovery,
+ parallel_updates,
+ reauthentication_flow,
+ reconfiguration_flow,
+ runtime_data,
+ strict_typing,
+ test_before_setup,
+ unique_config_entry,
+)
+
+QUALITY_SCALE_TIERS = {value.name.lower(): value for value in ScaledQualityScaleTiers}
+
+
+@dataclass
+class Rule:
+ """Quality scale rules."""
+
+ name: str
+ tier: ScaledQualityScaleTiers
+ validator: RuleValidationProtocol | None = None
+
+
+ALL_RULES = [
+ # BRONZE
+ Rule("action-setup", ScaledQualityScaleTiers.BRONZE),
+ Rule("appropriate-polling", ScaledQualityScaleTiers.BRONZE),
+ Rule("brands", ScaledQualityScaleTiers.BRONZE),
+ Rule("common-modules", ScaledQualityScaleTiers.BRONZE),
+ Rule("config-flow", ScaledQualityScaleTiers.BRONZE, config_flow),
+ Rule("config-flow-test-coverage", ScaledQualityScaleTiers.BRONZE),
+ Rule("dependency-transparency", ScaledQualityScaleTiers.BRONZE),
+ Rule("docs-actions", ScaledQualityScaleTiers.BRONZE),
+ Rule("docs-high-level-description", ScaledQualityScaleTiers.BRONZE),
+ Rule("docs-installation-instructions", ScaledQualityScaleTiers.BRONZE),
+ Rule("docs-removal-instructions", ScaledQualityScaleTiers.BRONZE),
+ Rule("entity-event-setup", ScaledQualityScaleTiers.BRONZE),
+ Rule("entity-unique-id", ScaledQualityScaleTiers.BRONZE),
+ Rule("has-entity-name", ScaledQualityScaleTiers.BRONZE),
+ Rule("runtime-data", ScaledQualityScaleTiers.BRONZE, runtime_data),
+ Rule("test-before-configure", ScaledQualityScaleTiers.BRONZE),
+ Rule("test-before-setup", ScaledQualityScaleTiers.BRONZE, test_before_setup),
+ Rule("unique-config-entry", ScaledQualityScaleTiers.BRONZE, unique_config_entry),
+ # SILVER
+ Rule("action-exceptions", ScaledQualityScaleTiers.SILVER),
+ Rule(
+ "config-entry-unloading", ScaledQualityScaleTiers.SILVER, config_entry_unloading
+ ),
+ Rule("docs-configuration-parameters", ScaledQualityScaleTiers.SILVER),
+ Rule("docs-installation-parameters", ScaledQualityScaleTiers.SILVER),
+ Rule("entity-unavailable", ScaledQualityScaleTiers.SILVER),
+ Rule("integration-owner", ScaledQualityScaleTiers.SILVER),
+ Rule("log-when-unavailable", ScaledQualityScaleTiers.SILVER),
+ Rule("parallel-updates", ScaledQualityScaleTiers.SILVER, parallel_updates),
+ Rule(
+ "reauthentication-flow", ScaledQualityScaleTiers.SILVER, reauthentication_flow
+ ),
+ Rule("test-coverage", ScaledQualityScaleTiers.SILVER),
+ # GOLD: [
+ Rule("devices", ScaledQualityScaleTiers.GOLD),
+ Rule("diagnostics", ScaledQualityScaleTiers.GOLD, diagnostics),
+ Rule("discovery", ScaledQualityScaleTiers.GOLD, discovery),
+ Rule("discovery-update-info", ScaledQualityScaleTiers.GOLD),
+ Rule("docs-data-update", ScaledQualityScaleTiers.GOLD),
+ Rule("docs-examples", ScaledQualityScaleTiers.GOLD),
+ Rule("docs-known-limitations", ScaledQualityScaleTiers.GOLD),
+ Rule("docs-supported-devices", ScaledQualityScaleTiers.GOLD),
+ Rule("docs-supported-functions", ScaledQualityScaleTiers.GOLD),
+ Rule("docs-troubleshooting", ScaledQualityScaleTiers.GOLD),
+ Rule("docs-use-cases", ScaledQualityScaleTiers.GOLD),
+ Rule("dynamic-devices", ScaledQualityScaleTiers.GOLD),
+ Rule("entity-category", ScaledQualityScaleTiers.GOLD),
+ Rule("entity-device-class", ScaledQualityScaleTiers.GOLD),
+ Rule("entity-disabled-by-default", ScaledQualityScaleTiers.GOLD),
+ Rule("entity-translations", ScaledQualityScaleTiers.GOLD),
+ Rule("exception-translations", ScaledQualityScaleTiers.GOLD),
+ Rule("icon-translations", ScaledQualityScaleTiers.GOLD),
+ Rule("reconfiguration-flow", ScaledQualityScaleTiers.GOLD, reconfiguration_flow),
+ Rule("repair-issues", ScaledQualityScaleTiers.GOLD),
+ Rule("stale-devices", ScaledQualityScaleTiers.GOLD),
+ # PLATINUM
+ Rule("async-dependency", ScaledQualityScaleTiers.PLATINUM),
+ Rule("inject-websession", ScaledQualityScaleTiers.PLATINUM),
+ Rule("strict-typing", ScaledQualityScaleTiers.PLATINUM, strict_typing),
+]
+
+SCALE_RULES = {
+ tier: [rule.name for rule in ALL_RULES if rule.tier == tier]
+ for tier in ScaledQualityScaleTiers
+}
+
+VALIDATORS = {rule.name: rule.validator for rule in ALL_RULES if rule.validator}
+
+RULE_URL = (
+ "Please check the documentation at "
+ "https://developers.home-assistant.io/docs/core/"
+ "integration-quality-scale/rules/{rule_name}/"
+)
+
+INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [
+ "abode",
+ "accuweather",
+ "acer_projector",
+ "acmeda",
+ "actiontec",
+ "adax",
+ "adguard",
+ "ads",
+ "advantage_air",
+ "aemet",
+ "aftership",
+ "agent_dvr",
+ "airly",
+ "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",
+ "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",
+ "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",
+ "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",
+ "eliqonline",
+ "elkm1",
+ "elmax",
+ "elv",
+ "elvia",
+ "emby",
+ "emoncms",
+ "emoncms_history",
+ "emonitor",
+ "emulated_hue",
+ "emulated_kasa",
+ "emulated_roku",
+ "energenie_power_sockets",
+ "energy",
+ "energyzero",
+ "enigma2",
+ "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",
+ "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",
+ "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_pubsub",
+ "google_sheets",
+ "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",
+ "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",
+ "imgw_pib",
+ "improv_ble",
+ "incomfort",
+ "influxdb",
+ "inkbird",
+ "insteon",
+ "integration",
+ "intellifire",
+ "intesishome",
+ "ios",
+ "iotawatt",
+ "iotty",
+ "iperf3",
+ "ipma",
+ "ipp",
+ "iqvia",
+ "irish_rail_transport",
+ "isal",
+ "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",
+ "kodi",
+ "konnected",
+ "kostal_plenticore",
+ "kraken",
+ "kulersky",
+ "kwb",
+ "lacrosse",
+ "lacrosse_view",
+ "landisgyr_heat_meter",
+ "lannouncer",
+ "lastfm",
+ "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",
+ "litterrobot",
+ "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",
+ "marytts",
+ "matrix",
+ "matter",
+ "maxcube",
+ "mazda",
+ "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",
+ "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",
+ "ness_alarm",
+ "netatmo",
+ "netdata",
+ "netgear",
+ "netgear_lte",
+ "netio",
+ "network",
+ "neurio_energy",
+ "nexia",
+ "nextbus",
+ "nextcloud",
+ "nextdns",
+ "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",
+ "ondilo_ico",
+ "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",
+ "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",
+ "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",
+ "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",
+ "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",
+ "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",
+ "weheat",
+ "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",
+ "discovergy",
+ "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",
+ "heos",
+ "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",
+ "litterrobot",
+ "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",
+ "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",
+ "plugwise",
+ "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",
+ "rympro",
+ "saj",
+ "samsungtv",
+ "sanix",
+ "satel_integra",
+ "schlage",
+ "schluter",
+ "scrape",
+ "screenlogic",
+ "scsgate",
+ "season",
+ "sendgrid",
+ "sense",
+ "sensibo",
+ "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",
+ "stookwijzer",
+ "subaru",
+ "sun",
+ "sunweg",
+ "supervisord",
+ "supla",
+ "surepetcare",
+ "swiss_public_transport",
+ "swiss_hydrological_data",
+ "swisscom",
+ "switch_as_x",
+ "switchbee",
+ "switchbot",
+ "switchbot_cloud",
+ "switcher_kis",
+ "switchmate",
+ "syncthing",
+ "syncthru",
+ "synology_chat",
+ "synology_dsm",
+ "synology_srm",
+ "syslog",
+ "system_bridge",
+ "systemmonitor",
+ "tado",
+ "tailscale",
+ "tailwind",
+ "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",
+ "totalconnect",
+ "torque",
+ "touchline",
+ "touchline_sl",
+ "tplink",
+ "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",
+ "velbus",
+ "verisure",
+ "versasense",
+ "version",
+ "vesync",
+ "vicare",
+ "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",
+ "webostv",
+ "webmin",
+ "weheat",
+ "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",
+]
+
+NO_QUALITY_SCALE = [
+ *{platform.value for platform in Platform},
+ "api",
+ "application_credentials",
+ "auth",
+ "automation",
+ "blueprint",
+ "config",
+ "configurator",
+ "counter",
+ "default_config",
+ "device_automation",
+ "device_tracker",
+ "diagnostics",
+ "ffmpeg",
+ "file_upload",
+ "frontend",
+ "hardkernel",
+ "hardware",
+ "history",
+ "homeassistant",
+ "homeassistant_alerts",
+ "homeassistant_green",
+ "homeassistant_hardware",
+ "homeassistant_sky_connect",
+ "homeassistant_yellow",
+ "image_upload",
+ "input_boolean",
+ "input_button",
+ "input_datetime",
+ "input_number",
+ "input_select",
+ "input_text",
+ "intent_script",
+ "intent",
+ "logbook",
+ "logger",
+ "lovelace",
+ "media_source",
+ "my",
+ "onboarding",
+ "panel_custom",
+ "proxy",
+ "python_script",
+ "raspberry_pi",
+ "recovery_mode",
+ "repairs",
+ "schedule",
+ "script",
+ "search",
+ "system_health",
+ "system_log",
+ "tag",
+ "timer",
+ "trace",
+ "webhook",
+ "websocket_api",
+ "zone",
+]
+
+SCHEMA = vol.Schema(
+ {
+ vol.Required("rules"): vol.Schema(
+ {
+ vol.Optional(rule.name): vol.Any(
+ vol.In(["todo", "done"]),
+ vol.Schema(
+ {
+ vol.Required("status"): vol.In(["todo", "done"]),
+ vol.Optional("comment"): str,
+ }
+ ),
+ vol.Schema(
+ {
+ vol.Required("status"): "exempt",
+ vol.Required("comment"): str,
+ }
+ ),
+ )
+ for rule in ALL_RULES
+ }
+ )
+ }
+)
+
+
+def validate_iqs_file(config: Config, integration: Integration) -> None:
+ """Validate quality scale file for integration."""
+ if not integration.core:
+ return
+
+ declared_quality_scale = QUALITY_SCALE_TIERS.get(integration.quality_scale)
+
+ iqs_file = integration.path / "quality_scale.yaml"
+ has_file = iqs_file.is_file()
+ if not has_file:
+ if (
+ integration.domain not in INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE
+ and integration.domain not in NO_QUALITY_SCALE
+ and integration.integration_type != "virtual"
+ ):
+ integration.add_error(
+ "quality_scale",
+ "Quality scale definition not found. New integrations are required to at least reach the Bronze tier.",
+ )
+ return
+ if declared_quality_scale is not None:
+ integration.add_error(
+ "quality_scale",
+ "Quality scale definition not found. Integrations that set a manifest quality scale must have a quality scale definition.",
+ )
+ return
+ return
+ if integration.integration_type == "virtual":
+ integration.add_error(
+ "quality_scale",
+ "Virtual integrations are not allowed to have a quality scale file.",
+ )
+ return
+ if integration.domain in NO_QUALITY_SCALE:
+ integration.add_error(
+ "quality_scale",
+ "This integration is not supposed to have a quality scale file.",
+ )
+ return
+ if integration.domain in INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE:
+ integration.add_error(
+ "quality_scale",
+ "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)
+
+ try:
+ data = load_yaml_dict(name)
+ except HomeAssistantError:
+ integration.add_error("quality_scale", "Invalid quality_scale.yaml")
+ return
+
+ try:
+ SCHEMA(data)
+ except vol.Invalid as err:
+ integration.add_error(
+ "quality_scale", f"Invalid {name}: {humanize_error(data, err)}"
+ )
+
+ rules_done = set[str]()
+ rules_met = set[str]()
+ for rule_name, rule_value in data.get("rules", {}).items():
+ status = rule_value["status"] if isinstance(rule_value, dict) else rule_value
+ if status not in {"done", "exempt"}:
+ continue
+ rules_met.add(rule_name)
+ if status == "done":
+ rules_done.add(rule_name)
+
+ for rule_name in rules_done:
+ if (validator := VALIDATORS.get(rule_name)) and (
+ errors := validator.validate(config, integration, rules_done=rules_done)
+ ):
+ for error in errors:
+ integration.add_error("quality_scale", f"[{rule_name}] {error}")
+ integration.add_error("quality_scale", RULE_URL.format(rule_name=rule_name))
+
+ # An integration must have all the necessary rules for the declared
+ # quality scale, and all the rules below.
+ if declared_quality_scale is None:
+ return
+
+ for scale in ScaledQualityScaleTiers:
+ if scale > declared_quality_scale:
+ break
+ required_rules = set(SCALE_RULES[scale])
+ if missing_rules := (required_rules - rules_met):
+ friendly_rule_str = "\n".join(
+ f" {rule}: todo" for rule in sorted(missing_rules)
+ )
+ integration.add_error(
+ "quality_scale",
+ f"Quality scale tier {scale.name.lower()} requires quality scale rules to be met:\n{friendly_rule_str}",
+ )
+
+
+def validate(integrations: dict[str, Integration], config: Config) -> None:
+ """Handle YAML files inside integrations."""
+ for integration in integrations.values():
+ validate_iqs_file(config, integration)
diff --git a/script/hassfest/quality_scale_validation/__init__.py b/script/hassfest/quality_scale_validation/__init__.py
new file mode 100644
index 00000000000..7c41a58b601
--- /dev/null
+++ b/script/hassfest/quality_scale_validation/__init__.py
@@ -0,0 +1,17 @@
+"""Integration quality scale rules."""
+
+from typing import Protocol
+
+from script.hassfest.model import Config, Integration
+
+
+class RuleValidationProtocol(Protocol):
+ """Protocol for rule validation."""
+
+ def validate(
+ self, config: Config, integration: Integration, *, rules_done: set[str]
+ ) -> list[str] | None:
+ """Validate a quality scale rule.
+
+ Returns error (if any).
+ """
diff --git a/script/hassfest/quality_scale_validation/config_entry_unloading.py b/script/hassfest/quality_scale_validation/config_entry_unloading.py
new file mode 100644
index 00000000000..4874ddc4625
--- /dev/null
+++ b/script/hassfest/quality_scale_validation/config_entry_unloading.py
@@ -0,0 +1,33 @@
+"""Enforce that the integration implements entry unloading.
+
+https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/config-entry-unloading/
+"""
+
+import ast
+
+from script.hassfest import ast_parse_module
+from script.hassfest.model import Config, Integration
+
+
+def _has_unload_entry_function(module: ast.Module) -> bool:
+ """Test if the module defines `async_unload_entry` function."""
+ return any(
+ type(item) is ast.AsyncFunctionDef and item.name == "async_unload_entry"
+ for item in module.body
+ )
+
+
+def validate(
+ config: Config, integration: Integration, *, rules_done: set[str]
+) -> list[str] | None:
+ """Validate that the integration has a config flow."""
+
+ init_file = integration.path / "__init__.py"
+ init = ast_parse_module(init_file)
+
+ if not _has_unload_entry_function(init):
+ return [
+ "Integration does not support config entry unloading "
+ "(is missing `async_unload_entry` in __init__.py)"
+ ]
+ return None
diff --git a/script/hassfest/quality_scale_validation/config_flow.py b/script/hassfest/quality_scale_validation/config_flow.py
new file mode 100644
index 00000000000..d1ac70ab469
--- /dev/null
+++ b/script/hassfest/quality_scale_validation/config_flow.py
@@ -0,0 +1,26 @@
+"""Enforce that the integration implements config flow.
+
+https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/config-flow/
+"""
+
+from script.hassfest.model import Config, Integration
+
+
+def validate(
+ config: Config, integration: Integration, *, rules_done: set[str]
+) -> list[str] | None:
+ """Validate that the integration implements config flow."""
+
+ if not integration.config_flow:
+ return [
+ "Integration does not set config_flow in its manifest "
+ f"homeassistant/components/{integration.domain}/manifest.json",
+ ]
+
+ config_flow_file = integration.path / "config_flow.py"
+ if not config_flow_file.exists():
+ return [
+ "Integration does not implement config flow (is missing config_flow.py)",
+ ]
+
+ return None
diff --git a/script/hassfest/quality_scale_validation/diagnostics.py b/script/hassfest/quality_scale_validation/diagnostics.py
new file mode 100644
index 00000000000..ea143002b09
--- /dev/null
+++ b/script/hassfest/quality_scale_validation/diagnostics.py
@@ -0,0 +1,45 @@
+"""Enforce that the integration implements diagnostics.
+
+https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/diagnostics/
+"""
+
+import ast
+
+from script.hassfest import ast_parse_module
+from script.hassfest.model import Config, Integration
+
+DIAGNOSTICS_FUNCTIONS = {
+ "async_get_config_entry_diagnostics",
+ "async_get_device_diagnostics",
+}
+
+
+def _has_diagnostics_function(module: ast.Module) -> bool:
+ """Test if the module defines at least one of diagnostic functions."""
+ return any(
+ type(item) is ast.AsyncFunctionDef and item.name in DIAGNOSTICS_FUNCTIONS
+ for item in ast.walk(module)
+ )
+
+
+def validate(
+ config: Config, integration: Integration, *, rules_done: set[str]
+) -> list[str] | None:
+ """Validate that the integration implements diagnostics."""
+
+ diagnostics_file = integration.path / "diagnostics.py"
+ if not diagnostics_file.exists():
+ return [
+ "Integration does implement diagnostics platform "
+ "(is missing diagnostics.py)",
+ ]
+
+ diagnostics = ast_parse_module(diagnostics_file)
+
+ if not _has_diagnostics_function(diagnostics):
+ return [
+ f"Integration is missing one of {DIAGNOSTICS_FUNCTIONS} "
+ f"in {diagnostics_file}"
+ ]
+
+ return None
diff --git a/script/hassfest/quality_scale_validation/discovery.py b/script/hassfest/quality_scale_validation/discovery.py
new file mode 100644
index 00000000000..d11bcaf2cec
--- /dev/null
+++ b/script/hassfest/quality_scale_validation/discovery.py
@@ -0,0 +1,62 @@
+"""Enforce that the integration supports discovery.
+
+https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/discovery/
+"""
+
+import ast
+
+from script.hassfest import ast_parse_module
+from script.hassfest.model import Config, Integration
+
+MANIFEST_KEYS = [
+ "bluetooth",
+ "dhcp",
+ "homekit",
+ "mqtt",
+ "ssdp",
+ "usb",
+ "zeroconf",
+]
+CONFIG_FLOW_STEPS = {
+ "async_step_bluetooth",
+ "async_step_discovery",
+ "async_step_dhcp",
+ "async_step_hassio",
+ "async_step_homekit",
+ "async_step_mqtt",
+ "async_step_ssdp",
+ "async_step_usb",
+ "async_step_zeroconf",
+}
+
+
+def _has_discovery_function(module: ast.Module) -> bool:
+ """Test if the module defines at least one of the discovery functions."""
+ return any(
+ type(item) is ast.AsyncFunctionDef and item.name in CONFIG_FLOW_STEPS
+ for item in ast.walk(module)
+ )
+
+
+def validate(
+ config: Config, integration: Integration, *, rules_done: set[str]
+) -> list[str] | None:
+ """Validate that the integration implements diagnostics."""
+
+ config_flow_file = integration.path / "config_flow.py"
+ if not config_flow_file.exists():
+ return ["Integration is missing config_flow.py"]
+
+ # Check manifest
+ if any(key in integration.manifest for key in MANIFEST_KEYS):
+ return None
+
+ # Fallback => check config_flow step
+ 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}"
+ ]
+
+ return None
diff --git a/script/hassfest/quality_scale_validation/parallel_updates.py b/script/hassfest/quality_scale_validation/parallel_updates.py
new file mode 100644
index 00000000000..00ad891774d
--- /dev/null
+++ b/script/hassfest/quality_scale_validation/parallel_updates.py
@@ -0,0 +1,38 @@
+"""Enforce that the integration sets PARALLEL_UPDATES constant.
+
+https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/parallel-updates
+"""
+
+import ast
+
+from homeassistant.const import Platform
+from script.hassfest import ast_parse_module
+from script.hassfest.model import Config, Integration
+
+
+def _has_parallel_updates_defined(module: ast.Module) -> bool:
+ """Test if the module defines `PARALLEL_UPDATES` constant."""
+ return any(
+ type(item) is ast.Assign and item.targets[0].id == "PARALLEL_UPDATES"
+ for item in module.body
+ )
+
+
+def validate(
+ config: Config, integration: Integration, *, rules_done: set[str]
+) -> list[str] | None:
+ """Validate that the integration sets PARALLEL_UPDATES constant."""
+
+ errors = []
+ for platform in Platform:
+ module_file = integration.path / f"{platform}.py"
+ if not module_file.exists():
+ continue
+ module = ast_parse_module(module_file)
+
+ if not _has_parallel_updates_defined(module):
+ errors.append(
+ f"Integration does not set `PARALLEL_UPDATES` in {module_file}"
+ )
+
+ return errors
diff --git a/script/hassfest/quality_scale_validation/reauthentication_flow.py b/script/hassfest/quality_scale_validation/reauthentication_flow.py
new file mode 100644
index 00000000000..3db9700af98
--- /dev/null
+++ b/script/hassfest/quality_scale_validation/reauthentication_flow.py
@@ -0,0 +1,33 @@
+"""Enforce that the integration implements reauthentication flow.
+
+https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/reauthentication-flow/
+"""
+
+import ast
+
+from script.hassfest import ast_parse_module
+from script.hassfest.model import Config, Integration
+
+
+def _has_step_reauth_function(module: ast.Module) -> bool:
+ """Test if the module defines `async_step_reauth` function."""
+ return any(
+ type(item) is ast.AsyncFunctionDef and item.name == "async_step_reauth"
+ for item in ast.walk(module)
+ )
+
+
+def validate(
+ config: Config, integration: Integration, *, rules_done: set[str]
+) -> list[str] | None:
+ """Validate that the integration has a reauthentication flow."""
+
+ config_flow_file = integration.path / "config_flow.py"
+ config_flow = ast_parse_module(config_flow_file)
+
+ if not _has_step_reauth_function(config_flow):
+ return [
+ "Integration does not support a reauthentication flow "
+ f"(is missing `async_step_reauth` in {config_flow_file})"
+ ]
+ return None
diff --git a/script/hassfest/quality_scale_validation/reconfiguration_flow.py b/script/hassfest/quality_scale_validation/reconfiguration_flow.py
new file mode 100644
index 00000000000..28cc0ef6d43
--- /dev/null
+++ b/script/hassfest/quality_scale_validation/reconfiguration_flow.py
@@ -0,0 +1,33 @@
+"""Enforce that the integration implements reconfiguration flow.
+
+https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/reconfiguration-flow/
+"""
+
+import ast
+
+from script.hassfest import ast_parse_module
+from script.hassfest.model import Config, Integration
+
+
+def _has_step_reconfigure_function(module: ast.Module) -> bool:
+ """Test if the module defines a function."""
+ return any(
+ type(item) is ast.AsyncFunctionDef and item.name == "async_step_reconfigure"
+ for item in ast.walk(module)
+ )
+
+
+def validate(
+ config: Config, integration: Integration, *, rules_done: set[str]
+) -> list[str] | None:
+ """Validate that the integration has a reconfiguration flow."""
+
+ config_flow_file = integration.path / "config_flow.py"
+ config_flow = ast_parse_module(config_flow_file)
+
+ if not _has_step_reconfigure_function(config_flow):
+ return [
+ "Integration does not support a reconfiguration flow "
+ f"(is missing `async_step_reconfigure` in {config_flow_file})"
+ ]
+ return None
diff --git a/script/hassfest/quality_scale_validation/runtime_data.py b/script/hassfest/quality_scale_validation/runtime_data.py
new file mode 100644
index 00000000000..cfc4c5224de
--- /dev/null
+++ b/script/hassfest/quality_scale_validation/runtime_data.py
@@ -0,0 +1,130 @@
+"""Enforce that the integration uses ConfigEntry.runtime_data to store runtime data.
+
+https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/runtime-data
+"""
+
+import ast
+import re
+
+from homeassistant.const import Platform
+from script.hassfest import ast_parse_module
+from script.hassfest.model import Config, Integration
+
+_ANNOTATION_MATCH = re.compile(r"^[A-Za-z]+ConfigEntry$")
+_FUNCTIONS: dict[str, dict[str, int]] = {
+ "__init__": { # based on ComponentProtocol
+ "async_migrate_entry": 2,
+ "async_remove_config_entry_device": 2,
+ "async_remove_entry": 2,
+ "async_setup_entry": 2,
+ "async_unload_entry": 2,
+ },
+ "diagnostics": { # based on DiagnosticsProtocol
+ "async_get_config_entry_diagnostics": 2,
+ "async_get_device_diagnostics": 2,
+ },
+}
+for platform in Platform: # based on EntityPlatformModule
+ _FUNCTIONS[platform.value] = {
+ "async_setup_entry": 2,
+ }
+
+
+def _sets_runtime_data(
+ async_setup_entry_function: ast.AsyncFunctionDef, config_entry_argument: ast.arg
+) -> bool:
+ """Check that `entry.runtime` gets set within `async_setup_entry`."""
+ for node in ast.walk(async_setup_entry_function):
+ if (
+ isinstance(node, ast.Attribute)
+ and isinstance(node.value, ast.Name)
+ and node.value.id == config_entry_argument.arg
+ and node.attr == "runtime_data"
+ and isinstance(node.ctx, ast.Store)
+ ):
+ return True
+ return False
+
+
+def _get_async_function(module: ast.Module, name: str) -> ast.AsyncFunctionDef | None:
+ """Get async function."""
+ for item in module.body:
+ if isinstance(item, ast.AsyncFunctionDef) and item.name == name:
+ return item
+ return None
+
+
+def _check_function_annotation(
+ function: ast.AsyncFunctionDef, position: int
+) -> str | None:
+ """Ensure function uses CustomConfigEntry type annotation."""
+ if len(function.args.args) < position:
+ return f"{function.name} has incorrect signature"
+ argument = function.args.args[position - 1]
+ if not (
+ (annotation := argument.annotation)
+ and isinstance(annotation, ast.Name)
+ and _ANNOTATION_MATCH.match(annotation.id)
+ ):
+ return f"([+ strict-typing]) {function.name} does not use typed ConfigEntry"
+ return None
+
+
+def _check_typed_config_entry(integration: Integration) -> list[str]:
+ """Ensure integration uses CustomConfigEntry type annotation."""
+ errors: list[str] = []
+ # Check body level function annotations
+ for file, functions in _FUNCTIONS.items():
+ module_file = integration.path / f"{file}.py"
+ if not module_file.exists():
+ continue
+ module = ast_parse_module(module_file)
+ for function, position in functions.items():
+ if not (async_function := _get_async_function(module, function)):
+ continue
+ if error := _check_function_annotation(async_function, position):
+ errors.append(f"{error} in {module_file}")
+
+ # Check config_flow annotations
+ config_flow_file = integration.path / "config_flow.py"
+ config_flow = ast_parse_module(config_flow_file)
+ for node in config_flow.body:
+ if not isinstance(node, ast.ClassDef):
+ continue
+ if any(
+ isinstance(async_function, ast.FunctionDef)
+ and async_function.name == "async_get_options_flow"
+ and (error := _check_function_annotation(async_function, 1))
+ for async_function in node.body
+ ):
+ errors.append(f"{error} in {config_flow_file}")
+
+ return errors
+
+
+def validate(
+ config: Config, integration: Integration, *, rules_done: set[str]
+) -> list[str] | None:
+ """Validate correct use of ConfigEntry.runtime_data."""
+ init_file = integration.path / "__init__.py"
+ init = ast_parse_module(init_file)
+
+ # Should not happen, but better to be safe
+ if not (async_setup_entry := _get_async_function(init, "async_setup_entry")):
+ return [f"Could not find `async_setup_entry` in {init_file}"]
+ if len(async_setup_entry.args.args) != 2:
+ return [f"async_setup_entry has incorrect signature in {init_file}"]
+ config_entry_argument = async_setup_entry.args.args[1]
+
+ errors: list[str] = []
+ if not _sets_runtime_data(async_setup_entry, config_entry_argument):
+ errors.append(
+ "Integration does not set entry.runtime_data in async_setup_entry"
+ f"({init_file})"
+ )
+
+ # Extra checks, if strict-typing is marked as done
+ if "strict-typing" in rules_done:
+ errors.extend(_check_typed_config_entry(integration))
+
+ return errors
diff --git a/script/hassfest/quality_scale_validation/strict_typing.py b/script/hassfest/quality_scale_validation/strict_typing.py
new file mode 100644
index 00000000000..c1373032ff8
--- /dev/null
+++ b/script/hassfest/quality_scale_validation/strict_typing.py
@@ -0,0 +1,67 @@
+"""Enforce that the integration has strict typing enabled.
+
+https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/strict-typing/
+"""
+
+from functools import lru_cache
+from importlib import metadata
+from pathlib import Path
+import re
+
+from script.hassfest.model import Config, Integration
+
+_STRICT_TYPING_FILE = Path(".strict-typing")
+_COMPONENT_REGEX = r"homeassistant.components.([^.]+).*"
+
+
+@lru_cache
+def _strict_typing_components(strict_typing_file: Path) -> set[str]:
+ return set(
+ {
+ match.group(1)
+ for line in strict_typing_file.read_text(encoding="utf-8").splitlines()
+ if (match := re.match(_COMPONENT_REGEX, line)) is not None
+ }
+ )
+
+
+def _check_requirements_are_typed(integration: Integration) -> list[str]:
+ """Check if all requirements are typed."""
+ invalid_requirements = []
+ for requirement in integration.requirements:
+ requirement_name, requirement_version = requirement.split("==")
+ # Remove any extras
+ requirement_name = requirement_name.split("[")[0]
+ try:
+ distribution = metadata.distribution(requirement_name)
+ except metadata.PackageNotFoundError:
+ # Package not installed locally
+ continue
+ if distribution.version != requirement_version:
+ # Version out of date locally
+ continue
+
+ if not any(file for file in distribution.files if file.name == "py.typed"):
+ # no py.typed file
+ invalid_requirements.append(requirement)
+
+ return invalid_requirements
+
+
+def validate(
+ config: Config, integration: Integration, *, rules_done: set[str]
+) -> list[str] | None:
+ """Validate that the integration has strict typing enabled."""
+ strict_typing_file = config.root / _STRICT_TYPING_FILE
+
+ if integration.domain not in _strict_typing_components(strict_typing_file):
+ return [
+ "Integration does not have strict typing enabled "
+ "(is missing from .strict-typing)"
+ ]
+ if untyped_requirements := _check_requirements_are_typed(integration):
+ return [
+ f"Requirements {untyped_requirements} do not conform PEP 561 (https://peps.python.org/pep-0561/)",
+ "They should be typed and have a 'py.typed' file",
+ ]
+ return None
diff --git a/script/hassfest/quality_scale_validation/test_before_setup.py b/script/hassfest/quality_scale_validation/test_before_setup.py
new file mode 100644
index 00000000000..1ac0d3d8e0b
--- /dev/null
+++ b/script/hassfest/quality_scale_validation/test_before_setup.py
@@ -0,0 +1,89 @@
+"""Enforce that the integration raises correctly during initialisation.
+
+https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/test-before-setup/
+"""
+
+import ast
+
+from script.hassfest import ast_parse_module
+from script.hassfest.model import Config, Integration
+
+_VALID_EXCEPTIONS = {
+ "ConfigEntryNotReady",
+ "ConfigEntryAuthFailed",
+ "ConfigEntryError",
+}
+
+
+def _get_exception_name(expression: ast.expr) -> str:
+ """Get the name of the exception being raised."""
+ if expression is None:
+ # Bare raise
+ return None
+
+ if isinstance(expression, ast.Name):
+ # Raise Exception
+ return expression.id
+
+ if isinstance(expression, ast.Call):
+ # Raise Exception()
+ return _get_exception_name(expression.func)
+
+ if isinstance(expression, ast.Attribute):
+ # Raise namespace.???
+ return _get_exception_name(expression.value)
+
+ raise AssertionError(
+ f"Raise is neither Attribute nor Call nor Name: {type(expression)}"
+ )
+
+
+def _raises_exception(integration: Integration) -> bool:
+ """Check that a valid exception is raised."""
+ for module_file in integration.path.rglob("*.py"):
+ module = ast_parse_module(module_file)
+ for node in ast.walk(module):
+ if (
+ isinstance(node, ast.Raise)
+ and _get_exception_name(node.exc) in _VALID_EXCEPTIONS
+ ):
+ return True
+
+ return False
+
+
+def _calls_first_refresh(async_setup_entry_function: ast.AsyncFunctionDef) -> bool:
+ """Check that a async_config_entry_first_refresh within `async_setup_entry`."""
+ for node in ast.walk(async_setup_entry_function):
+ if (
+ isinstance(node, ast.Call)
+ and isinstance(node.func, ast.Attribute)
+ and node.func.attr == "async_config_entry_first_refresh"
+ ):
+ return True
+
+ return False
+
+
+def _get_setup_entry_function(module: ast.Module) -> ast.AsyncFunctionDef | None:
+ """Get async_setup_entry function."""
+ for item in module.body:
+ if isinstance(item, ast.AsyncFunctionDef) and item.name == "async_setup_entry":
+ return item
+ return None
+
+
+def validate(
+ config: Config, integration: Integration, *, rules_done: set[str]
+) -> list[str] | None:
+ """Validate correct use of ConfigEntry.runtime_data."""
+ init_file = integration.path / "__init__.py"
+ init = ast_parse_module(init_file)
+
+ # Should not happen, but better to be safe
+ if not (async_setup_entry := _get_setup_entry_function(init)):
+ return [f"Could not find `async_setup_entry` in {init_file}"]
+
+ if not (_calls_first_refresh(async_setup_entry) or _raises_exception(integration)):
+ return [f"Integration does not raise one of {_VALID_EXCEPTIONS}"]
+ return None
diff --git a/script/hassfest/quality_scale_validation/unique_config_entry.py b/script/hassfest/quality_scale_validation/unique_config_entry.py
new file mode 100644
index 00000000000..83b3d20bd80
--- /dev/null
+++ b/script/hassfest/quality_scale_validation/unique_config_entry.py
@@ -0,0 +1,52 @@
+"""Enforce that the integration prevents duplicates from being configured.
+
+https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/unique-config-entry/
+"""
+
+import ast
+
+from script.hassfest import ast_parse_module
+from script.hassfest.model import Config, Integration
+
+
+def _has_method_call(module: ast.Module, name: str) -> bool:
+ """Test if the module calls a specific method."""
+ return any(
+ type(item.func) is ast.Attribute and item.func.attr == name
+ for item in ast.walk(module)
+ if isinstance(item, ast.Call)
+ )
+
+
+def _has_abort_entries_match(module: ast.Module) -> bool:
+ """Test if the module calls `_async_abort_entries_match`."""
+ return _has_method_call(module, "_async_abort_entries_match")
+
+
+def _has_abort_unique_id_configured(module: ast.Module) -> bool:
+ """Test if the module calls defines (and checks for) a unique_id."""
+ return _has_method_call(module, "async_set_unique_id") and _has_method_call(
+ module, "_abort_if_unique_id_configured"
+ )
+
+
+def validate(
+ config: Config, integration: Integration, *, rules_done: set[str]
+) -> list[str] | None:
+ """Validate that the integration prevents duplicate devices."""
+
+ if integration.manifest.get("single_config_entry"):
+ return None
+
+ config_flow_file = integration.path / "config_flow.py"
+ config_flow = ast_parse_module(config_flow_file)
+
+ if not (
+ _has_abort_entries_match(config_flow)
+ or _has_abort_unique_id_configured(config_flow)
+ ):
+ return [
+ "Integration doesn't prevent the same device or service from being "
+ f"set up twice in {config_flow_file}"
+ ]
+ return None
diff --git a/script/hassfest/services.py b/script/hassfest/services.py
index 92fca14d373..3a0ebed76fe 100644
--- a/script/hassfest/services.py
+++ b/script/hassfest/services.py
@@ -75,6 +75,16 @@ CUSTOM_INTEGRATION_FIELD_SCHEMA = CORE_INTEGRATION_FIELD_SCHEMA.extend(
}
)
+CUSTOM_INTEGRATION_SECTION_SCHEMA = vol.Schema(
+ {
+ vol.Optional("description"): str,
+ vol.Optional("name"): str,
+ vol.Optional("collapsed"): bool,
+ vol.Required("fields"): vol.Schema({str: CUSTOM_INTEGRATION_FIELD_SCHEMA}),
+ }
+)
+
+
CORE_INTEGRATION_SERVICE_SCHEMA = vol.Any(
vol.Schema(
{
@@ -105,7 +115,17 @@ CUSTOM_INTEGRATION_SERVICE_SCHEMA = vol.Any(
vol.Optional("target"): vol.Any(
selector.TargetSelector.CONFIG_SCHEMA, None
),
- vol.Optional("fields"): vol.Schema({str: CUSTOM_INTEGRATION_FIELD_SCHEMA}),
+ vol.Optional("fields"): vol.All(
+ vol.Schema(
+ {
+ str: vol.Any(
+ CUSTOM_INTEGRATION_FIELD_SCHEMA,
+ CUSTOM_INTEGRATION_SECTION_SCHEMA,
+ )
+ }
+ ),
+ unique_field_validator,
+ ),
}
),
None,
diff --git a/script/hassfest/translations.py b/script/hassfest/translations.py
index 2c3b9b4d99b..2fb70b6e0be 100644
--- a/script/hassfest/translations.py
+++ b/script/hassfest/translations.py
@@ -172,6 +172,9 @@ def gen_data_entry_schema(
vol.Optional("sections"): {
str: {
vol.Optional("data"): {str: translation_value_validator},
+ vol.Optional("data_description"): {
+ str: translation_value_validator
+ },
vol.Optional("description"): translation_value_validator,
vol.Optional("name"): translation_value_validator,
},
@@ -368,6 +371,9 @@ def gen_strings_schema(config: Config, integration: Integration) -> vol.Schema:
},
slug_validator=translation_key_validator,
),
+ vol.Optional(
+ "unit_of_measurement"
+ ): translation_value_validator,
},
slug_validator=translation_key_validator,
),
diff --git a/script/hassfest/zeroconf.py b/script/hassfest/zeroconf.py
index 48fcc0a4589..fe3e5bb3875 100644
--- a/script/hassfest/zeroconf.py
+++ b/script/hassfest/zeroconf.py
@@ -55,19 +55,19 @@ def generate_and_validate(integrations: dict[str, Integration]) -> str:
# HomeKit models are matched on starting string, make sure none overlap.
warned = set()
- for key in homekit_dict:
+ for key, value in homekit_dict.items():
if key in warned:
continue
# n^2 yoooo
- for key_2 in homekit_dict:
+ for key_2, value_2 in homekit_dict.items():
if key == key_2 or key_2 in warned:
continue
if key.startswith(key_2) or key_2.startswith(key):
integration.add_error(
"zeroconf",
- f"Integrations {homekit_dict[key]} and {homekit_dict[key_2]} "
+ f"Integrations {value} and {value_2} "
"have overlapping HomeKit models",
)
warned.add(key)
diff --git a/script/json_schemas/manifest_schema.json b/script/json_schemas/manifest_schema.json
index 40f08fd2c85..7349f12b55a 100644
--- a/script/json_schemas/manifest_schema.json
+++ b/script/json_schemas/manifest_schema.json
@@ -308,7 +308,7 @@
"quality_scale": {
"description": "The quality scale of the integration.\nhttps://developers.home-assistant.io/docs/creating_integration_manifest/#integration-quality-scale",
"type": "string",
- "enum": ["internal", "silver", "gold", "platinum"]
+ "enum": ["bronze", "silver", "gold", "platinum", "internal", "legacy"]
},
"requirements": {
"description": "The PyPI package requirements for the integration. The package has to be pinned to a specific version.\nhttps://developers.home-assistant.io/docs/creating_integration_manifest/#requirements",
diff --git a/script/licenses.py b/script/licenses.py
index f4d534365bc..464a2fc456b 100644
--- a/script/licenses.py
+++ b/script/licenses.py
@@ -84,6 +84,7 @@ OSI_APPROVED_LICENSES_SPDX = {
"LGPL-3.0-only",
"LGPL-3.0-or-later",
"MIT",
+ "MIT-CMU",
"MPL-1.1",
"MPL-2.0",
"PSF-2.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/__main__.py b/script/scaffold/__main__.py
index 45dbed790e6..93c787df50f 100644
--- a/script/scaffold/__main__.py
+++ b/script/scaffold/__main__.py
@@ -28,7 +28,7 @@ def get_arguments() -> argparse.Namespace:
return parser.parse_args()
-def main():
+def main() -> int:
"""Scaffold an integration."""
if not Path("requirements_all.txt").is_file():
print("Run from project root")
diff --git a/script/scaffold/generate.py b/script/scaffold/generate.py
index 0bee69b93f8..9ca5ead5719 100644
--- a/script/scaffold/generate.py
+++ b/script/scaffold/generate.py
@@ -19,7 +19,7 @@ def generate(template: str, info: Info) -> None:
print()
-def _generate(src_dir, target_dir, info: Info) -> None:
+def _generate(src_dir: Path, target_dir: Path, info: Info) -> None:
"""Generate an integration."""
replaces = {"NEW_DOMAIN": info.domain, "NEW_NAME": info.name}
diff --git a/script/scaffold/templates/config_flow/integration/config_flow.py b/script/scaffold/templates/config_flow/integration/config_flow.py
index 0bff976f288..06db7592840 100644
--- a/script/scaffold/templates/config_flow/integration/config_flow.py
+++ b/script/scaffold/templates/config_flow/integration/config_flow.py
@@ -1,4 +1,4 @@
-"""Config flow for NEW_NAME integration."""
+"""Config flow for the NEW_NAME integration."""
from __future__ import annotations
diff --git a/script/scaffold/templates/config_flow_discovery/integration/config_flow.py b/script/scaffold/templates/config_flow_discovery/integration/config_flow.py
index e2cfed40e1d..570b70b85aa 100644
--- a/script/scaffold/templates/config_flow_discovery/integration/config_flow.py
+++ b/script/scaffold/templates/config_flow_discovery/integration/config_flow.py
@@ -1,4 +1,4 @@
-"""Config flow for NEW_NAME."""
+"""Config flow for the NEW_NAME integration."""
import my_pypi_dependency
diff --git a/script/scaffold/templates/config_flow_helper/integration/config_flow.py b/script/scaffold/templates/config_flow_helper/integration/config_flow.py
index 5d89fec2da2..c2ab7a205da 100644
--- a/script/scaffold/templates/config_flow_helper/integration/config_flow.py
+++ b/script/scaffold/templates/config_flow_helper/integration/config_flow.py
@@ -1,4 +1,4 @@
-"""Config flow for NEW_NAME integration."""
+"""Config flow for the NEW_NAME integration."""
from __future__ import annotations
diff --git a/script/scaffold/templates/config_flow_oauth2/integration/application_credentials.py b/script/scaffold/templates/config_flow_oauth2/integration/application_credentials.py
index 51ef70b1885..0f01c8402df 100644
--- a/script/scaffold/templates/config_flow_oauth2/integration/application_credentials.py
+++ b/script/scaffold/templates/config_flow_oauth2/integration/application_credentials.py
@@ -1,11 +1,9 @@
-"""application_credentials platform the NEW_NAME integration."""
+"""Application credentials platform for the NEW_NAME integration."""
from homeassistant.components.application_credentials import AuthorizationServer
from homeassistant.core import HomeAssistant
-# TODO Update with your own urls
-OAUTH2_AUTHORIZE = "https://www.example.com/auth/authorize"
-OAUTH2_TOKEN = "https://www.example.com/auth/token"
+from .const import OAUTH2_AUTHORIZE, OAUTH2_TOKEN
async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer:
diff --git a/script/scaffold/templates/integration/integration/quality_scale.yaml b/script/scaffold/templates/integration/integration/quality_scale.yaml
new file mode 100644
index 00000000000..201a91652e5
--- /dev/null
+++ b/script/scaffold/templates/integration/integration/quality_scale.yaml
@@ -0,0 +1,60 @@
+rules:
+ # Bronze
+ action-setup: todo
+ appropriate-polling: todo
+ brands: todo
+ common-modules: todo
+ config-flow-test-coverage: todo
+ config-flow: todo
+ dependency-transparency: todo
+ docs-actions: todo
+ 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: todo
+ log-when-unavailable: todo
+ parallel-updates: todo
+ reauthentication-flow: todo
+ test-coverage: todo
+
+ # Gold
+ devices: todo
+ 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: todo
+ stale-devices: todo
+
+ # Platinum
+ async-dependency: todo
+ inject-websession: todo
+ strict-typing: todo
diff --git a/script/translations/deduplicate.py b/script/translations/deduplicate.py
index 8cc4cee3b10..f92f90115ce 100644
--- a/script/translations/deduplicate.py
+++ b/script/translations/deduplicate.py
@@ -7,8 +7,7 @@ from pathlib import Path
from homeassistant.const import Platform
from . import upload
-from .develop import flatten_translations
-from .util import get_base_arg_parser, load_json_from_path
+from .util import flatten_translations, get_base_arg_parser, load_json_from_path
def get_arguments() -> argparse.Namespace:
diff --git a/script/translations/develop.py b/script/translations/develop.py
index 00465e1bc24..9e3a2ded046 100644
--- a/script/translations/develop.py
+++ b/script/translations/develop.py
@@ -9,7 +9,7 @@ import sys
from . import download, upload
from .const import INTEGRATIONS_DIR
-from .util import get_base_arg_parser
+from .util import flatten_translations, get_base_arg_parser
def valid_integration(integration):
@@ -32,29 +32,6 @@ def get_arguments() -> argparse.Namespace:
return parser.parse_args()
-def flatten_translations(translations):
- """Flatten all translations."""
- stack = [iter(translations.items())]
- key_stack = []
- flattened_translations = {}
- while stack:
- for k, v in stack[-1]:
- key_stack.append(k)
- if isinstance(v, dict):
- stack.append(iter(v.items()))
- break
- if isinstance(v, str):
- common_key = "::".join(key_stack)
- flattened_translations[common_key] = v
- key_stack.pop()
- else:
- stack.pop()
- if key_stack:
- key_stack.pop()
-
- return flattened_translations
-
-
def substitute_translation_references(integration_strings, flattened_translations):
"""Recursively processes all translation strings for the integration."""
result = {}
diff --git a/script/translations/download.py b/script/translations/download.py
index 756de46fb61..3fa7065d058 100755
--- a/script/translations/download.py
+++ b/script/translations/download.py
@@ -7,10 +7,11 @@ import json
from pathlib import Path
import re
import subprocess
+from typing import Any
from .const import CLI_2_DOCKER_IMAGE, CORE_PROJECT_ID, INTEGRATIONS_DIR
from .error import ExitApp
-from .util import get_lokalise_token, load_json_from_path
+from .util import flatten_translations, get_lokalise_token, load_json_from_path
FILENAME_FORMAT = re.compile(r"strings\.(?P\w+)\.json")
DOWNLOAD_DIR = Path("build/translations-download").absolute()
@@ -103,7 +104,15 @@ def save_language_translations(lang, translations):
f"Skipping {lang} for {component}, as the integration doesn't seem to exist."
)
continue
+ if not (
+ Path("homeassistant") / "components" / component / "strings.json"
+ ).exists():
+ print(
+ f"Skipping {lang} for {component}, as the integration doesn't have a strings.json file."
+ )
+ continue
path.parent.mkdir(parents=True, exist_ok=True)
+ base_translations = pick_keys(component, base_translations)
save_json(path, base_translations)
if "platform" not in component_translations:
@@ -131,6 +140,32 @@ def delete_old_translations():
fil.unlink()
+def get_current_keys(component: str) -> dict[str, Any]:
+ """Get the current keys for a component."""
+ strings_path = Path("homeassistant") / "components" / component / "strings.json"
+ return load_json_from_path(strings_path)
+
+
+def pick_keys(component: str, translations: dict[str, Any]) -> dict[str, Any]:
+ """Pick the keys that are in the current strings."""
+ flat_translations = flatten_translations(translations)
+ flat_current_keys = flatten_translations(get_current_keys(component))
+ flatten_result = {}
+ for key in flat_current_keys:
+ if key in flat_translations:
+ flatten_result[key] = flat_translations[key]
+ result = {}
+ for key, value in flatten_result.items():
+ parts = key.split("::")
+ d = result
+ for part in parts[:-1]:
+ if part not in d:
+ d[part] = {}
+ d = d[part]
+ d[parts[-1]] = value
+ return result
+
+
def run():
"""Run the script."""
DOWNLOAD_DIR.mkdir(parents=True, exist_ok=True)
diff --git a/script/translations/util.py b/script/translations/util.py
index 8892bb46b7a..d78b2c4faff 100644
--- a/script/translations/util.py
+++ b/script/translations/util.py
@@ -66,3 +66,26 @@ def load_json_from_path(path: pathlib.Path) -> Any:
return json.loads(path.read_text())
except json.JSONDecodeError as err:
raise JSONDecodeErrorWithPath(err.msg, err.doc, err.pos, path) from err
+
+
+def flatten_translations(translations):
+ """Flatten all translations."""
+ stack = [iter(translations.items())]
+ key_stack = []
+ flattened_translations = {}
+ while stack:
+ for k, v in stack[-1]:
+ key_stack.append(k)
+ if isinstance(v, dict):
+ stack.append(iter(v.items()))
+ break
+ if isinstance(v, str):
+ common_key = "::".join(key_stack)
+ flattened_translations[common_key] = v
+ key_stack.pop()
+ else:
+ stack.pop()
+ if key_stack:
+ key_stack.pop()
+
+ return flattened_translations
diff --git a/tests/auth/test_jwt_wrapper.py b/tests/auth/test_jwt_wrapper.py
index 297d4dd5d7f..f9295a7791c 100644
--- a/tests/auth/test_jwt_wrapper.py
+++ b/tests/auth/test_jwt_wrapper.py
@@ -6,6 +6,12 @@ import pytest
from homeassistant.auth import jwt_wrapper
+async def test_all_default_options_are_in_verify_options() -> None:
+ """Test that all default options in _VERIFY_OPTIONS."""
+ for option in jwt_wrapper._PyJWTWithVerify._get_default_options():
+ assert option in jwt_wrapper._VERIFY_OPTIONS
+
+
async def test_reject_access_token_with_impossible_large_size() -> None:
"""Test rejecting access tokens with impossible sizes."""
with pytest.raises(jwt.DecodeError):
diff --git a/tests/common.py b/tests/common.py
index 8bd45e4d7f8..ac6f10b8c44 100644
--- a/tests/common.py
+++ b/tests/common.py
@@ -491,7 +491,7 @@ _MONOTONIC_RESOLUTION = time.get_clock_info("monotonic").resolution
def _async_fire_time_changed(
hass: HomeAssistant, utc_datetime: datetime | None, fire_all: bool
) -> None:
- timestamp = dt_util.utc_to_timestamp(utc_datetime)
+ timestamp = utc_datetime.timestamp()
for task in list(get_scheduled_timer_handles(hass.loop)):
if not isinstance(task, asyncio.TimerHandle):
continue
@@ -1815,3 +1815,20 @@ async def snapshot_platform(
state = hass.states.get(entity_entry.entity_id)
assert state, f"State not found for {entity_entry.entity_id}"
assert state == snapshot(name=f"{entity_entry.entity_id}-state")
+
+
+def reset_translation_cache(hass: HomeAssistant, components: list[str]) -> None:
+ """Reset translation cache for specified components.
+
+ Use this if you are mocking a core component (for example via
+ mock_integration), to ensure that the mocked translations are not
+ persisted in the shared session cache.
+ """
+ translations_cache = translation._async_get_translations_cache(hass)
+ for loaded_components in translations_cache.cache_data.loaded.values():
+ for component_to_unload in components:
+ loaded_components.discard(component_to_unload)
+ for loaded_categories in translations_cache.cache_data.cache.values():
+ for loaded_components in loaded_categories.values():
+ for component_to_unload in components:
+ loaded_components.pop(component_to_unload, None)
diff --git a/tests/components/abode/test_init.py b/tests/components/abode/test_init.py
index 9fca6dcbdd3..ed71cb550a7 100644
--- a/tests/components/abode/test_init.py
+++ b/tests/components/abode/test_init.py
@@ -13,7 +13,6 @@ from homeassistant.components.alarm_control_panel import DOMAIN as ALARM_DOMAIN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import CONF_USERNAME
from homeassistant.core import HomeAssistant
-from homeassistant.data_entry_flow import FlowResultType
from .common import setup_platform
@@ -63,25 +62,23 @@ async def test_unload_entry(hass: HomeAssistant) -> None:
async def test_invalid_credentials(hass: HomeAssistant) -> None:
"""Test Abode credentials changing."""
- with (
- patch(
- "homeassistant.components.abode.Abode",
- side_effect=AbodeAuthenticationException(
- (HTTPStatus.BAD_REQUEST, "auth error")
- ),
+ with patch(
+ "homeassistant.components.abode.Abode",
+ side_effect=AbodeAuthenticationException(
+ (HTTPStatus.BAD_REQUEST, "auth error")
),
- patch(
- "homeassistant.components.abode.config_flow.AbodeFlowHandler.async_step_reauth",
- return_value={
- "type": FlowResultType.FORM,
- "flow_id": "mock_flow",
- "step_id": "reauth_confirm",
- },
- ) as mock_async_step_reauth,
):
- await setup_platform(hass, ALARM_DOMAIN)
+ config_entry = await setup_platform(hass, ALARM_DOMAIN)
+ await hass.async_block_till_done()
- mock_async_step_reauth.assert_called_once()
+ assert config_entry.state is ConfigEntryState.SETUP_ERROR
+
+ flows = hass.config_entries.flow.async_progress()
+ assert len(flows) == 1
+ assert flows[0]["step_id"] == "reauth_confirm"
+
+ hass.config_entries.flow.async_abort(flows[0]["flow_id"])
+ assert not hass.config_entries.flow.async_progress()
async def test_raise_config_entry_not_ready_when_offline(hass: HomeAssistant) -> None:
diff --git a/tests/components/abode/test_light.py b/tests/components/abode/test_light.py
index fc9000a39f8..4be94a09ee8 100644
--- a/tests/components/abode/test_light.py
+++ b/tests/components/abode/test_light.py
@@ -6,7 +6,7 @@ from homeassistant.components.abode import ATTR_DEVICE_ID
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
ATTR_COLOR_MODE,
- ATTR_COLOR_TEMP,
+ ATTR_COLOR_TEMP_KELVIN,
ATTR_RGB_COLOR,
ATTR_SUPPORTED_COLOR_MODES,
DOMAIN as LIGHT_DOMAIN,
@@ -45,8 +45,8 @@ async def test_attributes(hass: HomeAssistant) -> None:
state = hass.states.get(DEVICE_ID)
assert state.state == STATE_ON
assert state.attributes.get(ATTR_BRIGHTNESS) == 204
- assert state.attributes.get(ATTR_RGB_COLOR) == (0, 63, 255)
- assert state.attributes.get(ATTR_COLOR_TEMP) is None
+ assert state.attributes.get(ATTR_RGB_COLOR) == (0, 64, 255)
+ assert state.attributes.get(ATTR_COLOR_TEMP_KELVIN) is None
assert state.attributes.get(ATTR_DEVICE_ID) == "ZB:db5b1a"
assert not state.attributes.get("battery_low")
assert not state.attributes.get("no_response")
diff --git a/tests/components/acaia/__init__.py b/tests/components/acaia/__init__.py
new file mode 100644
index 00000000000..f4eaa39e615
--- /dev/null
+++ b/tests/components/acaia/__init__.py
@@ -0,0 +1,14 @@
+"""Common test tools for the acaia integration."""
+
+from homeassistant.core import HomeAssistant
+
+from tests.common import MockConfigEntry
+
+
+async def setup_integration(
+ hass: HomeAssistant, mock_config_entry: MockConfigEntry
+) -> None:
+ """Set up the acaia integration for testing."""
+ mock_config_entry.add_to_hass(hass)
+ await hass.config_entries.async_setup(mock_config_entry.entry_id)
+ await hass.async_block_till_done()
diff --git a/tests/components/acaia/conftest.py b/tests/components/acaia/conftest.py
new file mode 100644
index 00000000000..ff151f3b096
--- /dev/null
+++ b/tests/components/acaia/conftest.py
@@ -0,0 +1,84 @@
+"""Common fixtures for the acaia tests."""
+
+from collections.abc import Generator
+from unittest.mock import AsyncMock, MagicMock, patch
+
+from aioacaia.acaiascale import AcaiaDeviceState
+from aioacaia.const import UnitMass as AcaiaUnitOfMass
+import pytest
+
+from homeassistant.components.acaia.const import CONF_IS_NEW_STYLE_SCALE, DOMAIN
+from homeassistant.const import CONF_ADDRESS
+from homeassistant.core import HomeAssistant
+
+from . import setup_integration
+
+from tests.common import MockConfigEntry
+
+
+@pytest.fixture
+def mock_setup_entry() -> Generator[AsyncMock]:
+ """Override async_setup_entry."""
+ with patch(
+ "homeassistant.components.acaia.async_setup_entry", return_value=True
+ ) as mock_setup_entry:
+ yield mock_setup_entry
+
+
+@pytest.fixture
+def mock_verify() -> Generator[AsyncMock]:
+ """Override is_new_scale check."""
+ with patch(
+ "homeassistant.components.acaia.config_flow.is_new_scale", return_value=True
+ ) as mock_verify:
+ yield mock_verify
+
+
+@pytest.fixture
+def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry:
+ """Return the default mocked config entry."""
+ return MockConfigEntry(
+ title="LUNAR-DDEEFF",
+ domain=DOMAIN,
+ version=1,
+ data={
+ CONF_ADDRESS: "aa:bb:cc:dd:ee:ff",
+ CONF_IS_NEW_STYLE_SCALE: True,
+ },
+ unique_id="aa:bb:cc:dd:ee:ff",
+ )
+
+
+@pytest.fixture
+async def init_integration(
+ hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_scale: MagicMock
+) -> MockConfigEntry:
+ """Set up the acaia integration for testing."""
+ await setup_integration(hass, mock_config_entry)
+ return mock_config_entry
+
+
+@pytest.fixture
+def mock_scale() -> Generator[MagicMock]:
+ """Return a mocked acaia scale client."""
+ with (
+ patch(
+ "homeassistant.components.acaia.coordinator.AcaiaScale",
+ autospec=True,
+ ) as scale_mock,
+ ):
+ scale = scale_mock.return_value
+ scale.connected = True
+ scale.mac = "aa:bb:cc:dd:ee:ff"
+ scale.model = "Lunar"
+ scale.last_disconnect_time = "1732181388.1895587"
+ scale.timer_running = True
+ scale.heartbeat_task = None
+ scale.process_queue_task = None
+ scale.device_state = AcaiaDeviceState(
+ battery_level=42, units=AcaiaUnitOfMass.OUNCES
+ )
+ scale.weight = 123.45
+ scale.timer = 23
+ scale.flow_rate = 1.23
+ yield scale
diff --git a/tests/components/acaia/snapshots/test_binary_sensor.ambr b/tests/components/acaia/snapshots/test_binary_sensor.ambr
new file mode 100644
index 00000000000..113b5f1501e
--- /dev/null
+++ b/tests/components/acaia/snapshots/test_binary_sensor.ambr
@@ -0,0 +1,48 @@
+# serializer version: 1
+# name: test_binary_sensors[binary_sensor.lunar_ddeeff_timer_running-entry]
+ EntityRegistryEntrySnapshot({
+ 'aliases': set({
+ }),
+ 'area_id': None,
+ 'capabilities': None,
+ 'config_entry_id': ,
+ 'device_class': None,
+ 'device_id': ,
+ 'disabled_by': None,
+ 'domain': 'binary_sensor',
+ 'entity_category': None,
+ 'entity_id': 'binary_sensor.lunar_ddeeff_timer_running',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'options': dict({
+ }),
+ 'original_device_class': ,
+ 'original_icon': None,
+ 'original_name': 'Timer running',
+ 'platform': 'acaia',
+ 'previous_unique_id': None,
+ 'supported_features': 0,
+ 'translation_key': 'timer_running',
+ 'unique_id': 'aa:bb:cc:dd:ee:ff_timer_running',
+ 'unit_of_measurement': None,
+ })
+# ---
+# name: test_binary_sensors[binary_sensor.lunar_ddeeff_timer_running-state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ 'device_class': 'running',
+ 'friendly_name': 'LUNAR-DDEEFF Timer running',
+ }),
+ 'context': ,
+ 'entity_id': 'binary_sensor.lunar_ddeeff_timer_running',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': 'on',
+ })
+# ---
diff --git a/tests/components/acaia/snapshots/test_button.ambr b/tests/components/acaia/snapshots/test_button.ambr
new file mode 100644
index 00000000000..cd91ca1a17a
--- /dev/null
+++ b/tests/components/acaia/snapshots/test_button.ambr
@@ -0,0 +1,139 @@
+# serializer version: 1
+# name: test_buttons[button.lunar_ddeeff_reset_timer-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.lunar_ddeeff_reset_timer',
+ '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': 'Reset timer',
+ 'platform': 'acaia',
+ 'previous_unique_id': None,
+ 'supported_features': 0,
+ 'translation_key': 'reset_timer',
+ 'unique_id': 'aa:bb:cc:dd:ee:ff_reset_timer',
+ 'unit_of_measurement': None,
+ })
+# ---
+# name: test_buttons[button.lunar_ddeeff_reset_timer-state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ 'friendly_name': 'LUNAR-DDEEFF Reset timer',
+ }),
+ 'context': ,
+ 'entity_id': 'button.lunar_ddeeff_reset_timer',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': 'unknown',
+ })
+# ---
+# name: test_buttons[button.lunar_ddeeff_start_stop_timer-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.lunar_ddeeff_start_stop_timer',
+ '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': 'Start/stop timer',
+ 'platform': 'acaia',
+ 'previous_unique_id': None,
+ 'supported_features': 0,
+ 'translation_key': 'start_stop',
+ 'unique_id': 'aa:bb:cc:dd:ee:ff_start_stop',
+ 'unit_of_measurement': None,
+ })
+# ---
+# name: test_buttons[button.lunar_ddeeff_start_stop_timer-state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ 'friendly_name': 'LUNAR-DDEEFF Start/stop timer',
+ }),
+ 'context': ,
+ 'entity_id': 'button.lunar_ddeeff_start_stop_timer',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': 'unknown',
+ })
+# ---
+# name: test_buttons[button.lunar_ddeeff_tare-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.lunar_ddeeff_tare',
+ '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': 'Tare',
+ 'platform': 'acaia',
+ 'previous_unique_id': None,
+ 'supported_features': 0,
+ 'translation_key': 'tare',
+ 'unique_id': 'aa:bb:cc:dd:ee:ff_tare',
+ 'unit_of_measurement': None,
+ })
+# ---
+# name: test_buttons[button.lunar_ddeeff_tare-state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ 'friendly_name': 'LUNAR-DDEEFF Tare',
+ }),
+ 'context': ,
+ 'entity_id': 'button.lunar_ddeeff_tare',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': 'unknown',
+ })
+# ---
diff --git a/tests/components/acaia/snapshots/test_diagnostics.ambr b/tests/components/acaia/snapshots/test_diagnostics.ambr
new file mode 100644
index 00000000000..df5e4d36555
--- /dev/null
+++ b/tests/components/acaia/snapshots/test_diagnostics.ambr
@@ -0,0 +1,16 @@
+# serializer version: 1
+# name: test_diagnostics
+ dict({
+ 'device_state': dict({
+ 'auto_off_time': 0,
+ 'battery_level': 42,
+ 'beeps': True,
+ 'units': 'ounces',
+ }),
+ 'last_disconnect_time': '1732181388.1895587',
+ 'mac': 'aa:bb:cc:dd:ee:ff',
+ 'model': 'Lunar',
+ 'timer': 23,
+ 'weight': 123.45,
+ })
+# ---
diff --git a/tests/components/acaia/snapshots/test_init.ambr b/tests/components/acaia/snapshots/test_init.ambr
new file mode 100644
index 00000000000..7011b20f68c
--- /dev/null
+++ b/tests/components/acaia/snapshots/test_init.ambr
@@ -0,0 +1,37 @@
+# serializer version: 1
+# name: test_device
+ DeviceRegistryEntrySnapshot({
+ 'area_id': 'kitchen',
+ 'config_entries': ,
+ 'configuration_url': None,
+ 'connections': set({
+ tuple(
+ 'bluetooth',
+ 'aa:bb:cc:dd:ee:ff',
+ ),
+ }),
+ 'disabled_by': None,
+ 'entry_type': None,
+ 'hw_version': None,
+ 'id': ,
+ 'identifiers': set({
+ tuple(
+ 'acaia',
+ 'aa:bb:cc:dd:ee:ff',
+ ),
+ }),
+ 'is_new': False,
+ 'labels': set({
+ }),
+ 'manufacturer': 'Acaia',
+ 'model': 'Lunar',
+ 'model_id': None,
+ 'name': 'LUNAR-DDEEFF',
+ 'name_by_user': None,
+ 'primary_config_entry': ,
+ 'serial_number': None,
+ 'suggested_area': 'Kitchen',
+ 'sw_version': None,
+ 'via_device_id': None,
+ })
+# ---
diff --git a/tests/components/acaia/snapshots/test_sensor.ambr b/tests/components/acaia/snapshots/test_sensor.ambr
new file mode 100644
index 00000000000..c3c8ce966ee
--- /dev/null
+++ b/tests/components/acaia/snapshots/test_sensor.ambr
@@ -0,0 +1,157 @@
+# serializer version: 1
+# name: test_sensors[sensor.lunar_ddeeff_battery-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.lunar_ddeeff_battery',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'options': dict({
+ }),
+ 'original_device_class': ,
+ 'original_icon': None,
+ 'original_name': 'Battery',
+ 'platform': 'acaia',
+ 'previous_unique_id': None,
+ 'supported_features': 0,
+ 'translation_key': None,
+ 'unique_id': 'aa:bb:cc:dd:ee:ff_battery',
+ 'unit_of_measurement': '%',
+ })
+# ---
+# name: test_sensors[sensor.lunar_ddeeff_battery-state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ 'device_class': 'battery',
+ 'friendly_name': 'LUNAR-DDEEFF Battery',
+ 'state_class': ,
+ 'unit_of_measurement': '%',
+ }),
+ 'context': ,
+ 'entity_id': 'sensor.lunar_ddeeff_battery',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': '42',
+ })
+# ---
+# name: test_sensors[sensor.lunar_ddeeff_volume_flow_rate-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.lunar_ddeeff_volume_flow_rate',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'options': dict({
+ 'sensor': dict({
+ 'suggested_display_precision': 1,
+ }),
+ }),
+ 'original_device_class': ,
+ 'original_icon': None,
+ 'original_name': 'Volume flow rate',
+ 'platform': 'acaia',
+ 'previous_unique_id': None,
+ 'supported_features': 0,
+ 'translation_key': None,
+ 'unique_id': 'aa:bb:cc:dd:ee:ff_flow_rate',
+ 'unit_of_measurement': ,
+ })
+# ---
+# name: test_sensors[sensor.lunar_ddeeff_volume_flow_rate-state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ 'device_class': 'volume_flow_rate',
+ 'friendly_name': 'LUNAR-DDEEFF Volume flow rate',
+ 'state_class': ,
+ 'unit_of_measurement': ,
+ }),
+ 'context': ,
+ 'entity_id': 'sensor.lunar_ddeeff_volume_flow_rate',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': '1.23',
+ })
+# ---
+# name: test_sensors[sensor.lunar_ddeeff_weight-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.lunar_ddeeff_weight',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'options': dict({
+ }),
+ 'original_device_class': ,
+ 'original_icon': None,
+ 'original_name': 'Weight',
+ 'platform': 'acaia',
+ 'previous_unique_id': None,
+ 'supported_features': 0,
+ 'translation_key': None,
+ 'unique_id': 'aa:bb:cc:dd:ee:ff_weight',
+ 'unit_of_measurement': ,
+ })
+# ---
+# name: test_sensors[sensor.lunar_ddeeff_weight-state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ 'device_class': 'weight',
+ 'friendly_name': 'LUNAR-DDEEFF Weight',
+ 'state_class': ,
+ 'unit_of_measurement': ,
+ }),
+ 'context': ,
+ 'entity_id': 'sensor.lunar_ddeeff_weight',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': '123.45',
+ })
+# ---
diff --git a/tests/components/acaia/test_binary_sensor.py b/tests/components/acaia/test_binary_sensor.py
new file mode 100644
index 00000000000..a7aa7034d8d
--- /dev/null
+++ b/tests/components/acaia/test_binary_sensor.py
@@ -0,0 +1,28 @@
+"""Test binary sensors for acaia integration."""
+
+from unittest.mock import MagicMock, patch
+
+from syrupy import SnapshotAssertion
+
+from homeassistant.const import Platform
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers import entity_registry as er
+
+from . import setup_integration
+
+from tests.common import MockConfigEntry, snapshot_platform
+
+
+async def test_binary_sensors(
+ hass: HomeAssistant,
+ entity_registry: er.EntityRegistry,
+ snapshot: SnapshotAssertion,
+ mock_scale: MagicMock,
+ mock_config_entry: MockConfigEntry,
+) -> None:
+ """Test the acaia binary sensors."""
+
+ with patch("homeassistant.components.acaia.PLATFORMS", [Platform.BINARY_SENSOR]):
+ await setup_integration(hass, mock_config_entry)
+
+ await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
diff --git a/tests/components/acaia/test_button.py b/tests/components/acaia/test_button.py
new file mode 100644
index 00000000000..f68f85e253d
--- /dev/null
+++ b/tests/components/acaia/test_button.py
@@ -0,0 +1,90 @@
+"""Tests for the acaia buttons."""
+
+from datetime import timedelta
+from unittest.mock import MagicMock, patch
+
+from freezegun.api import FrozenDateTimeFactory
+from syrupy import SnapshotAssertion
+
+from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS
+from homeassistant.const import (
+ ATTR_ENTITY_ID,
+ STATE_UNAVAILABLE,
+ STATE_UNKNOWN,
+ Platform,
+)
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers import entity_registry as er
+
+from . import setup_integration
+
+from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
+
+BUTTONS = (
+ "tare",
+ "reset_timer",
+ "start_stop_timer",
+)
+
+
+async def test_buttons(
+ hass: HomeAssistant,
+ entity_registry: er.EntityRegistry,
+ snapshot: SnapshotAssertion,
+ mock_scale: MagicMock,
+ mock_config_entry: MockConfigEntry,
+) -> None:
+ """Test the acaia buttons."""
+
+ with patch("homeassistant.components.acaia.PLATFORMS", [Platform.BUTTON]):
+ await setup_integration(hass, mock_config_entry)
+ await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
+
+
+async def test_button_presses(
+ hass: HomeAssistant,
+ mock_scale: MagicMock,
+ mock_config_entry: MockConfigEntry,
+) -> None:
+ """Test the acaia button presses."""
+
+ await setup_integration(hass, mock_config_entry)
+
+ for button in BUTTONS:
+ await hass.services.async_call(
+ BUTTON_DOMAIN,
+ SERVICE_PRESS,
+ {
+ ATTR_ENTITY_ID: f"button.lunar_ddeeff_{button}",
+ },
+ blocking=True,
+ )
+
+ function = getattr(mock_scale, button)
+ function.assert_called_once()
+
+
+async def test_buttons_unavailable_on_disconnected_scale(
+ hass: HomeAssistant,
+ mock_scale: MagicMock,
+ mock_config_entry: MockConfigEntry,
+ freezer: FrozenDateTimeFactory,
+) -> None:
+ """Test the acaia buttons are unavailable when the scale is disconnected."""
+
+ await setup_integration(hass, mock_config_entry)
+
+ for button in BUTTONS:
+ state = hass.states.get(f"button.lunar_ddeeff_{button}")
+ assert state
+ assert state.state == STATE_UNKNOWN
+
+ mock_scale.connected = False
+ freezer.tick(timedelta(minutes=10))
+ async_fire_time_changed(hass)
+ await hass.async_block_till_done()
+
+ for button in BUTTONS:
+ state = hass.states.get(f"button.lunar_ddeeff_{button}")
+ assert state
+ assert state.state == STATE_UNAVAILABLE
diff --git a/tests/components/acaia/test_config_flow.py b/tests/components/acaia/test_config_flow.py
new file mode 100644
index 00000000000..2bf4b1dbe8a
--- /dev/null
+++ b/tests/components/acaia/test_config_flow.py
@@ -0,0 +1,242 @@
+"""Test the acaia config flow."""
+
+from collections.abc import Generator
+from unittest.mock import AsyncMock, patch
+
+from aioacaia.exceptions import AcaiaDeviceNotFound, AcaiaError, AcaiaUnknownDevice
+import pytest
+
+from homeassistant.components.acaia.const import CONF_IS_NEW_STYLE_SCALE, DOMAIN
+from homeassistant.config_entries import SOURCE_BLUETOOTH, SOURCE_USER
+from homeassistant.const import CONF_ADDRESS
+from homeassistant.core import HomeAssistant
+from homeassistant.data_entry_flow import FlowResultType
+from homeassistant.helpers.service_info.bluetooth import BluetoothServiceInfo
+
+from tests.common import MockConfigEntry
+
+service_info = BluetoothServiceInfo(
+ name="LUNAR-DDEEFF",
+ address="aa:bb:cc:dd:ee:ff",
+ rssi=-63,
+ manufacturer_data={},
+ service_data={},
+ service_uuids=[],
+ source="local",
+)
+
+
+@pytest.fixture
+def mock_discovered_service_info() -> Generator[AsyncMock]:
+ """Override getting Bluetooth service info."""
+ with patch(
+ "homeassistant.components.acaia.config_flow.async_discovered_service_info",
+ return_value=[service_info],
+ ) as mock_discovered_service_info:
+ yield mock_discovered_service_info
+
+
+async def test_form(
+ hass: HomeAssistant,
+ mock_setup_entry: AsyncMock,
+ mock_verify: AsyncMock,
+ mock_discovered_service_info: AsyncMock,
+) -> None:
+ """Test we get the form."""
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_USER}
+ )
+ assert result["type"] is FlowResultType.FORM
+ assert result["step_id"] == "user"
+
+ user_input = {
+ CONF_ADDRESS: "aa:bb:cc:dd:ee:ff",
+ }
+ result2 = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ user_input=user_input,
+ )
+
+ assert result2["type"] is FlowResultType.CREATE_ENTRY
+ assert result2["title"] == "LUNAR-DDEEFF"
+ assert result2["data"] == {
+ **user_input,
+ CONF_IS_NEW_STYLE_SCALE: True,
+ }
+
+
+async def test_bluetooth_discovery(
+ hass: HomeAssistant,
+ mock_setup_entry: AsyncMock,
+ mock_verify: AsyncMock,
+) -> None:
+ """Test we can discover a device."""
+
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_BLUETOOTH}, data=service_info
+ )
+
+ assert result["type"] is FlowResultType.FORM
+ assert result["step_id"] == "bluetooth_confirm"
+
+ result2 = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ user_input={},
+ )
+
+ assert result2["type"] is FlowResultType.CREATE_ENTRY
+ assert result2["title"] == service_info.name
+ assert result2["data"] == {
+ CONF_ADDRESS: service_info.address,
+ CONF_IS_NEW_STYLE_SCALE: True,
+ }
+
+
+@pytest.mark.parametrize(
+ ("exception", "error"),
+ [
+ (AcaiaDeviceNotFound("Error"), "device_not_found"),
+ (AcaiaError, "unknown"),
+ (AcaiaUnknownDevice, "unsupported_device"),
+ ],
+)
+async def test_bluetooth_discovery_errors(
+ hass: HomeAssistant,
+ mock_verify: AsyncMock,
+ exception: Exception,
+ error: str,
+) -> None:
+ """Test abortions of Bluetooth discovery."""
+ mock_verify.side_effect = exception
+
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_BLUETOOTH}, data=service_info
+ )
+
+ assert result["type"] is FlowResultType.ABORT
+ assert result["reason"] == error
+
+
+async def test_already_configured(
+ hass: HomeAssistant,
+ mock_config_entry: MockConfigEntry,
+ mock_verify: AsyncMock,
+ mock_discovered_service_info: AsyncMock,
+) -> None:
+ """Ensure we can't add the same device twice."""
+
+ mock_config_entry.add_to_hass(hass)
+
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_USER}
+ )
+ assert result["type"] is FlowResultType.FORM
+
+ result2 = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {
+ CONF_ADDRESS: "aa:bb:cc:dd:ee:ff",
+ },
+ )
+ await hass.async_block_till_done()
+
+ assert result2["type"] is FlowResultType.ABORT
+ assert result2["reason"] == "already_configured"
+
+
+async def test_already_configured_bluetooth_discovery(
+ hass: HomeAssistant,
+ mock_config_entry: MockConfigEntry,
+) -> None:
+ """Ensure configure device is not discovered again."""
+
+ mock_config_entry.add_to_hass(hass)
+
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_BLUETOOTH}, data=service_info
+ )
+
+ assert result["type"] is FlowResultType.ABORT
+ assert result["reason"] == "already_configured"
+
+
+@pytest.mark.parametrize(
+ ("exception", "error"),
+ [
+ (AcaiaDeviceNotFound("Error"), "device_not_found"),
+ (AcaiaError, "unknown"),
+ ],
+)
+async def test_recoverable_config_flow_errors(
+ hass: HomeAssistant,
+ mock_setup_entry: AsyncMock,
+ mock_verify: AsyncMock,
+ mock_discovered_service_info: AsyncMock,
+ exception: Exception,
+ error: str,
+) -> None:
+ """Test recoverable errors."""
+ mock_verify.side_effect = exception
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_USER}
+ )
+ assert result["type"] is FlowResultType.FORM
+
+ result2 = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {
+ CONF_ADDRESS: "aa:bb:cc:dd:ee:ff",
+ },
+ )
+
+ assert result2["type"] is FlowResultType.FORM
+ assert result2["errors"] == {"base": error}
+
+ # recover
+ mock_verify.side_effect = None
+ result3 = await hass.config_entries.flow.async_configure(
+ result2["flow_id"],
+ {
+ CONF_ADDRESS: "aa:bb:cc:dd:ee:ff",
+ },
+ )
+ assert result3["type"] is FlowResultType.CREATE_ENTRY
+
+
+async def test_unsupported_device(
+ hass: HomeAssistant,
+ mock_setup_entry: AsyncMock,
+ mock_verify: AsyncMock,
+ mock_discovered_service_info: AsyncMock,
+) -> None:
+ """Test flow aborts on unsupported device."""
+ mock_verify.side_effect = AcaiaUnknownDevice
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_USER}
+ )
+ assert result["type"] is FlowResultType.FORM
+
+ result2 = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {
+ CONF_ADDRESS: "aa:bb:cc:dd:ee:ff",
+ },
+ )
+
+ assert result2["type"] is FlowResultType.ABORT
+ assert result2["reason"] == "unsupported_device"
+
+
+async def test_no_bluetooth_devices(
+ hass: HomeAssistant,
+ mock_setup_entry: AsyncMock,
+ mock_discovered_service_info: AsyncMock,
+) -> None:
+ """Test flow aborts on unsupported device."""
+ mock_discovered_service_info.return_value = []
+
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_USER}
+ )
+ assert result["type"] is FlowResultType.ABORT
+ assert result["reason"] == "no_devices_found"
diff --git a/tests/components/acaia/test_diagnostics.py b/tests/components/acaia/test_diagnostics.py
new file mode 100644
index 00000000000..77f6306b068
--- /dev/null
+++ b/tests/components/acaia/test_diagnostics.py
@@ -0,0 +1,22 @@
+"""Tests for the diagnostics data provided by the Acaia integration."""
+
+from syrupy import SnapshotAssertion
+
+from homeassistant.core import HomeAssistant
+
+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,
+ init_integration: MockConfigEntry,
+ snapshot: SnapshotAssertion,
+) -> None:
+ """Test diagnostics."""
+ assert (
+ await get_diagnostics_for_config_entry(hass, hass_client, init_integration)
+ == snapshot
+ )
diff --git a/tests/components/acaia/test_init.py b/tests/components/acaia/test_init.py
new file mode 100644
index 00000000000..8ad988d3b9b
--- /dev/null
+++ b/tests/components/acaia/test_init.py
@@ -0,0 +1,65 @@
+"""Test init of acaia integration."""
+
+from datetime import timedelta
+from unittest.mock import MagicMock
+
+from aioacaia.exceptions import AcaiaDeviceNotFound, AcaiaError
+from freezegun.api import FrozenDateTimeFactory
+import pytest
+from syrupy import SnapshotAssertion
+
+from homeassistant.components.acaia.const import DOMAIN
+from homeassistant.config_entries import ConfigEntryState
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers import device_registry as dr
+
+from tests.common import MockConfigEntry, async_fire_time_changed
+
+pytestmark = pytest.mark.usefixtures("init_integration")
+
+
+async def test_load_unload_config_entry(
+ hass: HomeAssistant,
+ mock_config_entry: MockConfigEntry,
+) -> None:
+ """Test loading and unloading the integration."""
+
+ assert mock_config_entry.state is ConfigEntryState.LOADED
+
+ await hass.config_entries.async_unload(mock_config_entry.entry_id)
+ await hass.async_block_till_done()
+
+ assert mock_config_entry.state is ConfigEntryState.NOT_LOADED
+
+
+@pytest.mark.parametrize(
+ "exception", [AcaiaError, AcaiaDeviceNotFound("Boom"), TimeoutError]
+)
+async def test_update_exception_leads_to_active_disconnect(
+ hass: HomeAssistant,
+ mock_scale: MagicMock,
+ freezer: FrozenDateTimeFactory,
+ exception: Exception,
+) -> None:
+ """Test scale gets disconnected on exception."""
+
+ mock_scale.connect.side_effect = exception
+ mock_scale.connected = False
+
+ freezer.tick(timedelta(minutes=10))
+ async_fire_time_changed(hass)
+ await hass.async_block_till_done()
+
+ mock_scale.device_disconnected_handler.assert_called_once()
+
+
+async def test_device(
+ mock_scale: MagicMock,
+ device_registry: dr.DeviceRegistry,
+ snapshot: SnapshotAssertion,
+) -> None:
+ """Snapshot the device from registry."""
+
+ device = device_registry.async_get_device({(DOMAIN, mock_scale.mac)})
+ assert device
+ assert device == snapshot
diff --git a/tests/components/acaia/test_sensor.py b/tests/components/acaia/test_sensor.py
new file mode 100644
index 00000000000..2f5a851121c
--- /dev/null
+++ b/tests/components/acaia/test_sensor.py
@@ -0,0 +1,63 @@
+"""Test sensors for acaia integration."""
+
+from unittest.mock import MagicMock, patch
+
+from syrupy import SnapshotAssertion
+
+from homeassistant.const import PERCENTAGE, Platform
+from homeassistant.core import HomeAssistant, State
+from homeassistant.helpers import entity_registry as er
+
+from . import setup_integration
+
+from tests.common import (
+ MockConfigEntry,
+ mock_restore_cache_with_extra_data,
+ snapshot_platform,
+)
+
+
+async def test_sensors(
+ hass: HomeAssistant,
+ mock_scale: MagicMock,
+ entity_registry: er.EntityRegistry,
+ snapshot: SnapshotAssertion,
+ mock_config_entry: MockConfigEntry,
+) -> None:
+ """Test the Acaia sensors."""
+ with patch("homeassistant.components.acaia.PLATFORMS", [Platform.SENSOR]):
+ await setup_integration(hass, mock_config_entry)
+
+ await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
+
+
+async def test_restore_state(
+ hass: HomeAssistant,
+ mock_scale: MagicMock,
+ mock_config_entry: MockConfigEntry,
+) -> None:
+ """Test battery sensor restore state."""
+ mock_scale.device_state = None
+ entity_id = "sensor.lunar_ddeeff_battery"
+
+ mock_restore_cache_with_extra_data(
+ hass,
+ (
+ (
+ State(
+ entity_id,
+ "1",
+ ),
+ {
+ "native_value": 65,
+ "native_unit_of_measurement": PERCENTAGE,
+ },
+ ),
+ ),
+ )
+
+ await setup_integration(hass, mock_config_entry)
+
+ state = hass.states.get(entity_id)
+ assert state
+ assert state.state == "65"
diff --git a/tests/components/adguard/__init__.py b/tests/components/adguard/__init__.py
index 318e881ef2f..4d8ae091dc5 100644
--- a/tests/components/adguard/__init__.py
+++ b/tests/components/adguard/__init__.py
@@ -1 +1 @@
-"""Tests for the AdGuard Home component."""
+"""Tests for the AdGuard Home integration."""
diff --git a/tests/components/adguard/test_config_flow.py b/tests/components/adguard/test_config_flow.py
index 6644a4ca20f..bd0f1b0a08f 100644
--- a/tests/components/adguard/test_config_flow.py
+++ b/tests/components/adguard/test_config_flow.py
@@ -59,9 +59,9 @@ async def test_connection_error(
)
assert result
- assert result.get("type") is FlowResultType.FORM
- assert result.get("step_id") == "user"
- assert result.get("errors") == {"base": "cannot_connect"}
+ assert result["type"] is FlowResultType.FORM
+ assert result["step_id"] == "user"
+ assert result["errors"] == {"base": "cannot_connect"}
async def test_full_flow_implementation(
@@ -83,25 +83,27 @@ async def test_full_flow_implementation(
)
assert result
- assert result.get("flow_id")
- assert result.get("type") is FlowResultType.FORM
- assert result.get("step_id") == "user"
+ assert result["flow_id"]
+ assert result["type"] is FlowResultType.FORM
+ assert result["step_id"] == "user"
- result2 = await hass.config_entries.flow.async_configure(
+ result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input=FIXTURE_USER_INPUT
)
- assert result2
- assert result2.get("type") is FlowResultType.CREATE_ENTRY
- assert result2.get("title") == FIXTURE_USER_INPUT[CONF_HOST]
+ assert result
+ assert result["type"] is FlowResultType.CREATE_ENTRY
- data = result2.get("data")
- assert data
- assert data[CONF_HOST] == FIXTURE_USER_INPUT[CONF_HOST]
- assert data[CONF_PASSWORD] == FIXTURE_USER_INPUT[CONF_PASSWORD]
- assert data[CONF_PORT] == FIXTURE_USER_INPUT[CONF_PORT]
- assert data[CONF_SSL] == FIXTURE_USER_INPUT[CONF_SSL]
- assert data[CONF_USERNAME] == FIXTURE_USER_INPUT[CONF_USERNAME]
- assert data[CONF_VERIFY_SSL] == FIXTURE_USER_INPUT[CONF_VERIFY_SSL]
+ config_entry = result["result"]
+ assert config_entry.title == FIXTURE_USER_INPUT[CONF_HOST]
+ assert config_entry.data == {
+ CONF_HOST: FIXTURE_USER_INPUT[CONF_HOST],
+ CONF_PASSWORD: FIXTURE_USER_INPUT[CONF_PASSWORD],
+ CONF_PORT: FIXTURE_USER_INPUT[CONF_PORT],
+ CONF_SSL: FIXTURE_USER_INPUT[CONF_SSL],
+ CONF_USERNAME: FIXTURE_USER_INPUT[CONF_USERNAME],
+ CONF_VERIFY_SSL: FIXTURE_USER_INPUT[CONF_VERIFY_SSL],
+ }
+ assert not config_entry.options
async def test_integration_already_exists(hass: HomeAssistant) -> None:
@@ -116,8 +118,8 @@ async def test_integration_already_exists(hass: HomeAssistant) -> None:
context={"source": config_entries.SOURCE_USER},
)
assert result
- assert result.get("type") is FlowResultType.ABORT
- assert result.get("reason") == "already_configured"
+ assert result["type"] is FlowResultType.ABORT
+ assert result["reason"] == "already_configured"
async def test_hassio_already_configured(hass: HomeAssistant) -> None:
@@ -141,8 +143,8 @@ async def test_hassio_already_configured(hass: HomeAssistant) -> None:
context={"source": config_entries.SOURCE_HASSIO},
)
assert result
- assert result.get("type") is FlowResultType.ABORT
- assert result.get("reason") == "already_configured"
+ assert result["type"] is FlowResultType.ABORT
+ assert result["reason"] == "already_configured"
async def test_hassio_ignored(hass: HomeAssistant) -> None:
@@ -166,8 +168,8 @@ async def test_hassio_ignored(hass: HomeAssistant) -> None:
context={"source": config_entries.SOURCE_HASSIO},
)
assert result
- assert result.get("type") is FlowResultType.ABORT
- assert result.get("reason") == "already_configured"
+ assert result["type"] is FlowResultType.ABORT
+ assert result["reason"] == "already_configured"
async def test_hassio_confirm(
@@ -195,24 +197,25 @@ async def test_hassio_confirm(
context={"source": config_entries.SOURCE_HASSIO},
)
assert result
- assert result.get("type") is FlowResultType.FORM
- assert result.get("step_id") == "hassio_confirm"
- assert result.get("description_placeholders") == {"addon": "AdGuard Home Addon"}
+ assert result["type"] is FlowResultType.FORM
+ assert result["step_id"] == "hassio_confirm"
+ assert result["description_placeholders"] == {"addon": "AdGuard Home Addon"}
- result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {})
+ result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
- assert result2
- assert result2.get("type") is FlowResultType.CREATE_ENTRY
- assert result2.get("title") == "AdGuard Home Addon"
+ assert result
+ assert result["type"] is FlowResultType.CREATE_ENTRY
- data = result2.get("data")
- assert data
- assert data[CONF_HOST] == "mock-adguard"
- assert data[CONF_PASSWORD] is None
- assert data[CONF_PORT] == 3000
- assert data[CONF_SSL] is False
- assert data[CONF_USERNAME] is None
- assert data[CONF_VERIFY_SSL]
+ config_entry = result["result"]
+ assert config_entry.title == "AdGuard Home Addon"
+ assert config_entry.data == {
+ CONF_HOST: "mock-adguard",
+ CONF_PASSWORD: None,
+ CONF_PORT: 3000,
+ CONF_SSL: False,
+ CONF_USERNAME: None,
+ CONF_VERIFY_SSL: True,
+ }
async def test_hassio_connection_error(
@@ -241,6 +244,6 @@ async def test_hassio_connection_error(
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
assert result
- assert result.get("type") is FlowResultType.FORM
- assert result.get("step_id") == "hassio_confirm"
- assert result.get("errors") == {"base": "cannot_connect"}
+ assert result["type"] is FlowResultType.FORM
+ assert result["step_id"] == "hassio_confirm"
+ assert result["errors"] == {"base": "cannot_connect"}
diff --git a/tests/components/aemet/snapshots/test_diagnostics.ambr b/tests/components/aemet/snapshots/test_diagnostics.ambr
index 54546507dfa..0e40cce1b86 100644
--- a/tests/components/aemet/snapshots/test_diagnostics.ambr
+++ b/tests/components/aemet/snapshots/test_diagnostics.ambr
@@ -17,6 +17,7 @@
'entry_id': '7442b231f139e813fc1939281123f220',
'minor_version': 1,
'options': dict({
+ 'radar_updates': True,
}),
'pref_disable_new_entities': False,
'pref_disable_polling': False,
@@ -33,6 +34,12 @@
]),
}),
'lib': dict({
+ 'radar': dict({
+ 'datetime': '2021-01-09T11:34:06.448809+00:00',
+ 'id': 'national',
+ 'image-bytes': '**REDACTED**',
+ 'image-type': 'image/gif',
+ }),
'station': dict({
'altitude': 667.0,
'coordinates': '**REDACTED**',
diff --git a/tests/components/aemet/test_config_flow.py b/tests/components/aemet/test_config_flow.py
index 0f3491b1c43..3dd8303c8cb 100644
--- a/tests/components/aemet/test_config_flow.py
+++ b/tests/components/aemet/test_config_flow.py
@@ -6,7 +6,11 @@ from aemet_opendata.exceptions import AuthError
from freezegun.api import FrozenDateTimeFactory
import pytest
-from homeassistant.components.aemet.const import CONF_STATION_UPDATES, DOMAIN
+from homeassistant.components.aemet.const import (
+ CONF_RADAR_UPDATES,
+ CONF_STATION_UPDATES,
+ DOMAIN,
+)
from homeassistant.config_entries import SOURCE_USER, ConfigEntryState
from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME
from homeassistant.core import HomeAssistant
@@ -61,13 +65,20 @@ async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None:
@pytest.mark.parametrize(
- ("user_input", "expected"), [({}, True), ({CONF_STATION_UPDATES: False}, False)]
+ ("user_input", "expected"),
+ [
+ ({}, {CONF_RADAR_UPDATES: False, CONF_STATION_UPDATES: True}),
+ (
+ {CONF_RADAR_UPDATES: False, CONF_STATION_UPDATES: False},
+ {CONF_RADAR_UPDATES: False, CONF_STATION_UPDATES: False},
+ ),
+ ],
)
async def test_form_options(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
user_input: dict[str, bool],
- expected: bool,
+ expected: dict[str, bool],
) -> None:
"""Test the form options."""
@@ -98,7 +109,8 @@ async def test_form_options(
assert result["type"] is FlowResultType.CREATE_ENTRY
assert entry.options == {
- CONF_STATION_UPDATES: expected,
+ CONF_RADAR_UPDATES: expected[CONF_RADAR_UPDATES],
+ CONF_STATION_UPDATES: expected[CONF_STATION_UPDATES],
}
await hass.async_block_till_done()
diff --git a/tests/components/aemet/test_image.py b/tests/components/aemet/test_image.py
new file mode 100644
index 00000000000..4321daac883
--- /dev/null
+++ b/tests/components/aemet/test_image.py
@@ -0,0 +1,22 @@
+"""The image tests for the AEMET OpenData platform."""
+
+from freezegun.api import FrozenDateTimeFactory
+
+from homeassistant.core import HomeAssistant
+
+from .util import async_init_integration
+
+
+async def test_aemet_create_images(
+ hass: HomeAssistant,
+ freezer: FrozenDateTimeFactory,
+) -> None:
+ """Test creation of AEMET images."""
+
+ await hass.config.async_set_time_zone("UTC")
+ freezer.move_to("2021-01-09 12:00:00+00:00")
+ await async_init_integration(hass)
+
+ state = hass.states.get("image.aemet_weather_radar")
+ assert state is not None
+ assert state.state == "2021-01-09T11:34:06.448809+00:00"
diff --git a/tests/components/aemet/test_init.py b/tests/components/aemet/test_init.py
index cf3204782cd..d6229438582 100644
--- a/tests/components/aemet/test_init.py
+++ b/tests/components/aemet/test_init.py
@@ -9,6 +9,7 @@ from homeassistant.components.aemet.const import DOMAIN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME
from homeassistant.core import HomeAssistant
+from homeassistant.helpers import entity_registry as er
from .util import mock_api_call
@@ -24,6 +25,7 @@ CONFIG = {
async def test_unload_entry(
hass: HomeAssistant,
+ entity_registry: er.EntityRegistry,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test (un)loading the AEMET integration."""
@@ -47,6 +49,12 @@ async def test_unload_entry(
await hass.async_block_till_done()
assert config_entry.state is ConfigEntryState.NOT_LOADED
+ assert await hass.config_entries.async_remove(config_entry.entry_id)
+ await hass.async_block_till_done()
+
+ assert hass.states.get("weather.aemet") is None
+ assert entity_registry.async_get("weather.aemet") is None
+
async def test_init_town_not_found(
hass: HomeAssistant,
diff --git a/tests/components/aemet/util.py b/tests/components/aemet/util.py
index 162ee657513..0361ca9e6d8 100644
--- a/tests/components/aemet/util.py
+++ b/tests/components/aemet/util.py
@@ -3,9 +3,9 @@
from typing import Any
from unittest.mock import patch
-from aemet_opendata.const import ATTR_DATA
+from aemet_opendata.const import ATTR_BYTES, ATTR_DATA, ATTR_TIMESTAMP, ATTR_TYPE
-from homeassistant.components.aemet.const import DOMAIN
+from homeassistant.components.aemet.const import CONF_RADAR_UPDATES, DOMAIN
from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME
from homeassistant.core import HomeAssistant
@@ -19,6 +19,14 @@ FORECAST_HOURLY_DATA_MOCK = {
ATTR_DATA: load_json_value_fixture("aemet/town-28065-forecast-hourly-data.json"),
}
+RADAR_DATA_MOCK = {
+ ATTR_DATA: {
+ ATTR_TYPE: "image/gif",
+ ATTR_BYTES: bytes([0]),
+ },
+ ATTR_TIMESTAMP: "2021-01-09T11:34:06.448809+00:00",
+}
+
STATION_DATA_MOCK = {
ATTR_DATA: load_json_value_fixture("aemet/station-3195-data.json"),
}
@@ -53,6 +61,9 @@ def mock_api_call(cmd: str, fetch_data: bool = False) -> dict[str, Any]:
return FORECAST_DAILY_DATA_MOCK
if cmd == "prediccion/especifica/municipio/horaria/28065":
return FORECAST_HOURLY_DATA_MOCK
+ if cmd == "red/radar/nacional":
+ return RADAR_DATA_MOCK
+
return {}
@@ -69,6 +80,9 @@ async def async_init_integration(hass: HomeAssistant):
},
entry_id="7442b231f139e813fc1939281123f220",
unique_id="40.30403754--3.72935236",
+ options={
+ CONF_RADAR_UPDATES: True,
+ },
)
config_entry.add_to_hass(hass)
diff --git a/tests/components/airgradient/test_config_flow.py b/tests/components/airgradient/test_config_flow.py
index 73dbd17a213..8927947c40e 100644
--- a/tests/components/airgradient/test_config_flow.py
+++ b/tests/components/airgradient/test_config_flow.py
@@ -255,6 +255,20 @@ async def test_zeroconf_flow_abort_old_firmware(hass: HomeAssistant) -> None:
assert result["reason"] == "invalid_version"
+async def test_zeroconf_flow_abort_duplicate(
+ hass: HomeAssistant, mock_config_entry: MockConfigEntry
+) -> None:
+ """Test zeroconf flow aborts with duplicate."""
+ mock_config_entry.add_to_hass(hass)
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": SOURCE_ZEROCONF},
+ data=ZEROCONF_DISCOVERY,
+ )
+ assert result["type"] is FlowResultType.ABORT
+ assert result["reason"] == "already_configured"
+
+
async def test_user_flow_works_discovery(
hass: HomeAssistant,
mock_new_airgradient_client: AsyncMock,
diff --git a/tests/components/alarm_control_panel/__init__.py b/tests/components/alarm_control_panel/__init__.py
index 1ef1161edd0..1f43c567844 100644
--- a/tests/components/alarm_control_panel/__init__.py
+++ b/tests/components/alarm_control_panel/__init__.py
@@ -1 +1,27 @@
"""The tests for Alarm control panel platforms."""
+
+from homeassistant.components.alarm_control_panel import (
+ DOMAIN as ALARM_CONTROL_PANEL_DOMAIN,
+)
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import Platform
+from homeassistant.core import HomeAssistant
+
+
+async def help_async_setup_entry_init(
+ hass: HomeAssistant, config_entry: ConfigEntry
+) -> bool:
+ """Set up test config entry."""
+ await hass.config_entries.async_forward_entry_setups(
+ config_entry, [ALARM_CONTROL_PANEL_DOMAIN]
+ )
+ return True
+
+
+async def help_async_unload_entry(
+ hass: HomeAssistant, config_entry: ConfigEntry
+) -> bool:
+ """Unload test config emntry."""
+ return await hass.config_entries.async_unload_platforms(
+ config_entry, [Platform.ALARM_CONTROL_PANEL]
+ )
diff --git a/tests/components/alarm_control_panel/conftest.py b/tests/components/alarm_control_panel/conftest.py
index 3e82b935493..ddf67b27860 100644
--- a/tests/components/alarm_control_panel/conftest.py
+++ b/tests/components/alarm_control_panel/conftest.py
@@ -1,7 +1,7 @@
"""Fixturs for Alarm Control Panel tests."""
-from collections.abc import Generator
-from unittest.mock import MagicMock
+from collections.abc import AsyncGenerator, Generator
+from unittest.mock import MagicMock, patch
import pytest
@@ -13,7 +13,7 @@ from homeassistant.components.alarm_control_panel import (
from homeassistant.components.alarm_control_panel.const import CodeFormat
from homeassistant.config_entries import ConfigEntry, ConfigFlow
from homeassistant.core import HomeAssistant
-from homeassistant.helpers import entity_registry as er
+from homeassistant.helpers import entity_registry as er, frame
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .common import MockAlarm
@@ -107,6 +107,22 @@ class MockFlow(ConfigFlow):
"""Test flow."""
+@pytest.fixture(name="mock_as_custom_component")
+async def mock_frame(hass: HomeAssistant) -> AsyncGenerator[None]:
+ """Mock frame."""
+ with patch(
+ "homeassistant.helpers.frame.get_integration_frame",
+ return_value=frame.IntegrationFrame(
+ custom_integration=True,
+ integration="alarm_control_panel",
+ module="test_init.py",
+ relative_filename="test_init.py",
+ frame=frame.get_current_frame(),
+ ),
+ ):
+ yield
+
+
@pytest.fixture(autouse=True)
def config_flow_fixture(hass: HomeAssistant) -> Generator[None]:
"""Mock config flow."""
diff --git a/tests/components/alarm_control_panel/test_init.py b/tests/components/alarm_control_panel/test_init.py
index 90b23f87ab1..168d7ecc269 100644
--- a/tests/components/alarm_control_panel/test_init.py
+++ b/tests/components/alarm_control_panel/test_init.py
@@ -1,6 +1,5 @@
"""Test for the alarm control panel const module."""
-from types import ModuleType
from typing import Any
from unittest.mock import patch
@@ -12,7 +11,6 @@ from homeassistant.components.alarm_control_panel import (
AlarmControlPanelEntityFeature,
CodeFormat,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
ATTR_CODE,
SERVICE_ALARM_ARM_AWAY,
@@ -25,20 +23,17 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceValidationError
-from homeassistant.helpers import entity_registry as er
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers import entity_registry as er, frame
from homeassistant.helpers.typing import UNDEFINED, UndefinedType
-from .conftest import TEST_DOMAIN, MockAlarmControlPanel
+from . import help_async_setup_entry_init, help_async_unload_entry
+from .conftest import MockAlarmControlPanel
from tests.common import (
MockConfigEntry,
MockModule,
- MockPlatform,
- help_test_all,
- import_and_test_deprecated_constant_enum,
mock_integration,
- mock_platform,
+ setup_test_component_platform,
)
@@ -59,76 +54,6 @@ async def help_test_async_alarm_control_panel_service(
await hass.async_block_till_done()
-@pytest.mark.parametrize(
- "module",
- [alarm_control_panel, alarm_control_panel.const],
-)
-def test_all(module: ModuleType) -> None:
- """Test module.__all__ is correctly set."""
- help_test_all(module)
-
-
-@pytest.mark.parametrize(
- "code_format",
- list(alarm_control_panel.CodeFormat),
-)
-@pytest.mark.parametrize(
- "module",
- [alarm_control_panel, alarm_control_panel.const],
-)
-def test_deprecated_constant_code_format(
- caplog: pytest.LogCaptureFixture,
- code_format: alarm_control_panel.CodeFormat,
- module: ModuleType,
-) -> None:
- """Test deprecated format constants."""
- import_and_test_deprecated_constant_enum(
- caplog, module, code_format, "FORMAT_", "2025.1"
- )
-
-
-@pytest.mark.parametrize(
- "entity_feature",
- list(alarm_control_panel.AlarmControlPanelEntityFeature),
-)
-@pytest.mark.parametrize(
- "module",
- [alarm_control_panel, alarm_control_panel.const],
-)
-def test_deprecated_support_alarm_constants(
- caplog: pytest.LogCaptureFixture,
- entity_feature: alarm_control_panel.AlarmControlPanelEntityFeature,
- module: ModuleType,
-) -> None:
- """Test deprecated support alarm constants."""
- import_and_test_deprecated_constant_enum(
- caplog, module, entity_feature, "SUPPORT_ALARM_", "2025.1"
- )
-
-
-def test_deprecated_supported_features_ints(caplog: pytest.LogCaptureFixture) -> None:
- """Test deprecated supported features ints."""
-
- class MockAlarmControlPanelEntity(alarm_control_panel.AlarmControlPanelEntity):
- _attr_supported_features = 1
-
- entity = MockAlarmControlPanelEntity()
- assert (
- entity.supported_features
- is alarm_control_panel.AlarmControlPanelEntityFeature(1)
- )
- assert "MockAlarmControlPanelEntity" in caplog.text
- assert "is using deprecated supported features values" in caplog.text
- assert "Instead it should use" in caplog.text
- assert "AlarmControlPanelEntityFeature.ARM_HOME" in caplog.text
- caplog.clear()
- assert (
- entity.supported_features
- is alarm_control_panel.AlarmControlPanelEntityFeature(1)
- )
- assert "is using deprecated supported features values" not in caplog.text
-
-
async def test_set_mock_alarm_control_panel_options(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
@@ -297,6 +222,7 @@ async def test_alarm_control_panel_with_default_code(
mock_alarm_control_panel_entity.calls_disarm.assert_called_with("1234")
+@patch.object(frame, "_REPORTED_INTEGRATIONS", set())
async def test_alarm_control_panel_not_log_deprecated_state_warning(
hass: HomeAssistant,
mock_alarm_control_panel_entity: MockAlarmControlPanel,
@@ -305,9 +231,14 @@ async def test_alarm_control_panel_not_log_deprecated_state_warning(
"""Test correctly using alarm_state doesn't log issue or raise repair."""
state = hass.states.get(mock_alarm_control_panel_entity.entity_id)
assert state is not None
- assert "Entities should implement the 'alarm_state' property and" not in caplog.text
+ assert (
+ "the 'alarm_state' property and return its state using the AlarmControlPanelState enum"
+ not in caplog.text
+ )
+@pytest.mark.usefixtures("mock_as_custom_component")
+@patch.object(frame, "_REPORTED_INTEGRATIONS", set())
async def test_alarm_control_panel_log_deprecated_state_warning_using_state_prop(
hass: HomeAssistant,
code_format: CodeFormat | None,
@@ -317,23 +248,6 @@ async def test_alarm_control_panel_log_deprecated_state_warning_using_state_prop
) -> None:
"""Test incorrectly using state property does log issue and raise repair."""
- async def async_setup_entry_init(
- hass: HomeAssistant, config_entry: ConfigEntry
- ) -> bool:
- """Set up test config entry."""
- await hass.config_entries.async_forward_entry_setups(
- config_entry, [ALARM_CONTROL_PANEL_DOMAIN]
- )
- return True
-
- mock_integration(
- hass,
- MockModule(
- TEST_DOMAIN,
- async_setup_entry=async_setup_entry_init,
- ),
- )
-
class MockLegacyAlarmControlPanel(MockAlarmControlPanel):
"""Mocked alarm control entity."""
@@ -358,37 +272,38 @@ async def test_alarm_control_panel_log_deprecated_state_warning_using_state_prop
code_format=code_format,
code_arm_required=code_arm_required,
)
-
- async def async_setup_entry_platform(
- hass: HomeAssistant,
- config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
- ) -> None:
- """Set up test alarm control panel platform via config entry."""
- async_add_entities([entity])
-
- mock_platform(
+ config_entry = MockConfigEntry(domain="test")
+ config_entry.add_to_hass(hass)
+ mock_integration(
hass,
- f"{TEST_DOMAIN}.{ALARM_CONTROL_PANEL_DOMAIN}",
- MockPlatform(async_setup_entry=async_setup_entry_platform),
+ MockModule(
+ "test",
+ async_setup_entry=help_async_setup_entry_init,
+ async_unload_entry=help_async_unload_entry,
+ ),
+ built_in=False,
)
-
- with patch.object(
- MockLegacyAlarmControlPanel,
- "__module__",
- "tests.custom_components.test.alarm_control_panel",
- ):
- config_entry = MockConfigEntry(domain=TEST_DOMAIN)
- config_entry.add_to_hass(hass)
- assert await hass.config_entries.async_setup(config_entry.entry_id)
- await hass.async_block_till_done()
+ setup_test_component_platform(
+ hass, ALARM_CONTROL_PANEL_DOMAIN, [entity], from_config_entry=True
+ )
+ assert await hass.config_entries.async_setup(config_entry.entry_id)
state = hass.states.get(entity.entity_id)
assert state is not None
- assert "Entities should implement the 'alarm_state' property and" in caplog.text
+ assert (
+ "Detected that custom integration 'alarm_control_panel' is setting state"
+ " directly. Entity None (.MockLegacyAlarmControlPanel'>) should implement"
+ " the 'alarm_state' property and return its state using the AlarmControlPanelState"
+ " enum at test_init.py, line 123: yield. This will stop working in Home Assistant"
+ " 2025.11, please create a bug report at" in caplog.text
+ )
+@pytest.mark.usefixtures("mock_as_custom_component")
+@patch.object(frame, "_REPORTED_INTEGRATIONS", set())
async def test_alarm_control_panel_log_deprecated_state_warning_using_attr_state_attr(
hass: HomeAssistant,
code_format: CodeFormat | None,
@@ -398,23 +313,6 @@ async def test_alarm_control_panel_log_deprecated_state_warning_using_attr_state
) -> None:
"""Test incorrectly using _attr_state attribute does log issue and raise repair."""
- async def async_setup_entry_init(
- hass: HomeAssistant, config_entry: ConfigEntry
- ) -> bool:
- """Set up test config entry."""
- await hass.config_entries.async_forward_entry_setups(
- config_entry, [ALARM_CONTROL_PANEL_DOMAIN]
- )
- return True
-
- mock_integration(
- hass,
- MockModule(
- TEST_DOMAIN,
- async_setup_entry=async_setup_entry_init,
- ),
- )
-
class MockLegacyAlarmControlPanel(MockAlarmControlPanel):
"""Mocked alarm control entity."""
@@ -438,54 +336,112 @@ async def test_alarm_control_panel_log_deprecated_state_warning_using_attr_state
code_format=code_format,
code_arm_required=code_arm_required,
)
-
- async def async_setup_entry_platform(
- hass: HomeAssistant,
- config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
- ) -> None:
- """Set up test alarm control panel platform via config entry."""
- async_add_entities([entity])
-
- mock_platform(
+ config_entry = MockConfigEntry(domain="test")
+ config_entry.add_to_hass(hass)
+ mock_integration(
hass,
- f"{TEST_DOMAIN}.{ALARM_CONTROL_PANEL_DOMAIN}",
- MockPlatform(async_setup_entry=async_setup_entry_platform),
+ MockModule(
+ "test",
+ async_setup_entry=help_async_setup_entry_init,
+ async_unload_entry=help_async_unload_entry,
+ ),
)
-
- with patch.object(
- MockLegacyAlarmControlPanel,
- "__module__",
- "tests.custom_components.test.alarm_control_panel",
- ):
- config_entry = MockConfigEntry(domain=TEST_DOMAIN)
- config_entry.add_to_hass(hass)
- assert await hass.config_entries.async_setup(config_entry.entry_id)
- await hass.async_block_till_done()
+ setup_test_component_platform(
+ hass, ALARM_CONTROL_PANEL_DOMAIN, [entity], from_config_entry=True
+ )
+ assert await hass.config_entries.async_setup(config_entry.entry_id)
state = hass.states.get(entity.entity_id)
assert state is not None
- assert "Entities should implement the 'alarm_state' property and" not in caplog.text
+ assert (
+ "Detected that custom integration 'alarm_control_panel' is setting state directly."
+ not in caplog.text
+ )
- with patch.object(
- MockLegacyAlarmControlPanel,
- "__module__",
- "tests.custom_components.test.alarm_control_panel",
- ):
- await help_test_async_alarm_control_panel_service(
- hass, entity.entity_id, SERVICE_ALARM_DISARM
- )
+ await help_test_async_alarm_control_panel_service(
+ hass, entity.entity_id, SERVICE_ALARM_DISARM
+ )
- assert "Entities should implement the 'alarm_state' property and" in caplog.text
+ assert (
+ "Detected that custom integration 'alarm_control_panel' is setting state directly."
+ " Entity alarm_control_panel.test_alarm_control_panel"
+ " (.MockLegacyAlarmControlPanel'>) should implement the 'alarm_state' property"
+ " and return its state using the AlarmControlPanelState enum at test_init.py, line 123:"
+ " yield. This will stop working in Home Assistant 2025.11,"
+ " please create a bug report at" in caplog.text
+ )
caplog.clear()
- with patch.object(
- MockLegacyAlarmControlPanel,
- "__module__",
- "tests.custom_components.test.alarm_control_panel",
- ):
- await help_test_async_alarm_control_panel_service(
- hass, entity.entity_id, SERVICE_ALARM_DISARM
- )
+ await help_test_async_alarm_control_panel_service(
+ hass, entity.entity_id, SERVICE_ALARM_DISARM
+ )
# Test we only log once
- assert "Entities should implement the 'alarm_state' property and" not in caplog.text
+ assert (
+ "Detected that custom integration 'alarm_control_panel' is setting state directly."
+ not in caplog.text
+ )
+
+
+@pytest.mark.usefixtures("mock_as_custom_component")
+@patch.object(frame, "_REPORTED_INTEGRATIONS", set())
+async def test_alarm_control_panel_deprecated_state_does_not_break_state(
+ hass: HomeAssistant,
+ code_format: CodeFormat | None,
+ supported_features: AlarmControlPanelEntityFeature,
+ code_arm_required: bool,
+ caplog: pytest.LogCaptureFixture,
+) -> None:
+ """Test using _attr_state attribute does not break state."""
+
+ class MockLegacyAlarmControlPanel(MockAlarmControlPanel):
+ """Mocked alarm control entity."""
+
+ def __init__(
+ self,
+ supported_features: AlarmControlPanelEntityFeature = AlarmControlPanelEntityFeature(
+ 0
+ ),
+ code_format: CodeFormat | None = None,
+ code_arm_required: bool = True,
+ ) -> None:
+ """Initialize the alarm control."""
+ self._attr_state = "armed_away"
+ super().__init__(supported_features, code_format, code_arm_required)
+
+ def alarm_disarm(self, code: str | None = None) -> None:
+ """Mock alarm disarm calls."""
+ self._attr_state = "disarmed"
+
+ entity = MockLegacyAlarmControlPanel(
+ supported_features=supported_features,
+ code_format=code_format,
+ code_arm_required=code_arm_required,
+ )
+ config_entry = MockConfigEntry(domain="test")
+ config_entry.add_to_hass(hass)
+ mock_integration(
+ hass,
+ MockModule(
+ "test",
+ async_setup_entry=help_async_setup_entry_init,
+ async_unload_entry=help_async_unload_entry,
+ ),
+ )
+ setup_test_component_platform(
+ hass, ALARM_CONTROL_PANEL_DOMAIN, [entity], from_config_entry=True
+ )
+ assert await hass.config_entries.async_setup(config_entry.entry_id)
+
+ state = hass.states.get(entity.entity_id)
+ assert state is not None
+ assert state.state == "armed_away"
+
+ await help_test_async_alarm_control_panel_service(
+ hass, entity.entity_id, SERVICE_ALARM_DISARM
+ )
+
+ state = hass.states.get(entity.entity_id)
+ assert state is not None
+ assert state.state == "disarmed"
diff --git a/tests/components/alexa/test_capabilities.py b/tests/components/alexa/test_capabilities.py
index a41c2f47b2d..b10a93df0c9 100644
--- a/tests/components/alexa/test_capabilities.py
+++ b/tests/components/alexa/test_capabilities.py
@@ -159,11 +159,11 @@ async def test_api_set_color_temperature(hass: HomeAssistant) -> None:
assert len(call_light) == 1
assert call_light[0].data["entity_id"] == "light.test"
- assert call_light[0].data["kelvin"] == 7500
+ assert call_light[0].data["color_temp_kelvin"] == 7500
assert msg["header"]["name"] == "Response"
-@pytest.mark.parametrize(("result", "initial"), [(383, "333"), (500, "500")])
+@pytest.mark.parametrize(("result", "initial"), [(2500, "3000"), (2000, "2000")])
async def test_api_decrease_color_temp(
hass: HomeAssistant, result: int, initial: str
) -> None:
@@ -176,7 +176,11 @@ async def test_api_decrease_color_temp(
hass.states.async_set(
"light.test",
"off",
- {"friendly_name": "Test light", "color_temp": initial, "max_mireds": 500},
+ {
+ "friendly_name": "Test light",
+ "color_temp_kelvin": initial,
+ "min_color_temp_kelvin": 2000,
+ },
)
call_light = async_mock_service(hass, "light", "turn_on")
@@ -189,11 +193,11 @@ async def test_api_decrease_color_temp(
assert len(call_light) == 1
assert call_light[0].data["entity_id"] == "light.test"
- assert call_light[0].data["color_temp"] == result
+ assert call_light[0].data["color_temp_kelvin"] == result
assert msg["header"]["name"] == "Response"
-@pytest.mark.parametrize(("result", "initial"), [(283, "333"), (142, "142")])
+@pytest.mark.parametrize(("result", "initial"), [(3500, "3000"), (7000, "7000")])
async def test_api_increase_color_temp(
hass: HomeAssistant, result: int, initial: str
) -> None:
@@ -206,7 +210,11 @@ async def test_api_increase_color_temp(
hass.states.async_set(
"light.test",
"off",
- {"friendly_name": "Test light", "color_temp": initial, "min_mireds": 142},
+ {
+ "friendly_name": "Test light",
+ "color_temp_kelvin": initial,
+ "max_color_temp_kelvin": 7000,
+ },
)
call_light = async_mock_service(hass, "light", "turn_on")
@@ -219,7 +227,7 @@ async def test_api_increase_color_temp(
assert len(call_light) == 1
assert call_light[0].data["entity_id"] == "light.test"
- assert call_light[0].data["color_temp"] == result
+ assert call_light[0].data["color_temp_kelvin"] == result
assert msg["header"]["name"] == "Response"
diff --git a/tests/components/alexa/test_smart_home.py b/tests/components/alexa/test_smart_home.py
index 68010a6a711..e4a46db7d34 100644
--- a/tests/components/alexa/test_smart_home.py
+++ b/tests/components/alexa/test_smart_home.py
@@ -4546,6 +4546,7 @@ async def test_presence_sensor(hass: HomeAssistant) -> None:
"tilt_position_attr_in_service_call",
"supported_features",
"service_call",
+ "stop_feature_enabled",
),
[
(
@@ -4556,6 +4557,7 @@ async def test_presence_sensor(hass: HomeAssistant) -> None:
| CoverEntityFeature.CLOSE_TILT
| CoverEntityFeature.STOP_TILT,
"cover.set_cover_tilt_position",
+ True,
),
(
0,
@@ -4565,6 +4567,7 @@ async def test_presence_sensor(hass: HomeAssistant) -> None:
| CoverEntityFeature.CLOSE_TILT
| CoverEntityFeature.STOP_TILT,
"cover.close_cover_tilt",
+ True,
),
(
99,
@@ -4574,6 +4577,7 @@ async def test_presence_sensor(hass: HomeAssistant) -> None:
| CoverEntityFeature.CLOSE_TILT
| CoverEntityFeature.STOP_TILT,
"cover.set_cover_tilt_position",
+ True,
),
(
100,
@@ -4583,36 +4587,42 @@ async def test_presence_sensor(hass: HomeAssistant) -> None:
| CoverEntityFeature.CLOSE_TILT
| CoverEntityFeature.STOP_TILT,
"cover.open_cover_tilt",
+ True,
),
(
0,
0,
CoverEntityFeature.SET_TILT_POSITION,
"cover.set_cover_tilt_position",
+ False,
),
(
60,
60,
CoverEntityFeature.SET_TILT_POSITION,
"cover.set_cover_tilt_position",
+ False,
),
(
100,
100,
CoverEntityFeature.SET_TILT_POSITION,
"cover.set_cover_tilt_position",
+ False,
),
(
0,
0,
CoverEntityFeature.SET_TILT_POSITION | CoverEntityFeature.OPEN_TILT,
"cover.set_cover_tilt_position",
+ False,
),
(
100,
100,
CoverEntityFeature.SET_TILT_POSITION | CoverEntityFeature.CLOSE_TILT,
"cover.set_cover_tilt_position",
+ False,
),
],
ids=[
@@ -4633,6 +4643,7 @@ async def test_cover_tilt_position(
tilt_position_attr_in_service_call: int | None,
supported_features: CoverEntityFeature,
service_call: str,
+ stop_feature_enabled: bool,
) -> None:
"""Test cover discovery and tilt position using rangeController."""
device = (
@@ -4651,12 +4662,24 @@ async def test_cover_tilt_position(
assert appliance["displayCategories"][0] == "INTERIOR_BLIND"
assert appliance["friendlyName"] == "Test cover tilt range"
+ expected_interfaces: dict[bool, list[str]] = {
+ False: [
+ "Alexa.PowerController",
+ "Alexa.RangeController",
+ "Alexa.EndpointHealth",
+ "Alexa",
+ ],
+ True: [
+ "Alexa.PowerController",
+ "Alexa.RangeController",
+ "Alexa.PlaybackController",
+ "Alexa.EndpointHealth",
+ "Alexa",
+ ],
+ }
+
capabilities = assert_endpoint_capabilities(
- appliance,
- "Alexa.PowerController",
- "Alexa.RangeController",
- "Alexa.EndpointHealth",
- "Alexa",
+ appliance, *expected_interfaces[stop_feature_enabled]
)
range_capability = get_capability(capabilities, "Alexa.RangeController")
@@ -4713,6 +4736,7 @@ async def test_cover_tilt_position_range(hass: HomeAssistant) -> None:
appliance,
"Alexa.PowerController",
"Alexa.RangeController",
+ "Alexa.PlaybackController",
"Alexa.EndpointHealth",
"Alexa",
)
@@ -4767,6 +4791,66 @@ async def test_cover_tilt_position_range(hass: HomeAssistant) -> None:
)
+@pytest.mark.parametrize(
+ ("supported_stop_features", "cover_stop_calls", "cover_stop_tilt_calls"),
+ [
+ (CoverEntityFeature(0), 0, 0),
+ (CoverEntityFeature.STOP, 1, 0),
+ (CoverEntityFeature.STOP_TILT, 0, 1),
+ (CoverEntityFeature.STOP | CoverEntityFeature.STOP_TILT, 1, 1),
+ ],
+ ids=["no_stop", "stop_cover", "stop_cover_tilt", "stop_cover_and_stop_cover_tilt"],
+)
+async def test_cover_stop(
+ hass: HomeAssistant,
+ supported_stop_features: CoverEntityFeature,
+ cover_stop_calls: int,
+ cover_stop_tilt_calls: int,
+) -> None:
+ """Test cover and cover tilt can be stopped."""
+
+ base_features = (
+ CoverEntityFeature.OPEN
+ | CoverEntityFeature.CLOSE
+ | CoverEntityFeature.OPEN_TILT
+ | CoverEntityFeature.CLOSE_TILT
+ | CoverEntityFeature.SET_POSITION
+ | CoverEntityFeature.SET_TILT_POSITION
+ )
+
+ device = (
+ "cover.test_semantics",
+ "open",
+ {
+ "friendly_name": "Test cover semantics",
+ "device_class": "blind",
+ "supported_features": int(base_features | supported_stop_features),
+ "current_position": 30,
+ "tilt_position": 30,
+ },
+ )
+ appliance = await discovery_test(device, hass)
+
+ assert appliance["endpointId"] == "cover#test_semantics"
+ assert appliance["displayCategories"][0] == "INTERIOR_BLIND"
+ assert appliance["friendlyName"] == "Test cover semantics"
+
+ calls_stop = async_mock_service(hass, "cover", "stop_cover")
+ calls_stop_tilt = async_mock_service(hass, "cover", "stop_cover_tilt")
+
+ context = Context()
+ request = get_new_request(
+ "Alexa.PlaybackController", "Stop", "cover#test_semantics"
+ )
+ await smart_home.async_handle_message(
+ hass, get_default_config(hass), request, context
+ )
+ await hass.async_block_till_done()
+
+ assert len(calls_stop) == cover_stop_calls
+ assert len(calls_stop_tilt) == cover_stop_tilt_calls
+
+
async def test_cover_semantics_position_and_tilt(hass: HomeAssistant) -> None:
"""Test cover discovery and semantics with position and tilt support."""
device = (
@@ -4790,10 +4874,30 @@ async def test_cover_semantics_position_and_tilt(hass: HomeAssistant) -> None:
appliance,
"Alexa.PowerController",
"Alexa.RangeController",
+ "Alexa.PlaybackController",
"Alexa.EndpointHealth",
"Alexa",
)
+ playback_controller_capability = get_capability(
+ capabilities, "Alexa.PlaybackController"
+ )
+ assert playback_controller_capability is not None
+ assert playback_controller_capability["supportedOperations"] == ["Stop"]
+
+ # Assert both the cover and tilt stop calls are invoked
+ stop_cover_tilt_calls = async_mock_service(hass, "cover", "stop_cover_tilt")
+ await assert_request_calls_service(
+ "Alexa.PlaybackController",
+ "Stop",
+ "cover#test_semantics",
+ "cover.stop_cover",
+ hass,
+ )
+ assert len(stop_cover_tilt_calls) == 1
+ call = stop_cover_tilt_calls[0]
+ assert call.data == {"entity_id": "cover.test_semantics"}
+
# Assert for Position Semantics
position_capability = get_capability(
capabilities, "Alexa.RangeController", "cover.position"
diff --git a/tests/components/amberelectric/helpers.py b/tests/components/amberelectric/helpers.py
index 2bc65fdd558..971f3690a0d 100644
--- a/tests/components/amberelectric/helpers.py
+++ b/tests/components/amberelectric/helpers.py
@@ -2,73 +2,82 @@
from datetime import datetime, timedelta
-from amberelectric.model.actual_interval import ActualInterval
-from amberelectric.model.channel import ChannelType
-from amberelectric.model.current_interval import CurrentInterval
-from amberelectric.model.forecast_interval import ForecastInterval
-from amberelectric.model.interval import Descriptor, SpikeStatus
+from amberelectric.models.actual_interval import ActualInterval
+from amberelectric.models.channel import ChannelType
+from amberelectric.models.current_interval import CurrentInterval
+from amberelectric.models.forecast_interval import ForecastInterval
+from amberelectric.models.interval import Interval
+from amberelectric.models.price_descriptor import PriceDescriptor
+from amberelectric.models.spike_status import SpikeStatus
from dateutil import parser
-def generate_actual_interval(
- channel_type: ChannelType, end_time: datetime
-) -> ActualInterval:
+def generate_actual_interval(channel_type: ChannelType, end_time: datetime) -> Interval:
"""Generate a mock actual interval."""
start_time = end_time - timedelta(minutes=30)
- return ActualInterval(
- duration=30,
- spot_per_kwh=1.0,
- per_kwh=8.0,
- date=start_time.date(),
- nem_time=end_time,
- start_time=start_time,
- end_time=end_time,
- renewables=50,
- channel_type=channel_type.value,
- spike_status=SpikeStatus.NO_SPIKE.value,
- descriptor=Descriptor.LOW.value,
+ return Interval(
+ ActualInterval(
+ type="ActualInterval",
+ duration=30,
+ spot_per_kwh=1.0,
+ per_kwh=8.0,
+ date=start_time.date(),
+ nem_time=end_time,
+ start_time=start_time,
+ end_time=end_time,
+ renewables=50,
+ channel_type=channel_type,
+ spike_status=SpikeStatus.NONE,
+ descriptor=PriceDescriptor.LOW,
+ )
)
def generate_current_interval(
channel_type: ChannelType, end_time: datetime
-) -> CurrentInterval:
+) -> Interval:
"""Generate a mock current price."""
start_time = end_time - timedelta(minutes=30)
- return CurrentInterval(
- duration=30,
- spot_per_kwh=1.0,
- per_kwh=8.0,
- date=start_time.date(),
- nem_time=end_time,
- start_time=start_time,
- end_time=end_time,
- renewables=50.6,
- channel_type=channel_type.value,
- spike_status=SpikeStatus.NO_SPIKE.value,
- descriptor=Descriptor.EXTREMELY_LOW.value,
- estimate=True,
+ return Interval(
+ CurrentInterval(
+ type="CurrentInterval",
+ duration=30,
+ spot_per_kwh=1.0,
+ per_kwh=8.0,
+ date=start_time.date(),
+ nem_time=end_time,
+ start_time=start_time,
+ end_time=end_time,
+ renewables=50.6,
+ channel_type=channel_type,
+ spike_status=SpikeStatus.NONE,
+ descriptor=PriceDescriptor.EXTREMELYLOW,
+ estimate=True,
+ )
)
def generate_forecast_interval(
channel_type: ChannelType, end_time: datetime
-) -> ForecastInterval:
+) -> Interval:
"""Generate a mock forecast interval."""
start_time = end_time - timedelta(minutes=30)
- return ForecastInterval(
- duration=30,
- spot_per_kwh=1.1,
- per_kwh=8.8,
- date=start_time.date(),
- nem_time=end_time,
- start_time=start_time,
- end_time=end_time,
- renewables=50,
- channel_type=channel_type.value,
- spike_status=SpikeStatus.NO_SPIKE.value,
- descriptor=Descriptor.VERY_LOW.value,
- estimate=True,
+ return Interval(
+ ForecastInterval(
+ type="ForecastInterval",
+ duration=30,
+ spot_per_kwh=1.1,
+ per_kwh=8.8,
+ date=start_time.date(),
+ nem_time=end_time,
+ start_time=start_time,
+ end_time=end_time,
+ renewables=50,
+ channel_type=channel_type,
+ spike_status=SpikeStatus.NONE,
+ descriptor=PriceDescriptor.VERYLOW,
+ estimate=True,
+ )
)
@@ -94,31 +103,31 @@ GENERAL_CHANNEL = [
CONTROLLED_LOAD_CHANNEL = [
generate_current_interval(
- ChannelType.CONTROLLED_LOAD, parser.parse("2021-09-21T08:30:00+10:00")
+ ChannelType.CONTROLLEDLOAD, parser.parse("2021-09-21T08:30:00+10:00")
),
generate_forecast_interval(
- ChannelType.CONTROLLED_LOAD, parser.parse("2021-09-21T09:00:00+10:00")
+ ChannelType.CONTROLLEDLOAD, parser.parse("2021-09-21T09:00:00+10:00")
),
generate_forecast_interval(
- ChannelType.CONTROLLED_LOAD, parser.parse("2021-09-21T09:30:00+10:00")
+ ChannelType.CONTROLLEDLOAD, parser.parse("2021-09-21T09:30:00+10:00")
),
generate_forecast_interval(
- ChannelType.CONTROLLED_LOAD, parser.parse("2021-09-21T10:00:00+10:00")
+ ChannelType.CONTROLLEDLOAD, parser.parse("2021-09-21T10:00:00+10:00")
),
]
FEED_IN_CHANNEL = [
generate_current_interval(
- ChannelType.FEED_IN, parser.parse("2021-09-21T08:30:00+10:00")
+ ChannelType.FEEDIN, parser.parse("2021-09-21T08:30:00+10:00")
),
generate_forecast_interval(
- ChannelType.FEED_IN, parser.parse("2021-09-21T09:00:00+10:00")
+ ChannelType.FEEDIN, parser.parse("2021-09-21T09:00:00+10:00")
),
generate_forecast_interval(
- ChannelType.FEED_IN, parser.parse("2021-09-21T09:30:00+10:00")
+ ChannelType.FEEDIN, parser.parse("2021-09-21T09:30:00+10:00")
),
generate_forecast_interval(
- ChannelType.FEED_IN, parser.parse("2021-09-21T10:00:00+10:00")
+ ChannelType.FEEDIN, parser.parse("2021-09-21T10:00:00+10:00")
),
]
diff --git a/tests/components/amberelectric/test_binary_sensor.py b/tests/components/amberelectric/test_binary_sensor.py
index 2c1ee22b644..6a6ca372bc2 100644
--- a/tests/components/amberelectric/test_binary_sensor.py
+++ b/tests/components/amberelectric/test_binary_sensor.py
@@ -5,10 +5,10 @@ from __future__ import annotations
from collections.abc import AsyncGenerator
from unittest.mock import Mock, patch
-from amberelectric.model.channel import ChannelType
-from amberelectric.model.current_interval import CurrentInterval
-from amberelectric.model.interval import SpikeStatus
-from amberelectric.model.tariff_information import TariffInformation
+from amberelectric.models.channel import ChannelType
+from amberelectric.models.current_interval import CurrentInterval
+from amberelectric.models.spike_status import SpikeStatus
+from amberelectric.models.tariff_information import TariffInformation
from dateutil import parser
import pytest
@@ -42,10 +42,10 @@ async def setup_no_spike(hass: HomeAssistant) -> AsyncGenerator[Mock]:
instance = Mock()
with patch(
- "amberelectric.api.AmberApi.create",
+ "amberelectric.AmberApi",
return_value=instance,
) as mock_update:
- instance.get_current_price = Mock(return_value=GENERAL_CHANNEL)
+ instance.get_current_prices = Mock(return_value=GENERAL_CHANNEL)
assert await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
yield mock_update.return_value
@@ -65,7 +65,7 @@ async def setup_potential_spike(hass: HomeAssistant) -> AsyncGenerator[Mock]:
instance = Mock()
with patch(
- "amberelectric.api.AmberApi.create",
+ "amberelectric.AmberApi",
return_value=instance,
) as mock_update:
general_channel: list[CurrentInterval] = [
@@ -73,8 +73,8 @@ async def setup_potential_spike(hass: HomeAssistant) -> AsyncGenerator[Mock]:
ChannelType.GENERAL, parser.parse("2021-09-21T08:30:00+10:00")
),
]
- general_channel[0].spike_status = SpikeStatus.POTENTIAL
- instance.get_current_price = Mock(return_value=general_channel)
+ general_channel[0].actual_instance.spike_status = SpikeStatus.POTENTIAL
+ instance.get_current_prices = Mock(return_value=general_channel)
assert await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
yield mock_update.return_value
@@ -94,7 +94,7 @@ async def setup_spike(hass: HomeAssistant) -> AsyncGenerator[Mock]:
instance = Mock()
with patch(
- "amberelectric.api.AmberApi.create",
+ "amberelectric.AmberApi",
return_value=instance,
) as mock_update:
general_channel: list[CurrentInterval] = [
@@ -102,8 +102,8 @@ async def setup_spike(hass: HomeAssistant) -> AsyncGenerator[Mock]:
ChannelType.GENERAL, parser.parse("2021-09-21T08:30:00+10:00")
),
]
- general_channel[0].spike_status = SpikeStatus.SPIKE
- instance.get_current_price = Mock(return_value=general_channel)
+ general_channel[0].actual_instance.spike_status = SpikeStatus.SPIKE
+ instance.get_current_prices = Mock(return_value=general_channel)
assert await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
yield mock_update.return_value
@@ -156,7 +156,7 @@ async def setup_inactive_demand_window(hass: HomeAssistant) -> AsyncGenerator[Mo
instance = Mock()
with patch(
- "amberelectric.api.AmberApi.create",
+ "amberelectric.AmberApi",
return_value=instance,
) as mock_update:
general_channel: list[CurrentInterval] = [
@@ -164,8 +164,10 @@ async def setup_inactive_demand_window(hass: HomeAssistant) -> AsyncGenerator[Mo
ChannelType.GENERAL, parser.parse("2021-09-21T08:30:00+10:00")
),
]
- general_channel[0].tariff_information = TariffInformation(demandWindow=False)
- instance.get_current_price = Mock(return_value=general_channel)
+ general_channel[0].actual_instance.tariff_information = TariffInformation(
+ demandWindow=False
+ )
+ instance.get_current_prices = Mock(return_value=general_channel)
assert await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
yield mock_update.return_value
@@ -185,7 +187,7 @@ async def setup_active_demand_window(hass: HomeAssistant) -> AsyncGenerator[Mock
instance = Mock()
with patch(
- "amberelectric.api.AmberApi.create",
+ "amberelectric.AmberApi",
return_value=instance,
) as mock_update:
general_channel: list[CurrentInterval] = [
@@ -193,8 +195,10 @@ async def setup_active_demand_window(hass: HomeAssistant) -> AsyncGenerator[Mock
ChannelType.GENERAL, parser.parse("2021-09-21T08:30:00+10:00")
),
]
- general_channel[0].tariff_information = TariffInformation(demandWindow=True)
- instance.get_current_price = Mock(return_value=general_channel)
+ general_channel[0].actual_instance.tariff_information = TariffInformation(
+ demandWindow=True
+ )
+ instance.get_current_prices = Mock(return_value=general_channel)
assert await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
yield mock_update.return_value
diff --git a/tests/components/amberelectric/test_config_flow.py b/tests/components/amberelectric/test_config_flow.py
index 030b82d3596..b394977b0e8 100644
--- a/tests/components/amberelectric/test_config_flow.py
+++ b/tests/components/amberelectric/test_config_flow.py
@@ -5,7 +5,8 @@ from datetime import date
from unittest.mock import Mock, patch
from amberelectric import ApiException
-from amberelectric.model.site import Site, SiteStatus
+from amberelectric.models.site import Site
+from amberelectric.models.site_status import SiteStatus
import pytest
from homeassistant.components.amberelectric.config_flow import filter_sites
@@ -28,7 +29,7 @@ pytestmark = pytest.mark.usefixtures("mock_setup_entry")
def mock_invalid_key_api() -> Generator:
"""Return an authentication error."""
- with patch("amberelectric.api.AmberApi.create") as mock:
+ with patch("amberelectric.AmberApi") as mock:
mock.return_value.get_sites.side_effect = ApiException(status=403)
yield mock
@@ -36,7 +37,7 @@ def mock_invalid_key_api() -> Generator:
@pytest.fixture(name="api_error")
def mock_api_error() -> Generator:
"""Return an authentication error."""
- with patch("amberelectric.api.AmberApi.create") as mock:
+ with patch("amberelectric.AmberApi") as mock:
mock.return_value.get_sites.side_effect = ApiException(status=500)
yield mock
@@ -45,16 +46,36 @@ def mock_api_error() -> Generator:
def mock_single_site_api() -> Generator:
"""Return a single site."""
site = Site(
- "01FG0AGP818PXK0DWHXJRRT2DH",
- "11111111111",
- [],
- "Jemena",
- SiteStatus.ACTIVE,
- date(2002, 1, 1),
- None,
+ id="01FG0AGP818PXK0DWHXJRRT2DH",
+ nmi="11111111111",
+ channels=[],
+ network="Jemena",
+ status=SiteStatus.ACTIVE,
+ active_from=date(2002, 1, 1),
+ closed_on=None,
+ interval_length=30,
)
- with patch("amberelectric.api.AmberApi.create") as mock:
+ with patch("amberelectric.AmberApi") as mock:
+ mock.return_value.get_sites.return_value = [site]
+ yield mock
+
+
+@pytest.fixture(name="single_site_closed_no_close_date_api")
+def single_site_closed_no_close_date_api() -> Generator:
+ """Return a single closed site with no closed date."""
+ site = Site(
+ id="01FG0AGP818PXK0DWHXJRRT2DH",
+ nmi="11111111111",
+ channels=[],
+ network="Jemena",
+ status=SiteStatus.CLOSED,
+ active_from=None,
+ closed_on=None,
+ interval_length=30,
+ )
+
+ with patch("amberelectric.AmberApi") as mock:
mock.return_value.get_sites.return_value = [site]
yield mock
@@ -63,16 +84,17 @@ def mock_single_site_api() -> Generator:
def mock_single_site_pending_api() -> Generator:
"""Return a single site."""
site = Site(
- "01FG0AGP818PXK0DWHXJRRT2DH",
- "11111111111",
- [],
- "Jemena",
- SiteStatus.PENDING,
- None,
- None,
+ id="01FG0AGP818PXK0DWHXJRRT2DH",
+ nmi="11111111111",
+ channels=[],
+ network="Jemena",
+ status=SiteStatus.PENDING,
+ active_from=None,
+ closed_on=None,
+ interval_length=30,
)
- with patch("amberelectric.api.AmberApi.create") as mock:
+ with patch("amberelectric.AmberApi") as mock:
mock.return_value.get_sites.return_value = [site]
yield mock
@@ -82,35 +104,38 @@ def mock_single_site_rejoin_api() -> Generator:
"""Return a single site."""
instance = Mock()
site_1 = Site(
- "01HGD9QB72HB3DWQNJ6SSCGXGV",
- "11111111111",
- [],
- "Jemena",
- SiteStatus.CLOSED,
- date(2002, 1, 1),
- date(2002, 6, 1),
+ id="01HGD9QB72HB3DWQNJ6SSCGXGV",
+ nmi="11111111111",
+ channels=[],
+ network="Jemena",
+ status=SiteStatus.CLOSED,
+ active_from=date(2002, 1, 1),
+ closed_on=date(2002, 6, 1),
+ interval_length=30,
)
site_2 = Site(
- "01FG0AGP818PXK0DWHXJRRT2DH",
- "11111111111",
- [],
- "Jemena",
- SiteStatus.ACTIVE,
- date(2003, 1, 1),
- None,
+ id="01FG0AGP818PXK0DWHXJRRT2DH",
+ nmi="11111111111",
+ channels=[],
+ network="Jemena",
+ status=SiteStatus.ACTIVE,
+ active_from=date(2003, 1, 1),
+ closed_on=None,
+ interval_length=30,
)
site_3 = Site(
- "01FG0AGP818PXK0DWHXJRRT2DH",
- "11111111112",
- [],
- "Jemena",
- SiteStatus.CLOSED,
- date(2003, 1, 1),
- date(2003, 6, 1),
+ id="01FG0AGP818PXK0DWHXJRRT2DH",
+ nmi="11111111112",
+ channels=[],
+ network="Jemena",
+ status=SiteStatus.CLOSED,
+ active_from=date(2003, 1, 1),
+ closed_on=date(2003, 6, 1),
+ interval_length=30,
)
instance.get_sites.return_value = [site_1, site_2, site_3]
- with patch("amberelectric.api.AmberApi.create", return_value=instance):
+ with patch("amberelectric.AmberApi", return_value=instance):
yield instance
@@ -120,7 +145,7 @@ def mock_no_site_api() -> Generator:
instance = Mock()
instance.get_sites.return_value = []
- with patch("amberelectric.api.AmberApi.create", return_value=instance):
+ with patch("amberelectric.AmberApi", return_value=instance):
yield instance
@@ -188,6 +213,39 @@ async def test_single_site(hass: HomeAssistant, single_site_api: Mock) -> None:
assert data[CONF_SITE_ID] == "01FG0AGP818PXK0DWHXJRRT2DH"
+async def test_single_closed_site_no_closed_date(
+ hass: HomeAssistant, single_site_closed_no_close_date_api: Mock
+) -> None:
+ """Test single closed site with no closed date."""
+ initial_result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_USER}
+ )
+ assert initial_result.get("type") is FlowResultType.FORM
+ assert initial_result.get("step_id") == "user"
+
+ # Test filling in API key
+ enter_api_key_result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": SOURCE_USER},
+ data={CONF_API_TOKEN: API_KEY},
+ )
+ assert enter_api_key_result.get("type") is FlowResultType.FORM
+ assert enter_api_key_result.get("step_id") == "site"
+
+ select_site_result = await hass.config_entries.flow.async_configure(
+ enter_api_key_result["flow_id"],
+ {CONF_SITE_ID: "01FG0AGP818PXK0DWHXJRRT2DH", CONF_SITE_NAME: "Home"},
+ )
+
+ # Show available sites
+ assert select_site_result.get("type") is FlowResultType.CREATE_ENTRY
+ assert select_site_result.get("title") == "Home"
+ data = select_site_result.get("data")
+ assert data
+ assert data[CONF_API_TOKEN] == API_KEY
+ assert data[CONF_SITE_ID] == "01FG0AGP818PXK0DWHXJRRT2DH"
+
+
async def test_single_site_rejoin(
hass: HomeAssistant, single_site_rejoin_api: Mock
) -> None:
diff --git a/tests/components/amberelectric/test_coordinator.py b/tests/components/amberelectric/test_coordinator.py
index cb3912cb5ac..0a8f5b874fa 100644
--- a/tests/components/amberelectric/test_coordinator.py
+++ b/tests/components/amberelectric/test_coordinator.py
@@ -7,10 +7,12 @@ from datetime import date
from unittest.mock import Mock, patch
from amberelectric import ApiException
-from amberelectric.model.channel import Channel, ChannelType
-from amberelectric.model.current_interval import CurrentInterval
-from amberelectric.model.interval import Descriptor, SpikeStatus
-from amberelectric.model.site import Site, SiteStatus
+from amberelectric.models.channel import Channel, ChannelType
+from amberelectric.models.interval import Interval
+from amberelectric.models.price_descriptor import PriceDescriptor
+from amberelectric.models.site import Site
+from amberelectric.models.site_status import SiteStatus
+from amberelectric.models.spike_status import SpikeStatus
from dateutil import parser
import pytest
@@ -38,37 +40,40 @@ def mock_api_current_price() -> Generator:
instance = Mock()
general_site = Site(
- GENERAL_ONLY_SITE_ID,
- "11111111111",
- [Channel(identifier="E1", type=ChannelType.GENERAL, tariff="A100")],
- "Jemena",
- SiteStatus.ACTIVE,
- date(2021, 1, 1),
- None,
+ id=GENERAL_ONLY_SITE_ID,
+ nmi="11111111111",
+ channels=[Channel(identifier="E1", type=ChannelType.GENERAL, tariff="A100")],
+ network="Jemena",
+ status=SiteStatus("active"),
+ activeFrom=date(2021, 1, 1),
+ closedOn=None,
+ interval_length=30,
)
general_and_controlled_load = Site(
- GENERAL_AND_CONTROLLED_SITE_ID,
- "11111111112",
- [
+ id=GENERAL_AND_CONTROLLED_SITE_ID,
+ nmi="11111111112",
+ channels=[
Channel(identifier="E1", type=ChannelType.GENERAL, tariff="A100"),
- Channel(identifier="E2", type=ChannelType.CONTROLLED_LOAD, tariff="A180"),
+ Channel(identifier="E2", type=ChannelType.CONTROLLEDLOAD, tariff="A180"),
],
- "Jemena",
- SiteStatus.ACTIVE,
- date(2021, 1, 1),
- None,
+ network="Jemena",
+ status=SiteStatus("active"),
+ activeFrom=date(2021, 1, 1),
+ closedOn=None,
+ interval_length=30,
)
general_and_feed_in = Site(
- GENERAL_AND_FEED_IN_SITE_ID,
- "11111111113",
- [
+ id=GENERAL_AND_FEED_IN_SITE_ID,
+ nmi="11111111113",
+ channels=[
Channel(identifier="E1", type=ChannelType.GENERAL, tariff="A100"),
- Channel(identifier="E2", type=ChannelType.FEED_IN, tariff="A100"),
+ Channel(identifier="E2", type=ChannelType.FEEDIN, tariff="A100"),
],
- "Jemena",
- SiteStatus.ACTIVE,
- date(2021, 1, 1),
- None,
+ network="Jemena",
+ status=SiteStatus("active"),
+ activeFrom=date(2021, 1, 1),
+ closedOn=None,
+ interval_length=30,
)
instance.get_sites.return_value = [
general_site,
@@ -76,44 +81,46 @@ def mock_api_current_price() -> Generator:
general_and_feed_in,
]
- with patch("amberelectric.api.AmberApi.create", return_value=instance):
+ with patch("amberelectric.AmberApi", return_value=instance):
yield instance
def test_normalize_descriptor() -> None:
"""Test normalizing descriptors works correctly."""
assert normalize_descriptor(None) is None
- assert normalize_descriptor(Descriptor.NEGATIVE) == "negative"
- assert normalize_descriptor(Descriptor.EXTREMELY_LOW) == "extremely_low"
- assert normalize_descriptor(Descriptor.VERY_LOW) == "very_low"
- assert normalize_descriptor(Descriptor.LOW) == "low"
- assert normalize_descriptor(Descriptor.NEUTRAL) == "neutral"
- assert normalize_descriptor(Descriptor.HIGH) == "high"
- assert normalize_descriptor(Descriptor.SPIKE) == "spike"
+ assert normalize_descriptor(PriceDescriptor.NEGATIVE) == "negative"
+ assert normalize_descriptor(PriceDescriptor.EXTREMELYLOW) == "extremely_low"
+ assert normalize_descriptor(PriceDescriptor.VERYLOW) == "very_low"
+ assert normalize_descriptor(PriceDescriptor.LOW) == "low"
+ assert normalize_descriptor(PriceDescriptor.NEUTRAL) == "neutral"
+ assert normalize_descriptor(PriceDescriptor.HIGH) == "high"
+ assert normalize_descriptor(PriceDescriptor.SPIKE) == "spike"
async def test_fetch_general_site(hass: HomeAssistant, current_price_api: Mock) -> None:
"""Test fetching a site with only a general channel."""
- current_price_api.get_current_price.return_value = GENERAL_CHANNEL
+ current_price_api.get_current_prices.return_value = GENERAL_CHANNEL
data_service = AmberUpdateCoordinator(hass, current_price_api, GENERAL_ONLY_SITE_ID)
result = await data_service._async_update_data()
- current_price_api.get_current_price.assert_called_with(
+ current_price_api.get_current_prices.assert_called_with(
GENERAL_ONLY_SITE_ID, next=48
)
- assert result["current"].get("general") == GENERAL_CHANNEL[0]
+ assert result["current"].get("general") == GENERAL_CHANNEL[0].actual_instance
assert result["forecasts"].get("general") == [
- GENERAL_CHANNEL[1],
- GENERAL_CHANNEL[2],
- GENERAL_CHANNEL[3],
+ GENERAL_CHANNEL[1].actual_instance,
+ GENERAL_CHANNEL[2].actual_instance,
+ GENERAL_CHANNEL[3].actual_instance,
]
assert result["current"].get("controlled_load") is None
assert result["forecasts"].get("controlled_load") is None
assert result["current"].get("feed_in") is None
assert result["forecasts"].get("feed_in") is None
- assert result["grid"]["renewables"] == round(GENERAL_CHANNEL[0].renewables)
+ assert result["grid"]["renewables"] == round(
+ GENERAL_CHANNEL[0].actual_instance.renewables
+ )
assert result["grid"]["price_spike"] == "none"
@@ -122,12 +129,12 @@ async def test_fetch_no_general_site(
) -> None:
"""Test fetching a site with no general channel."""
- current_price_api.get_current_price.return_value = CONTROLLED_LOAD_CHANNEL
+ current_price_api.get_current_prices.return_value = CONTROLLED_LOAD_CHANNEL
data_service = AmberUpdateCoordinator(hass, current_price_api, GENERAL_ONLY_SITE_ID)
with pytest.raises(UpdateFailed):
await data_service._async_update_data()
- current_price_api.get_current_price.assert_called_with(
+ current_price_api.get_current_prices.assert_called_with(
GENERAL_ONLY_SITE_ID, next=48
)
@@ -135,41 +142,45 @@ async def test_fetch_no_general_site(
async def test_fetch_api_error(hass: HomeAssistant, current_price_api: Mock) -> None:
"""Test that the old values are maintained if a second call fails."""
- current_price_api.get_current_price.return_value = GENERAL_CHANNEL
+ current_price_api.get_current_prices.return_value = GENERAL_CHANNEL
data_service = AmberUpdateCoordinator(hass, current_price_api, GENERAL_ONLY_SITE_ID)
result = await data_service._async_update_data()
- current_price_api.get_current_price.assert_called_with(
+ current_price_api.get_current_prices.assert_called_with(
GENERAL_ONLY_SITE_ID, next=48
)
- assert result["current"].get("general") == GENERAL_CHANNEL[0]
+ assert result["current"].get("general") == GENERAL_CHANNEL[0].actual_instance
assert result["forecasts"].get("general") == [
- GENERAL_CHANNEL[1],
- GENERAL_CHANNEL[2],
- GENERAL_CHANNEL[3],
+ GENERAL_CHANNEL[1].actual_instance,
+ GENERAL_CHANNEL[2].actual_instance,
+ GENERAL_CHANNEL[3].actual_instance,
]
assert result["current"].get("controlled_load") is None
assert result["forecasts"].get("controlled_load") is None
assert result["current"].get("feed_in") is None
assert result["forecasts"].get("feed_in") is None
- assert result["grid"]["renewables"] == round(GENERAL_CHANNEL[0].renewables)
+ assert result["grid"]["renewables"] == round(
+ GENERAL_CHANNEL[0].actual_instance.renewables
+ )
- current_price_api.get_current_price.side_effect = ApiException(status=403)
+ current_price_api.get_current_prices.side_effect = ApiException(status=403)
with pytest.raises(UpdateFailed):
await data_service._async_update_data()
- assert result["current"].get("general") == GENERAL_CHANNEL[0]
+ assert result["current"].get("general") == GENERAL_CHANNEL[0].actual_instance
assert result["forecasts"].get("general") == [
- GENERAL_CHANNEL[1],
- GENERAL_CHANNEL[2],
- GENERAL_CHANNEL[3],
+ GENERAL_CHANNEL[1].actual_instance,
+ GENERAL_CHANNEL[2].actual_instance,
+ GENERAL_CHANNEL[3].actual_instance,
]
assert result["current"].get("controlled_load") is None
assert result["forecasts"].get("controlled_load") is None
assert result["current"].get("feed_in") is None
assert result["forecasts"].get("feed_in") is None
- assert result["grid"]["renewables"] == round(GENERAL_CHANNEL[0].renewables)
+ assert result["grid"]["renewables"] == round(
+ GENERAL_CHANNEL[0].actual_instance.renewables
+ )
assert result["grid"]["price_spike"] == "none"
@@ -178,7 +189,7 @@ async def test_fetch_general_and_controlled_load_site(
) -> None:
"""Test fetching a site with a general and controlled load channel."""
- current_price_api.get_current_price.return_value = (
+ current_price_api.get_current_prices.return_value = (
GENERAL_CHANNEL + CONTROLLED_LOAD_CHANNEL
)
data_service = AmberUpdateCoordinator(
@@ -186,25 +197,30 @@ async def test_fetch_general_and_controlled_load_site(
)
result = await data_service._async_update_data()
- current_price_api.get_current_price.assert_called_with(
+ current_price_api.get_current_prices.assert_called_with(
GENERAL_AND_CONTROLLED_SITE_ID, next=48
)
- assert result["current"].get("general") == GENERAL_CHANNEL[0]
+ assert result["current"].get("general") == GENERAL_CHANNEL[0].actual_instance
assert result["forecasts"].get("general") == [
- GENERAL_CHANNEL[1],
- GENERAL_CHANNEL[2],
- GENERAL_CHANNEL[3],
+ GENERAL_CHANNEL[1].actual_instance,
+ GENERAL_CHANNEL[2].actual_instance,
+ GENERAL_CHANNEL[3].actual_instance,
]
- assert result["current"].get("controlled_load") is CONTROLLED_LOAD_CHANNEL[0]
+ assert (
+ result["current"].get("controlled_load")
+ is CONTROLLED_LOAD_CHANNEL[0].actual_instance
+ )
assert result["forecasts"].get("controlled_load") == [
- CONTROLLED_LOAD_CHANNEL[1],
- CONTROLLED_LOAD_CHANNEL[2],
- CONTROLLED_LOAD_CHANNEL[3],
+ CONTROLLED_LOAD_CHANNEL[1].actual_instance,
+ CONTROLLED_LOAD_CHANNEL[2].actual_instance,
+ CONTROLLED_LOAD_CHANNEL[3].actual_instance,
]
assert result["current"].get("feed_in") is None
assert result["forecasts"].get("feed_in") is None
- assert result["grid"]["renewables"] == round(GENERAL_CHANNEL[0].renewables)
+ assert result["grid"]["renewables"] == round(
+ GENERAL_CHANNEL[0].actual_instance.renewables
+ )
assert result["grid"]["price_spike"] == "none"
@@ -213,31 +229,35 @@ async def test_fetch_general_and_feed_in_site(
) -> None:
"""Test fetching a site with a general and feed_in channel."""
- current_price_api.get_current_price.return_value = GENERAL_CHANNEL + FEED_IN_CHANNEL
+ current_price_api.get_current_prices.return_value = (
+ GENERAL_CHANNEL + FEED_IN_CHANNEL
+ )
data_service = AmberUpdateCoordinator(
hass, current_price_api, GENERAL_AND_FEED_IN_SITE_ID
)
result = await data_service._async_update_data()
- current_price_api.get_current_price.assert_called_with(
+ current_price_api.get_current_prices.assert_called_with(
GENERAL_AND_FEED_IN_SITE_ID, next=48
)
- assert result["current"].get("general") == GENERAL_CHANNEL[0]
+ assert result["current"].get("general") == GENERAL_CHANNEL[0].actual_instance
assert result["forecasts"].get("general") == [
- GENERAL_CHANNEL[1],
- GENERAL_CHANNEL[2],
- GENERAL_CHANNEL[3],
+ GENERAL_CHANNEL[1].actual_instance,
+ GENERAL_CHANNEL[2].actual_instance,
+ GENERAL_CHANNEL[3].actual_instance,
]
assert result["current"].get("controlled_load") is None
assert result["forecasts"].get("controlled_load") is None
- assert result["current"].get("feed_in") is FEED_IN_CHANNEL[0]
+ assert result["current"].get("feed_in") is FEED_IN_CHANNEL[0].actual_instance
assert result["forecasts"].get("feed_in") == [
- FEED_IN_CHANNEL[1],
- FEED_IN_CHANNEL[2],
- FEED_IN_CHANNEL[3],
+ FEED_IN_CHANNEL[1].actual_instance,
+ FEED_IN_CHANNEL[2].actual_instance,
+ FEED_IN_CHANNEL[3].actual_instance,
]
- assert result["grid"]["renewables"] == round(GENERAL_CHANNEL[0].renewables)
+ assert result["grid"]["renewables"] == round(
+ GENERAL_CHANNEL[0].actual_instance.renewables
+ )
assert result["grid"]["price_spike"] == "none"
@@ -246,13 +266,13 @@ async def test_fetch_potential_spike(
) -> None:
"""Test fetching a site with only a general channel."""
- general_channel: list[CurrentInterval] = [
+ general_channel: list[Interval] = [
generate_current_interval(
ChannelType.GENERAL, parser.parse("2021-09-21T08:30:00+10:00")
- ),
+ )
]
- general_channel[0].spike_status = SpikeStatus.POTENTIAL
- current_price_api.get_current_price.return_value = general_channel
+ general_channel[0].actual_instance.spike_status = SpikeStatus.POTENTIAL
+ current_price_api.get_current_prices.return_value = general_channel
data_service = AmberUpdateCoordinator(hass, current_price_api, GENERAL_ONLY_SITE_ID)
result = await data_service._async_update_data()
assert result["grid"]["price_spike"] == "potential"
@@ -261,13 +281,13 @@ async def test_fetch_potential_spike(
async def test_fetch_spike(hass: HomeAssistant, current_price_api: Mock) -> None:
"""Test fetching a site with only a general channel."""
- general_channel: list[CurrentInterval] = [
+ general_channel: list[Interval] = [
generate_current_interval(
ChannelType.GENERAL, parser.parse("2021-09-21T08:30:00+10:00")
- ),
+ )
]
- general_channel[0].spike_status = SpikeStatus.SPIKE
- current_price_api.get_current_price.return_value = general_channel
+ general_channel[0].actual_instance.spike_status = SpikeStatus.SPIKE
+ current_price_api.get_current_prices.return_value = general_channel
data_service = AmberUpdateCoordinator(hass, current_price_api, GENERAL_ONLY_SITE_ID)
result = await data_service._async_update_data()
assert result["grid"]["price_spike"] == "spike"
diff --git a/tests/components/amberelectric/test_sensor.py b/tests/components/amberelectric/test_sensor.py
index 3a5626d14d5..203b65d6df6 100644
--- a/tests/components/amberelectric/test_sensor.py
+++ b/tests/components/amberelectric/test_sensor.py
@@ -3,8 +3,9 @@
from collections.abc import AsyncGenerator
from unittest.mock import Mock, patch
-from amberelectric.model.current_interval import CurrentInterval
-from amberelectric.model.range import Range
+from amberelectric.models.current_interval import CurrentInterval
+from amberelectric.models.interval import Interval
+from amberelectric.models.range import Range
import pytest
from homeassistant.components.amberelectric.const import (
@@ -44,10 +45,10 @@ async def setup_general(hass: HomeAssistant) -> AsyncGenerator[Mock]:
instance = Mock()
with patch(
- "amberelectric.api.AmberApi.create",
+ "amberelectric.AmberApi",
return_value=instance,
) as mock_update:
- instance.get_current_price = Mock(return_value=GENERAL_CHANNEL)
+ instance.get_current_prices = Mock(return_value=GENERAL_CHANNEL)
assert await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
yield mock_update.return_value
@@ -68,10 +69,10 @@ async def setup_general_and_controlled_load(
instance = Mock()
with patch(
- "amberelectric.api.AmberApi.create",
+ "amberelectric.AmberApi",
return_value=instance,
) as mock_update:
- instance.get_current_price = Mock(
+ instance.get_current_prices = Mock(
return_value=GENERAL_CHANNEL + CONTROLLED_LOAD_CHANNEL
)
assert await async_setup_component(hass, DOMAIN, {})
@@ -92,10 +93,10 @@ async def setup_general_and_feed_in(hass: HomeAssistant) -> AsyncGenerator[Mock]
instance = Mock()
with patch(
- "amberelectric.api.AmberApi.create",
+ "amberelectric.AmberApi",
return_value=instance,
) as mock_update:
- instance.get_current_price = Mock(
+ instance.get_current_prices = Mock(
return_value=GENERAL_CHANNEL + FEED_IN_CHANNEL
)
assert await async_setup_component(hass, DOMAIN, {})
@@ -126,7 +127,7 @@ async def test_general_price_sensor(hass: HomeAssistant, setup_general: Mock) ->
assert attributes.get("range_max") is None
with_range: list[CurrentInterval] = GENERAL_CHANNEL
- with_range[0].range = Range(7.8, 12.4)
+ with_range[0].actual_instance.range = Range(min=7.8, max=12.4)
setup_general.get_current_price.return_value = with_range
config_entry = hass.config_entries.async_entries(DOMAIN)[0]
@@ -211,8 +212,8 @@ async def test_general_forecast_sensor(
assert first_forecast.get("range_min") is None
assert first_forecast.get("range_max") is None
- with_range: list[CurrentInterval] = GENERAL_CHANNEL
- with_range[1].range = Range(7.8, 12.4)
+ with_range: list[Interval] = GENERAL_CHANNEL
+ with_range[1].actual_instance.range = Range(min=7.8, max=12.4)
setup_general.get_current_price.return_value = with_range
config_entry = hass.config_entries.async_entries(DOMAIN)[0]
diff --git a/tests/components/aprilaire/test_config_flow.py b/tests/components/aprilaire/test_config_flow.py
index e4b7c167256..0cda1ed40ad 100644
--- a/tests/components/aprilaire/test_config_flow.py
+++ b/tests/components/aprilaire/test_config_flow.py
@@ -95,7 +95,6 @@ async def test_config_flow_data(client: AprilaireClient, hass: HomeAssistant) ->
)
client.start_listen.assert_called_once()
- client.wait_for_response.assert_any_call(FunctionalDomain.IDENTIFICATION, 4, 30)
client.wait_for_response.assert_any_call(FunctionalDomain.CONTROL, 7, 30)
client.wait_for_response.assert_any_call(FunctionalDomain.SENSORS, 2, 30)
client.stop_listen.assert_called_once()
diff --git a/tests/components/apsystems/conftest.py b/tests/components/apsystems/conftest.py
index 0feccf21578..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,
@@ -59,6 +59,7 @@ def mock_apsystems() -> Generator[MagicMock]:
operating=False,
)
mock_api.get_device_power_status.return_value = True
+ mock_api.get_max_power.return_value = 666
yield mock_api
diff --git a/tests/components/apsystems/snapshots/test_number.ambr b/tests/components/apsystems/snapshots/test_number.ambr
new file mode 100644
index 00000000000..a2b82e23596
--- /dev/null
+++ b/tests/components/apsystems/snapshots/test_number.ambr
@@ -0,0 +1,58 @@
+# serializer version: 1
+# name: test_all_entities[number.mock_title_max_output-entry]
+ EntityRegistryEntrySnapshot({
+ 'aliases': set({
+ }),
+ 'area_id': None,
+ 'capabilities': dict({
+ 'max': 1000,
+ 'min': 0,
+ 'mode': ,
+ 'step': 1,
+ }),
+ 'config_entry_id': ,
+ 'device_class': None,
+ 'device_id': ,
+ 'disabled_by': None,
+ 'domain': 'number',
+ 'entity_category': None,
+ 'entity_id': 'number.mock_title_max_output',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'options': dict({
+ }),
+ 'original_device_class': ,
+ 'original_icon': None,
+ 'original_name': 'Max output',
+ 'platform': 'apsystems',
+ 'previous_unique_id': None,
+ 'supported_features': 0,
+ 'translation_key': 'max_output',
+ 'unique_id': 'MY_SERIAL_NUMBER_output_limit',
+ 'unit_of_measurement': ,
+ })
+# ---
+# name: test_all_entities[number.mock_title_max_output-state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ 'device_class': 'power',
+ 'friendly_name': 'Mock Title Max output',
+ 'max': 1000,
+ 'min': 0,
+ 'mode': ,
+ 'step': 1,
+ 'unit_of_measurement': ,
+ }),
+ 'context': ,
+ 'entity_id': 'number.mock_title_max_output',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': '666',
+ })
+# ---
diff --git a/tests/components/apsystems/test_init.py b/tests/components/apsystems/test_init.py
new file mode 100644
index 00000000000..f127744dbf4
--- /dev/null
+++ b/tests/components/apsystems/test_init.py
@@ -0,0 +1,69 @@
+"""Test the APSystem setup."""
+
+import datetime
+from unittest.mock import AsyncMock
+
+from APsystemsEZ1 import InverterReturnedError
+from freezegun.api import FrozenDateTimeFactory
+import pytest
+
+from homeassistant.components.apsystems.const import DOMAIN
+from homeassistant.config_entries import ConfigEntryState
+from homeassistant.core import HomeAssistant
+
+from . import setup_integration
+
+from tests.common import MockConfigEntry, async_fire_time_changed
+
+SCAN_INTERVAL = datetime.timedelta(seconds=12)
+
+
+@pytest.mark.usefixtures("mock_apsystems")
+async def test_load_unload_entry(
+ hass: HomeAssistant,
+ mock_config_entry: MockConfigEntry,
+) -> None:
+ """Test load and unload entry."""
+ await setup_integration(hass, mock_config_entry)
+ assert mock_config_entry.state is ConfigEntryState.LOADED
+
+ await hass.config_entries.async_remove(mock_config_entry.entry_id)
+ await hass.async_block_till_done()
+ assert mock_config_entry.state is ConfigEntryState.NOT_LOADED
+
+
+async def test_setup_failed(
+ hass: HomeAssistant,
+ mock_apsystems: AsyncMock,
+ mock_config_entry: MockConfigEntry,
+) -> None:
+ """Test update failed."""
+ mock_apsystems.get_device_info.side_effect = TimeoutError
+ await setup_integration(hass, mock_config_entry)
+ entry = hass.config_entries.async_entries(DOMAIN)[0]
+ assert entry.state is ConfigEntryState.SETUP_RETRY
+
+
+async def test_update(
+ hass: HomeAssistant,
+ mock_apsystems: AsyncMock,
+ mock_config_entry: MockConfigEntry,
+ caplog: pytest.LogCaptureFixture,
+ freezer: FrozenDateTimeFactory,
+) -> None:
+ """Test update data with an inverter error and recover."""
+ await setup_integration(hass, mock_config_entry)
+
+ assert mock_config_entry.state is ConfigEntryState.LOADED
+ assert "Inverter returned an error" not in caplog.text
+ mock_apsystems.get_output_data.side_effect = InverterReturnedError
+ freezer.tick(SCAN_INTERVAL)
+ async_fire_time_changed(hass)
+ await hass.async_block_till_done()
+ assert "Error fetching APSystems Data data:" in caplog.text
+ caplog.clear()
+ mock_apsystems.get_output_data.side_effect = None
+ freezer.tick(SCAN_INTERVAL)
+ async_fire_time_changed(hass)
+ await hass.async_block_till_done()
+ assert "Fetching APSystems Data data recovered" in caplog.text
diff --git a/tests/components/apsystems/test_number.py b/tests/components/apsystems/test_number.py
new file mode 100644
index 00000000000..912759b4a17
--- /dev/null
+++ b/tests/components/apsystems/test_number.py
@@ -0,0 +1,72 @@
+"""Test the APSystem number module."""
+
+import datetime
+from unittest.mock import AsyncMock, patch
+
+from freezegun.api import FrozenDateTimeFactory
+import pytest
+from syrupy import SnapshotAssertion
+
+from homeassistant.components.number import (
+ ATTR_VALUE,
+ DOMAIN as NUMBER_DOMAIN,
+ SERVICE_SET_VALUE,
+)
+from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, Platform
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers import entity_registry as er
+
+from . import setup_integration
+
+from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
+
+SCAN_INTERVAL = datetime.timedelta(seconds=30)
+
+
+async def test_number(
+ hass: HomeAssistant,
+ mock_apsystems: AsyncMock,
+ mock_config_entry: MockConfigEntry,
+ freezer: FrozenDateTimeFactory,
+) -> None:
+ """Test number command."""
+ await setup_integration(hass, mock_config_entry)
+ entity_id = "number.mock_title_max_output"
+ await hass.services.async_call(
+ NUMBER_DOMAIN,
+ SERVICE_SET_VALUE,
+ service_data={ATTR_VALUE: 50.1},
+ target={ATTR_ENTITY_ID: entity_id},
+ blocking=True,
+ )
+ mock_apsystems.set_max_power.assert_called_once_with(50)
+ mock_apsystems.get_max_power.return_value = 50
+ freezer.tick(SCAN_INTERVAL)
+ async_fire_time_changed(hass)
+ await hass.async_block_till_done()
+ state = hass.states.get(entity_id)
+ assert state.state == "50"
+ mock_apsystems.get_max_power.side_effect = TimeoutError()
+ await hass.services.async_call(
+ NUMBER_DOMAIN,
+ SERVICE_SET_VALUE,
+ service_data={ATTR_VALUE: 50.1},
+ target={ATTR_ENTITY_ID: entity_id},
+ blocking=True,
+ )
+ await hass.async_block_till_done()
+ state = hass.states.get(entity_id)
+ assert state.state == STATE_UNAVAILABLE
+
+
+@pytest.mark.usefixtures("mock_apsystems")
+@patch("homeassistant.components.apsystems.PLATFORMS", [Platform.NUMBER])
+async def test_all_entities(
+ hass: HomeAssistant,
+ snapshot: SnapshotAssertion,
+ mock_config_entry: MockConfigEntry,
+ entity_registry: er.EntityRegistry,
+) -> None:
+ """Test all entities."""
+ await setup_integration(hass, mock_config_entry)
+ await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
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/assist_pipeline/snapshots/test_init.ambr b/tests/components/assist_pipeline/snapshots/test_init.ambr
index e14bbac1839..f63a28efbb7 100644
--- a/tests/components/assist_pipeline/snapshots/test_init.ambr
+++ b/tests/components/assist_pipeline/snapshots/test_init.ambr
@@ -37,6 +37,7 @@
'engine': 'conversation.home_assistant',
'intent_input': 'test transcript',
'language': 'en',
+ 'prefer_local_intents': False,
}),
'type': ,
}),
@@ -60,6 +61,7 @@
}),
}),
}),
+ 'processed_locally': True,
}),
'type': ,
}),
@@ -77,7 +79,7 @@
'tts_output': dict({
'media_id': "media-source://tts/test?message=Sorry,+I+couldn't+understand+that&language=en-US&tts_options=%7B%22voice%22:%22james_earl_jones%22%7D",
'mime_type': 'audio/mpeg',
- 'url': '/api/tts_proxy/dae2cdcb27a1d1c3b07ba2c7db91480f9d4bfd8f_en-us_031e2ec052_test.mp3',
+ 'url': '/api/tts_proxy/test_token.mp3',
}),
}),
'type': ,
@@ -126,6 +128,7 @@
'engine': 'conversation.home_assistant',
'intent_input': 'test transcript',
'language': 'en-US',
+ 'prefer_local_intents': False,
}),
'type': ,
}),
@@ -149,6 +152,7 @@
}),
}),
}),
+ 'processed_locally': True,
}),
'type': ,
}),
@@ -166,7 +170,7 @@
'tts_output': dict({
'media_id': "media-source://tts/test?message=Sorry,+I+couldn't+understand+that&language=en-US&tts_options=%7B%22voice%22:%22Arnold+Schwarzenegger%22%7D",
'mime_type': 'audio/mpeg',
- 'url': '/api/tts_proxy/dae2cdcb27a1d1c3b07ba2c7db91480f9d4bfd8f_en-us_2657c1a8ee_test.mp3',
+ 'url': '/api/tts_proxy/test_token.mp3',
}),
}),
'type': ,
@@ -215,6 +219,7 @@
'engine': 'conversation.home_assistant',
'intent_input': 'test transcript',
'language': 'en-US',
+ 'prefer_local_intents': False,
}),
'type': ,
}),
@@ -238,6 +243,7 @@
}),
}),
}),
+ 'processed_locally': True,
}),
'type': ,
}),
@@ -255,7 +261,7 @@
'tts_output': dict({
'media_id': "media-source://tts/test?message=Sorry,+I+couldn't+understand+that&language=en-US&tts_options=%7B%22voice%22:%22Arnold+Schwarzenegger%22%7D",
'mime_type': 'audio/mpeg',
- 'url': '/api/tts_proxy/dae2cdcb27a1d1c3b07ba2c7db91480f9d4bfd8f_en-us_2657c1a8ee_test.mp3',
+ 'url': '/api/tts_proxy/test_token.mp3',
}),
}),
'type': ,
@@ -328,6 +334,7 @@
'engine': 'conversation.home_assistant',
'intent_input': 'test transcript',
'language': 'en',
+ 'prefer_local_intents': False,
}),
'type': ,
}),
@@ -351,6 +358,7 @@
}),
}),
}),
+ 'processed_locally': True,
}),
'type': ,
}),
@@ -368,7 +376,7 @@
'tts_output': dict({
'media_id': "media-source://tts/test?message=Sorry,+I+couldn't+understand+that&language=en-US&tts_options=%7B%22voice%22:%22james_earl_jones%22%7D",
'mime_type': 'audio/mpeg',
- 'url': '/api/tts_proxy/dae2cdcb27a1d1c3b07ba2c7db91480f9d4bfd8f_en-us_031e2ec052_test.mp3',
+ 'url': '/api/tts_proxy/test_token.mp3',
}),
}),
'type': ,
@@ -379,6 +387,93 @@
}),
])
# ---
+# name: test_pipeline_from_audio_stream_with_cloud_auth_fail
+ list([
+ dict({
+ 'data': dict({
+ 'language': 'en',
+ 'pipeline': ,
+ }),
+ 'type': ,
+ }),
+ dict({
+ 'data': dict({
+ 'engine': 'stt.mock_stt',
+ 'metadata': dict({
+ 'bit_rate': ,
+ 'channel': ,
+ 'codec': ,
+ 'format': ,
+ 'language': 'en-US',
+ 'sample_rate': ,
+ }),
+ }),
+ 'type': ,
+ }),
+ dict({
+ 'data': dict({
+ 'code': 'cloud-auth-failed',
+ 'message': 'Home Assistant Cloud authentication failed',
+ }),
+ 'type': ,
+ }),
+ dict({
+ 'data': None,
+ 'type': ,
+ }),
+ ])
+# ---
+# name: test_pipeline_language_used_instead_of_conversation_language
+ list([
+ dict({
+ 'data': dict({
+ 'language': 'en',
+ 'pipeline': ,
+ }),
+ 'type': ,
+ }),
+ dict({
+ 'data': dict({
+ 'conversation_id': None,
+ 'device_id': None,
+ 'engine': 'conversation.home_assistant',
+ 'intent_input': 'test input',
+ 'language': 'en',
+ 'prefer_local_intents': False,
+ }),
+ 'type': ,
+ }),
+ dict({
+ 'data': dict({
+ 'intent_output': dict({
+ 'conversation_id': None,
+ 'response': dict({
+ 'card': dict({
+ }),
+ 'data': dict({
+ 'failed': list([
+ ]),
+ 'success': list([
+ ]),
+ 'targets': list([
+ ]),
+ }),
+ 'language': 'en',
+ 'response_type': 'action_done',
+ 'speech': dict({
+ }),
+ }),
+ }),
+ 'processed_locally': True,
+ }),
+ 'type': ,
+ }),
+ dict({
+ 'data': None,
+ 'type': ,
+ }),
+ ])
+# ---
# name: test_wake_word_detection_aborted
list([
dict({
diff --git a/tests/components/assist_pipeline/snapshots/test_websocket.ambr b/tests/components/assist_pipeline/snapshots/test_websocket.ambr
index 131444c17ac..41747a50eb6 100644
--- a/tests/components/assist_pipeline/snapshots/test_websocket.ambr
+++ b/tests/components/assist_pipeline/snapshots/test_websocket.ambr
@@ -36,6 +36,7 @@
'engine': 'conversation.home_assistant',
'intent_input': 'test transcript',
'language': 'en',
+ 'prefer_local_intents': False,
})
# ---
# name: test_audio_pipeline.4
@@ -58,6 +59,7 @@
}),
}),
}),
+ 'processed_locally': True,
})
# ---
# name: test_audio_pipeline.5
@@ -73,7 +75,7 @@
'tts_output': dict({
'media_id': "media-source://tts/test?message=Sorry,+I+couldn't+understand+that&language=en-US&tts_options=%7B%22voice%22:%22james_earl_jones%22%7D",
'mime_type': 'audio/mpeg',
- 'url': '/api/tts_proxy/dae2cdcb27a1d1c3b07ba2c7db91480f9d4bfd8f_en-us_031e2ec052_test.mp3',
+ 'url': '/api/tts_proxy/test_token.mp3',
}),
})
# ---
@@ -117,6 +119,7 @@
'engine': 'conversation.home_assistant',
'intent_input': 'test transcript',
'language': 'en',
+ 'prefer_local_intents': False,
})
# ---
# name: test_audio_pipeline_debug.4
@@ -139,6 +142,7 @@
}),
}),
}),
+ 'processed_locally': True,
})
# ---
# name: test_audio_pipeline_debug.5
@@ -154,7 +158,7 @@
'tts_output': dict({
'media_id': "media-source://tts/test?message=Sorry,+I+couldn't+understand+that&language=en-US&tts_options=%7B%22voice%22:%22james_earl_jones%22%7D",
'mime_type': 'audio/mpeg',
- 'url': '/api/tts_proxy/dae2cdcb27a1d1c3b07ba2c7db91480f9d4bfd8f_en-us_031e2ec052_test.mp3',
+ 'url': '/api/tts_proxy/test_token.mp3',
}),
})
# ---
@@ -210,6 +214,7 @@
'engine': 'conversation.home_assistant',
'intent_input': 'test transcript',
'language': 'en',
+ 'prefer_local_intents': False,
})
# ---
# name: test_audio_pipeline_with_enhancements.4
@@ -232,6 +237,7 @@
}),
}),
}),
+ 'processed_locally': True,
})
# ---
# name: test_audio_pipeline_with_enhancements.5
@@ -247,7 +253,7 @@
'tts_output': dict({
'media_id': "media-source://tts/test?message=Sorry,+I+couldn't+understand+that&language=en-US&tts_options=%7B%22voice%22:%22james_earl_jones%22%7D",
'mime_type': 'audio/mpeg',
- 'url': '/api/tts_proxy/dae2cdcb27a1d1c3b07ba2c7db91480f9d4bfd8f_en-us_031e2ec052_test.mp3',
+ 'url': '/api/tts_proxy/test_token.mp3',
}),
})
# ---
@@ -313,6 +319,7 @@
'engine': 'conversation.home_assistant',
'intent_input': 'test transcript',
'language': 'en',
+ 'prefer_local_intents': False,
})
# ---
# name: test_audio_pipeline_with_wake_word_no_timeout.6
@@ -335,6 +342,7 @@
}),
}),
}),
+ 'processed_locally': True,
})
# ---
# name: test_audio_pipeline_with_wake_word_no_timeout.7
@@ -350,7 +358,7 @@
'tts_output': dict({
'media_id': "media-source://tts/test?message=Sorry,+I+couldn't+understand+that&language=en-US&tts_options=%7B%22voice%22:%22james_earl_jones%22%7D",
'mime_type': 'audio/mpeg',
- 'url': '/api/tts_proxy/dae2cdcb27a1d1c3b07ba2c7db91480f9d4bfd8f_en-us_031e2ec052_test.mp3',
+ 'url': '/api/tts_proxy/test_token.mp3',
}),
})
# ---
@@ -519,6 +527,7 @@
'engine': 'conversation.home_assistant',
'intent_input': 'Are the lights on?',
'language': 'en',
+ 'prefer_local_intents': False,
})
# ---
# name: test_intent_failed.2
@@ -541,6 +550,7 @@
'engine': 'conversation.home_assistant',
'intent_input': 'Are the lights on?',
'language': 'en',
+ 'prefer_local_intents': False,
})
# ---
# name: test_intent_timeout.2
@@ -569,6 +579,7 @@
'engine': 'conversation.home_assistant',
'intent_input': 'never mind',
'language': 'en',
+ 'prefer_local_intents': False,
})
# ---
# name: test_pipeline_empty_tts_output.2
@@ -592,6 +603,7 @@
}),
}),
}),
+ 'processed_locally': True,
})
# ---
# name: test_pipeline_empty_tts_output.3
@@ -680,6 +692,7 @@
'engine': 'conversation.home_assistant',
'intent_input': 'Are the lights on?',
'language': 'en',
+ 'prefer_local_intents': False,
})
# ---
# name: test_text_only_pipeline[extra_msg0].2
@@ -697,11 +710,12 @@
'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',
}),
}),
}),
}),
+ 'processed_locally': True,
})
# ---
# name: test_text_only_pipeline[extra_msg0].3
@@ -724,6 +738,7 @@
'engine': 'conversation.home_assistant',
'intent_input': 'Are the lights on?',
'language': 'en',
+ 'prefer_local_intents': False,
})
# ---
# name: test_text_only_pipeline[extra_msg1].2
@@ -741,11 +756,12 @@
'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',
}),
}),
}),
}),
+ 'processed_locally': True,
})
# ---
# name: test_text_only_pipeline[extra_msg1].3
diff --git a/tests/components/assist_pipeline/test_init.py b/tests/components/assist_pipeline/test_init.py
index c4696573bad..d4cce4e2e98 100644
--- a/tests/components/assist_pipeline/test_init.py
+++ b/tests/components/assist_pipeline/test_init.py
@@ -8,16 +8,25 @@ import tempfile
from unittest.mock import ANY, patch
import wave
+import hass_nabucasa
import pytest
from syrupy.assertion import SnapshotAssertion
-from homeassistant.components import assist_pipeline, media_source, stt, tts
+from homeassistant.components import (
+ assist_pipeline,
+ conversation,
+ media_source,
+ stt,
+ tts,
+)
from homeassistant.components.assist_pipeline.const import (
BYTES_PER_CHUNK,
CONF_DEBUG_RECORDING_DIR,
DOMAIN,
)
+from homeassistant.const import MATCH_ALL
from homeassistant.core import Context, HomeAssistant
+from homeassistant.helpers import intent
from homeassistant.setup import async_setup_component
from .conftest import (
@@ -63,21 +72,24 @@ async def test_pipeline_from_audio_stream_auto(
yield make_10ms_chunk(b"part2")
yield b""
- await assist_pipeline.async_pipeline_from_audio_stream(
- hass,
- context=Context(),
- event_callback=events.append,
- stt_metadata=stt.SpeechMetadata(
- language="",
- format=stt.AudioFormats.WAV,
- codec=stt.AudioCodecs.PCM,
- bit_rate=stt.AudioBitRates.BITRATE_16,
- sample_rate=stt.AudioSampleRates.SAMPLERATE_16000,
- channel=stt.AudioChannels.CHANNEL_MONO,
- ),
- stt_stream=audio_data(),
- audio_settings=assist_pipeline.AudioSettings(is_vad_enabled=False),
- )
+ with patch(
+ "homeassistant.components.tts.secrets.token_urlsafe", return_value="test_token"
+ ):
+ await assist_pipeline.async_pipeline_from_audio_stream(
+ hass,
+ context=Context(),
+ event_callback=events.append,
+ stt_metadata=stt.SpeechMetadata(
+ language="",
+ format=stt.AudioFormats.WAV,
+ codec=stt.AudioCodecs.PCM,
+ bit_rate=stt.AudioBitRates.BITRATE_16,
+ sample_rate=stt.AudioSampleRates.SAMPLERATE_16000,
+ channel=stt.AudioChannels.CHANNEL_MONO,
+ ),
+ stt_stream=audio_data(),
+ audio_settings=assist_pipeline.AudioSettings(is_vad_enabled=False),
+ )
assert process_events(events) == snapshot
assert len(mock_stt_provider_entity.received) == 2
@@ -126,23 +138,26 @@ async def test_pipeline_from_audio_stream_legacy(
assert msg["success"]
pipeline_id = msg["result"]["id"]
- # Use the created pipeline
- await assist_pipeline.async_pipeline_from_audio_stream(
- hass,
- context=Context(),
- event_callback=events.append,
- stt_metadata=stt.SpeechMetadata(
- language="en-UK",
- format=stt.AudioFormats.WAV,
- codec=stt.AudioCodecs.PCM,
- bit_rate=stt.AudioBitRates.BITRATE_16,
- sample_rate=stt.AudioSampleRates.SAMPLERATE_16000,
- channel=stt.AudioChannels.CHANNEL_MONO,
- ),
- stt_stream=audio_data(),
- pipeline_id=pipeline_id,
- audio_settings=assist_pipeline.AudioSettings(is_vad_enabled=False),
- )
+ with patch(
+ "homeassistant.components.tts.secrets.token_urlsafe", return_value="test_token"
+ ):
+ # Use the created pipeline
+ await assist_pipeline.async_pipeline_from_audio_stream(
+ hass,
+ context=Context(),
+ event_callback=events.append,
+ stt_metadata=stt.SpeechMetadata(
+ language="en-UK",
+ format=stt.AudioFormats.WAV,
+ codec=stt.AudioCodecs.PCM,
+ bit_rate=stt.AudioBitRates.BITRATE_16,
+ sample_rate=stt.AudioSampleRates.SAMPLERATE_16000,
+ channel=stt.AudioChannels.CHANNEL_MONO,
+ ),
+ stt_stream=audio_data(),
+ pipeline_id=pipeline_id,
+ audio_settings=assist_pipeline.AudioSettings(is_vad_enabled=False),
+ )
assert process_events(events) == snapshot
assert len(mock_stt_provider.received) == 2
@@ -191,23 +206,26 @@ async def test_pipeline_from_audio_stream_entity(
assert msg["success"]
pipeline_id = msg["result"]["id"]
- # Use the created pipeline
- await assist_pipeline.async_pipeline_from_audio_stream(
- hass,
- context=Context(),
- event_callback=events.append,
- stt_metadata=stt.SpeechMetadata(
- language="en-UK",
- format=stt.AudioFormats.WAV,
- codec=stt.AudioCodecs.PCM,
- bit_rate=stt.AudioBitRates.BITRATE_16,
- sample_rate=stt.AudioSampleRates.SAMPLERATE_16000,
- channel=stt.AudioChannels.CHANNEL_MONO,
- ),
- stt_stream=audio_data(),
- pipeline_id=pipeline_id,
- audio_settings=assist_pipeline.AudioSettings(is_vad_enabled=False),
- )
+ with patch(
+ "homeassistant.components.tts.secrets.token_urlsafe", return_value="test_token"
+ ):
+ # Use the created pipeline
+ await assist_pipeline.async_pipeline_from_audio_stream(
+ hass,
+ context=Context(),
+ event_callback=events.append,
+ stt_metadata=stt.SpeechMetadata(
+ language="en-UK",
+ format=stt.AudioFormats.WAV,
+ codec=stt.AudioCodecs.PCM,
+ bit_rate=stt.AudioBitRates.BITRATE_16,
+ sample_rate=stt.AudioSampleRates.SAMPLERATE_16000,
+ channel=stt.AudioChannels.CHANNEL_MONO,
+ ),
+ stt_stream=audio_data(),
+ pipeline_id=pipeline_id,
+ audio_settings=assist_pipeline.AudioSettings(is_vad_enabled=False),
+ )
assert process_events(events) == snapshot
assert len(mock_stt_provider_entity.received) == 2
@@ -355,25 +373,28 @@ async def test_pipeline_from_audio_stream_wake_word(
yield b""
- await assist_pipeline.async_pipeline_from_audio_stream(
- hass,
- context=Context(),
- event_callback=events.append,
- stt_metadata=stt.SpeechMetadata(
- language="",
- format=stt.AudioFormats.WAV,
- codec=stt.AudioCodecs.PCM,
- bit_rate=stt.AudioBitRates.BITRATE_16,
- sample_rate=stt.AudioSampleRates.SAMPLERATE_16000,
- channel=stt.AudioChannels.CHANNEL_MONO,
- ),
- stt_stream=audio_data(),
- start_stage=assist_pipeline.PipelineStage.WAKE_WORD,
- wake_word_settings=assist_pipeline.WakeWordSettings(
- audio_seconds_to_buffer=1.5
- ),
- audio_settings=assist_pipeline.AudioSettings(is_vad_enabled=False),
- )
+ with patch(
+ "homeassistant.components.tts.secrets.token_urlsafe", return_value="test_token"
+ ):
+ await assist_pipeline.async_pipeline_from_audio_stream(
+ hass,
+ context=Context(),
+ event_callback=events.append,
+ stt_metadata=stt.SpeechMetadata(
+ language="",
+ format=stt.AudioFormats.WAV,
+ codec=stt.AudioCodecs.PCM,
+ bit_rate=stt.AudioBitRates.BITRATE_16,
+ sample_rate=stt.AudioSampleRates.SAMPLERATE_16000,
+ channel=stt.AudioChannels.CHANNEL_MONO,
+ ),
+ stt_stream=audio_data(),
+ start_stage=assist_pipeline.PipelineStage.WAKE_WORD,
+ wake_word_settings=assist_pipeline.WakeWordSettings(
+ audio_seconds_to_buffer=1.5
+ ),
+ audio_settings=assist_pipeline.AudioSettings(is_vad_enabled=False),
+ )
assert process_events(events) == snapshot
@@ -927,3 +948,269 @@ async def test_tts_dict_preferred_format(
assert int(options.get(tts.ATTR_PREFERRED_SAMPLE_RATE)) == 48000
assert int(options.get(tts.ATTR_PREFERRED_SAMPLE_CHANNELS)) == 2
assert int(options.get(tts.ATTR_PREFERRED_SAMPLE_BYTES)) == 2
+
+
+async def test_sentence_trigger_overrides_conversation_agent(
+ hass: HomeAssistant,
+ init_components,
+ pipeline_data: assist_pipeline.pipeline.PipelineData,
+) -> None:
+ """Test that sentence triggers are checked before a non-default conversation agent."""
+ assert await async_setup_component(
+ hass,
+ "automation",
+ {
+ "automation": {
+ "trigger": {
+ "platform": "conversation",
+ "command": [
+ "test trigger sentence",
+ ],
+ },
+ "action": {
+ "set_conversation_response": "test trigger response",
+ },
+ }
+ },
+ )
+
+ events: list[assist_pipeline.PipelineEvent] = []
+
+ pipeline_store = pipeline_data.pipeline_store
+ pipeline_id = pipeline_store.async_get_preferred_item()
+ pipeline = assist_pipeline.pipeline.async_get_pipeline(hass, pipeline_id)
+
+ pipeline_input = assist_pipeline.pipeline.PipelineInput(
+ intent_input="test trigger sentence",
+ run=assist_pipeline.pipeline.PipelineRun(
+ hass,
+ context=Context(),
+ pipeline=pipeline,
+ start_stage=assist_pipeline.PipelineStage.INTENT,
+ end_stage=assist_pipeline.PipelineStage.INTENT,
+ event_callback=events.append,
+ intent_agent="test-agent", # not the default agent
+ ),
+ )
+
+ # Ensure prepare succeeds
+ with patch(
+ "homeassistant.components.assist_pipeline.pipeline.conversation.async_get_agent_info",
+ return_value=conversation.AgentInfo(id="test-agent", name="Test Agent"),
+ ):
+ await pipeline_input.validate()
+
+ with patch(
+ "homeassistant.components.assist_pipeline.pipeline.conversation.async_converse"
+ ) as mock_async_converse:
+ await pipeline_input.execute()
+
+ # Sentence trigger should have been handled
+ mock_async_converse.assert_not_called()
+
+ # Verify sentence trigger response
+ intent_end_event = next(
+ (
+ e
+ for e in events
+ if e.type == assist_pipeline.PipelineEventType.INTENT_END
+ ),
+ None,
+ )
+ assert (intent_end_event is not None) and intent_end_event.data
+ assert (
+ intent_end_event.data["intent_output"]["response"]["speech"]["plain"][
+ "speech"
+ ]
+ == "test trigger response"
+ )
+
+
+async def test_prefer_local_intents(
+ hass: HomeAssistant,
+ init_components,
+ pipeline_data: assist_pipeline.pipeline.PipelineData,
+) -> None:
+ """Test that the default agent is checked first when local intents are preferred."""
+ events: list[assist_pipeline.PipelineEvent] = []
+
+ # Reuse custom sentences in test config
+ class OrderBeerIntentHandler(intent.IntentHandler):
+ intent_type = "OrderBeer"
+
+ async def async_handle(
+ self, intent_obj: intent.Intent
+ ) -> intent.IntentResponse:
+ response = intent_obj.create_response()
+ response.async_set_speech("Order confirmed")
+ return response
+
+ handler = OrderBeerIntentHandler()
+ intent.async_register(hass, handler)
+
+ # Fake a test agent and prefer local intents
+ pipeline_store = pipeline_data.pipeline_store
+ pipeline_id = pipeline_store.async_get_preferred_item()
+ pipeline = assist_pipeline.pipeline.async_get_pipeline(hass, pipeline_id)
+ await assist_pipeline.pipeline.async_update_pipeline(
+ hass, pipeline, conversation_engine="test-agent", prefer_local_intents=True
+ )
+ pipeline = assist_pipeline.pipeline.async_get_pipeline(hass, pipeline_id)
+
+ pipeline_input = assist_pipeline.pipeline.PipelineInput(
+ intent_input="I'd like to order a stout please",
+ run=assist_pipeline.pipeline.PipelineRun(
+ hass,
+ context=Context(),
+ pipeline=pipeline,
+ start_stage=assist_pipeline.PipelineStage.INTENT,
+ end_stage=assist_pipeline.PipelineStage.INTENT,
+ event_callback=events.append,
+ ),
+ )
+
+ # Ensure prepare succeeds
+ with patch(
+ "homeassistant.components.assist_pipeline.pipeline.conversation.async_get_agent_info",
+ return_value=conversation.AgentInfo(id="test-agent", name="Test Agent"),
+ ):
+ await pipeline_input.validate()
+
+ with patch(
+ "homeassistant.components.assist_pipeline.pipeline.conversation.async_converse"
+ ) as mock_async_converse:
+ await pipeline_input.execute()
+
+ # Test agent should not have been called
+ mock_async_converse.assert_not_called()
+
+ # Verify local intent response
+ intent_end_event = next(
+ (
+ e
+ for e in events
+ if e.type == assist_pipeline.PipelineEventType.INTENT_END
+ ),
+ None,
+ )
+ assert (intent_end_event is not None) and intent_end_event.data
+ assert (
+ intent_end_event.data["intent_output"]["response"]["speech"]["plain"][
+ "speech"
+ ]
+ == "Order confirmed"
+ )
+
+
+async def test_pipeline_language_used_instead_of_conversation_language(
+ hass: HomeAssistant,
+ hass_ws_client: WebSocketGenerator,
+ init_components,
+ snapshot: SnapshotAssertion,
+) -> None:
+ """Test that the pipeline language is used when the conversation language is '*' (all languages)."""
+ client = await hass_ws_client(hass)
+
+ events: list[assist_pipeline.PipelineEvent] = []
+
+ await client.send_json_auto_id(
+ {
+ "type": "assist_pipeline/pipeline/create",
+ "conversation_engine": "homeassistant",
+ "conversation_language": MATCH_ALL,
+ "language": "en",
+ "name": "test_name",
+ "stt_engine": "test",
+ "stt_language": "en-US",
+ "tts_engine": "test",
+ "tts_language": "en-US",
+ "tts_voice": "Arnold Schwarzenegger",
+ "wake_word_entity": None,
+ "wake_word_id": None,
+ }
+ )
+ msg = await client.receive_json()
+ assert msg["success"]
+ pipeline_id = msg["result"]["id"]
+ pipeline = assist_pipeline.async_get_pipeline(hass, pipeline_id)
+
+ pipeline_input = assist_pipeline.pipeline.PipelineInput(
+ intent_input="test input",
+ run=assist_pipeline.pipeline.PipelineRun(
+ hass,
+ context=Context(),
+ pipeline=pipeline,
+ start_stage=assist_pipeline.PipelineStage.INTENT,
+ end_stage=assist_pipeline.PipelineStage.INTENT,
+ event_callback=events.append,
+ ),
+ )
+ await pipeline_input.validate()
+
+ with patch(
+ "homeassistant.components.assist_pipeline.pipeline.conversation.async_converse",
+ return_value=conversation.ConversationResult(
+ intent.IntentResponse(pipeline.language)
+ ),
+ ) as mock_async_converse:
+ await pipeline_input.execute()
+
+ # Check intent start event
+ assert process_events(events) == snapshot
+ intent_start: assist_pipeline.PipelineEvent | None = None
+ for event in events:
+ if event.type == assist_pipeline.PipelineEventType.INTENT_START:
+ intent_start = event
+ break
+
+ assert intent_start is not None
+
+ # Pipeline language (en) should be used instead of '*'
+ assert intent_start.data.get("language") == pipeline.language
+
+ # Check input to async_converse
+ mock_async_converse.assert_called_once()
+ assert (
+ mock_async_converse.call_args_list[0].kwargs.get("language")
+ == pipeline.language
+ )
+
+
+async def test_pipeline_from_audio_stream_with_cloud_auth_fail(
+ hass: HomeAssistant,
+ mock_stt_provider_entity: MockSTTProviderEntity,
+ init_components,
+ snapshot: SnapshotAssertion,
+) -> None:
+ """Test creating a pipeline from an audio stream but the cloud authentication fails."""
+
+ events: list[assist_pipeline.PipelineEvent] = []
+
+ async def audio_data():
+ yield b"audio"
+
+ with patch.object(
+ mock_stt_provider_entity,
+ "async_process_audio_stream",
+ side_effect=hass_nabucasa.auth.Unauthenticated,
+ ):
+ await assist_pipeline.async_pipeline_from_audio_stream(
+ hass,
+ context=Context(),
+ event_callback=events.append,
+ stt_metadata=stt.SpeechMetadata(
+ language="",
+ format=stt.AudioFormats.WAV,
+ codec=stt.AudioCodecs.PCM,
+ bit_rate=stt.AudioBitRates.BITRATE_16,
+ sample_rate=stt.AudioSampleRates.SAMPLERATE_16000,
+ channel=stt.AudioChannels.CHANNEL_MONO,
+ ),
+ stt_stream=audio_data(),
+ audio_settings=assist_pipeline.AudioSettings(is_vad_enabled=False),
+ )
+
+ assert process_events(events) == snapshot
+ assert len(events) == 4 # run start, stt start, error, run end
+ assert events[2].type == assist_pipeline.PipelineEventType.ERROR
+ assert events[2].data["code"] == "cloud-auth-failed"
diff --git a/tests/components/assist_pipeline/test_pipeline.py b/tests/components/assist_pipeline/test_pipeline.py
index 50d0fc9bed8..d52e2a762ee 100644
--- a/tests/components/assist_pipeline/test_pipeline.py
+++ b/tests/components/assist_pipeline/test_pipeline.py
@@ -574,6 +574,7 @@ async def test_update_pipeline(
"tts_voice": "test_voice",
"wake_word_entity": "wake_work.test_1",
"wake_word_id": "wake_word_id_1",
+ "prefer_local_intents": False,
}
await async_update_pipeline(
@@ -617,6 +618,7 @@ async def test_update_pipeline(
"tts_voice": "test_voice",
"wake_word_entity": "wake_work.test_1",
"wake_word_id": "wake_word_id_1",
+ "prefer_local_intents": False,
}
diff --git a/tests/components/assist_pipeline/test_select.py b/tests/components/assist_pipeline/test_select.py
index 9fb02e228d8..5ce3b1020d0 100644
--- a/tests/components/assist_pipeline/test_select.py
+++ b/tests/components/assist_pipeline/test_select.py
@@ -184,7 +184,7 @@ async def test_select_entity_changing_vad_sensitivity(
hass: HomeAssistant,
init_select: MockConfigEntry,
) -> None:
- """Test entity tracking pipeline changes."""
+ """Test entity tracking vad sensitivity changes."""
config_entry = init_select # nicer naming
config_entry.mock_state(hass, ConfigEntryState.LOADED)
@@ -192,7 +192,7 @@ async def test_select_entity_changing_vad_sensitivity(
assert state is not None
assert state.state == VadSensitivity.DEFAULT.value
- # Change select to new pipeline
+ # Change select to new sensitivity
await hass.services.async_call(
"select",
"select_option",
diff --git a/tests/components/assist_pipeline/test_websocket.py b/tests/components/assist_pipeline/test_websocket.py
index e339ee74fbb..c1caf6f86a4 100644
--- a/tests/components/assist_pipeline/test_websocket.py
+++ b/tests/components/assist_pipeline/test_websocket.py
@@ -119,85 +119,88 @@ async def test_audio_pipeline(
events = []
client = await hass_ws_client(hass)
- await client.send_json_auto_id(
- {
- "type": "assist_pipeline/run",
- "start_stage": "stt",
- "end_stage": "tts",
- "input": {
- "sample_rate": 44100,
- },
- }
- )
+ with patch(
+ "homeassistant.components.tts.secrets.token_urlsafe", return_value="test_token"
+ ):
+ await client.send_json_auto_id(
+ {
+ "type": "assist_pipeline/run",
+ "start_stage": "stt",
+ "end_stage": "tts",
+ "input": {
+ "sample_rate": 44100,
+ },
+ }
+ )
- # result
- msg = await client.receive_json()
- assert msg["success"]
+ # result
+ msg = await client.receive_json()
+ assert msg["success"]
- # run start
- msg = await client.receive_json()
- assert msg["event"]["type"] == "run-start"
- msg["event"]["data"]["pipeline"] = ANY
- assert msg["event"]["data"] == snapshot
- handler_id = msg["event"]["data"]["runner_data"]["stt_binary_handler_id"]
- events.append(msg["event"])
+ # run start
+ msg = await client.receive_json()
+ assert msg["event"]["type"] == "run-start"
+ msg["event"]["data"]["pipeline"] = ANY
+ assert msg["event"]["data"] == snapshot
+ handler_id = msg["event"]["data"]["runner_data"]["stt_binary_handler_id"]
+ events.append(msg["event"])
- # stt
- msg = await client.receive_json()
- assert msg["event"]["type"] == "stt-start"
- assert msg["event"]["data"] == snapshot
- events.append(msg["event"])
+ # stt
+ msg = await client.receive_json()
+ assert msg["event"]["type"] == "stt-start"
+ assert msg["event"]["data"] == snapshot
+ events.append(msg["event"])
- # End of audio stream (handler id + empty payload)
- await client.send_bytes(bytes([handler_id]))
+ # End of audio stream (handler id + empty payload)
+ await client.send_bytes(bytes([handler_id]))
- msg = await client.receive_json()
- assert msg["event"]["type"] == "stt-end"
- assert msg["event"]["data"] == snapshot
- events.append(msg["event"])
+ msg = await client.receive_json()
+ assert msg["event"]["type"] == "stt-end"
+ assert msg["event"]["data"] == snapshot
+ events.append(msg["event"])
- # intent
- msg = await client.receive_json()
- assert msg["event"]["type"] == "intent-start"
- assert msg["event"]["data"] == snapshot
- events.append(msg["event"])
+ # intent
+ msg = await client.receive_json()
+ assert msg["event"]["type"] == "intent-start"
+ assert msg["event"]["data"] == snapshot
+ events.append(msg["event"])
- msg = await client.receive_json()
- assert msg["event"]["type"] == "intent-end"
- assert msg["event"]["data"] == snapshot
- events.append(msg["event"])
+ msg = await client.receive_json()
+ assert msg["event"]["type"] == "intent-end"
+ assert msg["event"]["data"] == snapshot
+ events.append(msg["event"])
- # text-to-speech
- msg = await client.receive_json()
- assert msg["event"]["type"] == "tts-start"
- assert msg["event"]["data"] == snapshot
- events.append(msg["event"])
+ # text-to-speech
+ msg = await client.receive_json()
+ assert msg["event"]["type"] == "tts-start"
+ assert msg["event"]["data"] == snapshot
+ events.append(msg["event"])
- msg = await client.receive_json()
- assert msg["event"]["type"] == "tts-end"
- assert msg["event"]["data"] == snapshot
- events.append(msg["event"])
+ msg = await client.receive_json()
+ assert msg["event"]["type"] == "tts-end"
+ assert msg["event"]["data"] == snapshot
+ events.append(msg["event"])
- # run end
- msg = await client.receive_json()
- assert msg["event"]["type"] == "run-end"
- assert msg["event"]["data"] == snapshot
- events.append(msg["event"])
+ # run end
+ msg = await client.receive_json()
+ assert msg["event"]["type"] == "run-end"
+ assert msg["event"]["data"] == snapshot
+ events.append(msg["event"])
- pipeline_data: PipelineData = hass.data[DOMAIN]
- pipeline_id = list(pipeline_data.pipeline_debug)[0]
- pipeline_run_id = list(pipeline_data.pipeline_debug[pipeline_id])[0]
+ pipeline_data: PipelineData = hass.data[DOMAIN]
+ pipeline_id = list(pipeline_data.pipeline_debug)[0]
+ pipeline_run_id = list(pipeline_data.pipeline_debug[pipeline_id])[0]
- await client.send_json_auto_id(
- {
- "type": "assist_pipeline/pipeline_debug/get",
- "pipeline_id": pipeline_id,
- "pipeline_run_id": pipeline_run_id,
- }
- )
- msg = await client.receive_json()
- assert msg["success"]
- assert msg["result"] == {"events": events}
+ await client.send_json_auto_id(
+ {
+ "type": "assist_pipeline/pipeline_debug/get",
+ "pipeline_id": pipeline_id,
+ "pipeline_run_id": pipeline_run_id,
+ }
+ )
+ msg = await client.receive_json()
+ assert msg["success"]
+ assert msg["result"] == {"events": events}
async def test_audio_pipeline_with_wake_word_timeout(
@@ -210,49 +213,52 @@ async def test_audio_pipeline_with_wake_word_timeout(
events = []
client = await hass_ws_client(hass)
- await client.send_json_auto_id(
- {
- "type": "assist_pipeline/run",
- "start_stage": "wake_word",
- "end_stage": "tts",
- "input": {
- "sample_rate": SAMPLE_RATE,
- "timeout": 1,
- },
- }
- )
+ with patch(
+ "homeassistant.components.tts.secrets.token_urlsafe", return_value="test_token"
+ ):
+ await client.send_json_auto_id(
+ {
+ "type": "assist_pipeline/run",
+ "start_stage": "wake_word",
+ "end_stage": "tts",
+ "input": {
+ "sample_rate": SAMPLE_RATE,
+ "timeout": 1,
+ },
+ }
+ )
- # result
- msg = await client.receive_json()
- assert msg["success"], msg
+ # result
+ msg = await client.receive_json()
+ assert msg["success"], msg
- # run start
- msg = await client.receive_json()
- assert msg["event"]["type"] == "run-start"
- msg["event"]["data"]["pipeline"] = ANY
- assert msg["event"]["data"] == snapshot
- events.append(msg["event"])
+ # run start
+ msg = await client.receive_json()
+ assert msg["event"]["type"] == "run-start"
+ msg["event"]["data"]["pipeline"] = ANY
+ assert msg["event"]["data"] == snapshot
+ events.append(msg["event"])
- # wake_word
- msg = await client.receive_json()
- assert msg["event"]["type"] == "wake_word-start"
- assert msg["event"]["data"] == snapshot
- events.append(msg["event"])
+ # wake_word
+ msg = await client.receive_json()
+ assert msg["event"]["type"] == "wake_word-start"
+ assert msg["event"]["data"] == snapshot
+ events.append(msg["event"])
- # 2 seconds of silence
- await client.send_bytes(bytes([1]) + bytes(2 * BYTES_ONE_SECOND))
+ # 2 seconds of silence
+ await client.send_bytes(bytes([1]) + bytes(2 * BYTES_ONE_SECOND))
- # Time out error
- msg = await client.receive_json()
- assert msg["event"]["type"] == "error"
- assert msg["event"]["data"] == snapshot
- events.append(msg["event"])
+ # Time out error
+ msg = await client.receive_json()
+ assert msg["event"]["type"] == "error"
+ assert msg["event"]["data"] == snapshot
+ events.append(msg["event"])
- # run end
- msg = await client.receive_json()
- assert msg["event"]["type"] == "run-end"
- assert msg["event"]["data"] == snapshot
- events.append(msg["event"])
+ # run end
+ msg = await client.receive_json()
+ assert msg["event"]["type"] == "run-end"
+ assert msg["event"]["data"] == snapshot
+ events.append(msg["event"])
async def test_audio_pipeline_with_wake_word_no_timeout(
@@ -265,98 +271,101 @@ async def test_audio_pipeline_with_wake_word_no_timeout(
events = []
client = await hass_ws_client(hass)
- await client.send_json_auto_id(
- {
- "type": "assist_pipeline/run",
- "start_stage": "wake_word",
- "end_stage": "tts",
- "input": {"sample_rate": SAMPLE_RATE, "timeout": 0, "no_vad": True},
- }
- )
+ with patch(
+ "homeassistant.components.tts.secrets.token_urlsafe", return_value="test_token"
+ ):
+ await client.send_json_auto_id(
+ {
+ "type": "assist_pipeline/run",
+ "start_stage": "wake_word",
+ "end_stage": "tts",
+ "input": {"sample_rate": SAMPLE_RATE, "timeout": 0, "no_vad": True},
+ }
+ )
- # result
- msg = await client.receive_json()
- assert msg["success"], msg
-
- # run start
- msg = await client.receive_json()
- assert msg["event"]["type"] == "run-start"
- msg["event"]["data"]["pipeline"] = ANY
- assert msg["event"]["data"] == snapshot
- handler_id = msg["event"]["data"]["runner_data"]["stt_binary_handler_id"]
- events.append(msg["event"])
-
- # wake_word
- msg = await client.receive_json()
- assert msg["event"]["type"] == "wake_word-start"
- assert msg["event"]["data"] == snapshot
- events.append(msg["event"])
-
- # "audio"
- await client.send_bytes(bytes([handler_id]) + make_10ms_chunk(b"wake word"))
-
- async with asyncio.timeout(1):
+ # result
msg = await client.receive_json()
- assert msg["event"]["type"] == "wake_word-end"
- assert msg["event"]["data"] == snapshot
- events.append(msg["event"])
+ assert msg["success"], msg
- # stt
- msg = await client.receive_json()
- assert msg["event"]["type"] == "stt-start"
- assert msg["event"]["data"] == snapshot
- events.append(msg["event"])
+ # run start
+ msg = await client.receive_json()
+ assert msg["event"]["type"] == "run-start"
+ msg["event"]["data"]["pipeline"] = ANY
+ assert msg["event"]["data"] == snapshot
+ handler_id = msg["event"]["data"]["runner_data"]["stt_binary_handler_id"]
+ events.append(msg["event"])
- # End of audio stream (handler id + empty payload)
- await client.send_bytes(bytes([handler_id]))
+ # wake_word
+ msg = await client.receive_json()
+ assert msg["event"]["type"] == "wake_word-start"
+ assert msg["event"]["data"] == snapshot
+ events.append(msg["event"])
- msg = await client.receive_json()
- assert msg["event"]["type"] == "stt-end"
- assert msg["event"]["data"] == snapshot
- events.append(msg["event"])
+ # "audio"
+ await client.send_bytes(bytes([handler_id]) + make_10ms_chunk(b"wake word"))
- # intent
- msg = await client.receive_json()
- assert msg["event"]["type"] == "intent-start"
- assert msg["event"]["data"] == snapshot
- events.append(msg["event"])
+ async with asyncio.timeout(1):
+ msg = await client.receive_json()
+ assert msg["event"]["type"] == "wake_word-end"
+ assert msg["event"]["data"] == snapshot
+ events.append(msg["event"])
- msg = await client.receive_json()
- assert msg["event"]["type"] == "intent-end"
- assert msg["event"]["data"] == snapshot
- events.append(msg["event"])
+ # stt
+ msg = await client.receive_json()
+ assert msg["event"]["type"] == "stt-start"
+ assert msg["event"]["data"] == snapshot
+ events.append(msg["event"])
- # text-to-speech
- msg = await client.receive_json()
- assert msg["event"]["type"] == "tts-start"
- assert msg["event"]["data"] == snapshot
- events.append(msg["event"])
+ # End of audio stream (handler id + empty payload)
+ await client.send_bytes(bytes([handler_id]))
- msg = await client.receive_json()
- assert msg["event"]["type"] == "tts-end"
- assert msg["event"]["data"] == snapshot
- events.append(msg["event"])
+ msg = await client.receive_json()
+ assert msg["event"]["type"] == "stt-end"
+ assert msg["event"]["data"] == snapshot
+ events.append(msg["event"])
- # run end
- msg = await client.receive_json()
- assert msg["event"]["type"] == "run-end"
- assert msg["event"]["data"] == snapshot
- events.append(msg["event"])
+ # intent
+ msg = await client.receive_json()
+ assert msg["event"]["type"] == "intent-start"
+ assert msg["event"]["data"] == snapshot
+ events.append(msg["event"])
- pipeline_data: PipelineData = hass.data[DOMAIN]
- pipeline_id = list(pipeline_data.pipeline_debug)[0]
- pipeline_run_id = list(pipeline_data.pipeline_debug[pipeline_id])[0]
+ msg = await client.receive_json()
+ assert msg["event"]["type"] == "intent-end"
+ assert msg["event"]["data"] == snapshot
+ events.append(msg["event"])
- await client.send_json_auto_id(
- {
- "type": "assist_pipeline/pipeline_debug/get",
- "pipeline_id": pipeline_id,
- "pipeline_run_id": pipeline_run_id,
- }
- )
- msg = await client.receive_json()
- assert msg["success"]
- assert msg["result"] == {"events": events}
+ # text-to-speech
+ msg = await client.receive_json()
+ assert msg["event"]["type"] == "tts-start"
+ assert msg["event"]["data"] == snapshot
+ events.append(msg["event"])
+
+ msg = await client.receive_json()
+ assert msg["event"]["type"] == "tts-end"
+ assert msg["event"]["data"] == snapshot
+ events.append(msg["event"])
+
+ # run end
+ msg = await client.receive_json()
+ assert msg["event"]["type"] == "run-end"
+ assert msg["event"]["data"] == snapshot
+ events.append(msg["event"])
+
+ pipeline_data: PipelineData = hass.data[DOMAIN]
+ pipeline_id = list(pipeline_data.pipeline_debug)[0]
+ pipeline_run_id = list(pipeline_data.pipeline_debug[pipeline_id])[0]
+
+ await client.send_json_auto_id(
+ {
+ "type": "assist_pipeline/pipeline_debug/get",
+ "pipeline_id": pipeline_id,
+ "pipeline_run_id": pipeline_run_id,
+ }
+ )
+ msg = await client.receive_json()
+ assert msg["success"]
+ assert msg["result"] == {"events": events}
async def test_audio_pipeline_no_wake_word_engine(
@@ -974,6 +983,7 @@ async def test_add_pipeline(
"tts_voice": "Arnold Schwarzenegger",
"wake_word_entity": "wakeword_entity_1",
"wake_word_id": "wakeword_id_1",
+ "prefer_local_intents": True,
}
)
msg = await client.receive_json()
@@ -991,6 +1001,7 @@ async def test_add_pipeline(
"tts_voice": "Arnold Schwarzenegger",
"wake_word_entity": "wakeword_entity_1",
"wake_word_id": "wakeword_id_1",
+ "prefer_local_intents": True,
}
assert len(pipeline_store.data) == 2
@@ -1008,6 +1019,7 @@ async def test_add_pipeline(
tts_voice="Arnold Schwarzenegger",
wake_word_entity="wakeword_entity_1",
wake_word_id="wakeword_id_1",
+ prefer_local_intents=True,
)
await client.send_json_auto_id(
@@ -1195,6 +1207,7 @@ async def test_get_pipeline(
"tts_voice": "james_earl_jones",
"wake_word_entity": None,
"wake_word_id": None,
+ "prefer_local_intents": False,
}
# Get conversation agent as pipeline
@@ -1220,6 +1233,7 @@ async def test_get_pipeline(
"tts_voice": "james_earl_jones",
"wake_word_entity": None,
"wake_word_id": None,
+ "prefer_local_intents": False,
}
await client.send_json_auto_id(
@@ -1249,6 +1263,7 @@ async def test_get_pipeline(
"tts_voice": "Arnold Schwarzenegger",
"wake_word_entity": "wakeword_entity_1",
"wake_word_id": "wakeword_id_1",
+ "prefer_local_intents": False,
}
)
msg = await client.receive_json()
@@ -1277,6 +1292,7 @@ async def test_get_pipeline(
"tts_voice": "Arnold Schwarzenegger",
"wake_word_entity": "wakeword_entity_1",
"wake_word_id": "wakeword_id_1",
+ "prefer_local_intents": False,
}
@@ -1304,6 +1320,7 @@ async def test_list_pipelines(
"tts_voice": "james_earl_jones",
"wake_word_entity": None,
"wake_word_id": None,
+ "prefer_local_intents": False,
}
],
"preferred_pipeline": ANY,
@@ -1395,6 +1412,7 @@ async def test_update_pipeline(
"tts_voice": "new_tts_voice",
"wake_word_entity": "new_wakeword_entity",
"wake_word_id": "new_wakeword_id",
+ "prefer_local_intents": False,
}
assert len(pipeline_store.data) == 2
@@ -1446,6 +1464,7 @@ async def test_update_pipeline(
"tts_voice": None,
"wake_word_entity": None,
"wake_word_id": None,
+ "prefer_local_intents": False,
}
pipeline = pipeline_store.data[pipeline_id]
@@ -1530,99 +1549,102 @@ async def test_audio_pipeline_debug(
events = []
client = await hass_ws_client(hass)
- await client.send_json_auto_id(
- {
- "type": "assist_pipeline/run",
- "start_stage": "stt",
- "end_stage": "tts",
- "input": {
- "sample_rate": 44100,
- },
- }
- )
+ with patch(
+ "homeassistant.components.tts.secrets.token_urlsafe", return_value="test_token"
+ ):
+ await client.send_json_auto_id(
+ {
+ "type": "assist_pipeline/run",
+ "start_stage": "stt",
+ "end_stage": "tts",
+ "input": {
+ "sample_rate": 44100,
+ },
+ }
+ )
- # result
- msg = await client.receive_json()
- assert msg["success"]
+ # result
+ msg = await client.receive_json()
+ assert msg["success"]
- # run start
- msg = await client.receive_json()
- assert msg["event"]["type"] == "run-start"
- msg["event"]["data"]["pipeline"] = ANY
- assert msg["event"]["data"] == snapshot
- handler_id = msg["event"]["data"]["runner_data"]["stt_binary_handler_id"]
- events.append(msg["event"])
+ # run start
+ msg = await client.receive_json()
+ assert msg["event"]["type"] == "run-start"
+ msg["event"]["data"]["pipeline"] = ANY
+ assert msg["event"]["data"] == snapshot
+ handler_id = msg["event"]["data"]["runner_data"]["stt_binary_handler_id"]
+ events.append(msg["event"])
- # stt
- msg = await client.receive_json()
- assert msg["event"]["type"] == "stt-start"
- assert msg["event"]["data"] == snapshot
- events.append(msg["event"])
+ # stt
+ msg = await client.receive_json()
+ assert msg["event"]["type"] == "stt-start"
+ assert msg["event"]["data"] == snapshot
+ events.append(msg["event"])
- # End of audio stream (handler id + empty payload)
- await client.send_bytes(bytes([handler_id]))
+ # End of audio stream (handler id + empty payload)
+ await client.send_bytes(bytes([handler_id]))
- msg = await client.receive_json()
- assert msg["event"]["type"] == "stt-end"
- assert msg["event"]["data"] == snapshot
- events.append(msg["event"])
+ msg = await client.receive_json()
+ assert msg["event"]["type"] == "stt-end"
+ assert msg["event"]["data"] == snapshot
+ events.append(msg["event"])
- # intent
- msg = await client.receive_json()
- assert msg["event"]["type"] == "intent-start"
- assert msg["event"]["data"] == snapshot
- events.append(msg["event"])
+ # intent
+ msg = await client.receive_json()
+ assert msg["event"]["type"] == "intent-start"
+ assert msg["event"]["data"] == snapshot
+ events.append(msg["event"])
- msg = await client.receive_json()
- assert msg["event"]["type"] == "intent-end"
- assert msg["event"]["data"] == snapshot
- events.append(msg["event"])
+ msg = await client.receive_json()
+ assert msg["event"]["type"] == "intent-end"
+ assert msg["event"]["data"] == snapshot
+ events.append(msg["event"])
- # text-to-speech
- msg = await client.receive_json()
- assert msg["event"]["type"] == "tts-start"
- assert msg["event"]["data"] == snapshot
- events.append(msg["event"])
+ # text-to-speech
+ msg = await client.receive_json()
+ assert msg["event"]["type"] == "tts-start"
+ assert msg["event"]["data"] == snapshot
+ events.append(msg["event"])
- msg = await client.receive_json()
- assert msg["event"]["type"] == "tts-end"
- assert msg["event"]["data"] == snapshot
- events.append(msg["event"])
+ msg = await client.receive_json()
+ assert msg["event"]["type"] == "tts-end"
+ assert msg["event"]["data"] == snapshot
+ events.append(msg["event"])
- # run end
- msg = await client.receive_json()
- assert msg["event"]["type"] == "run-end"
- assert msg["event"]["data"] == snapshot
- events.append(msg["event"])
+ # run end
+ msg = await client.receive_json()
+ assert msg["event"]["type"] == "run-end"
+ assert msg["event"]["data"] == snapshot
+ events.append(msg["event"])
- # Get the id of the pipeline
- await client.send_json_auto_id({"type": "assist_pipeline/pipeline/list"})
- msg = await client.receive_json()
- assert msg["success"]
- assert len(msg["result"]["pipelines"]) == 1
+ # Get the id of the pipeline
+ await client.send_json_auto_id({"type": "assist_pipeline/pipeline/list"})
+ msg = await client.receive_json()
+ assert msg["success"]
+ assert len(msg["result"]["pipelines"]) == 1
- pipeline_id = msg["result"]["pipelines"][0]["id"]
+ pipeline_id = msg["result"]["pipelines"][0]["id"]
- # Get the id for the run
- await client.send_json_auto_id(
- {"type": "assist_pipeline/pipeline_debug/list", "pipeline_id": pipeline_id}
- )
- msg = await client.receive_json()
- assert msg["success"]
- assert msg["result"] == {"pipeline_runs": [ANY]}
+ # Get the id for the run
+ await client.send_json_auto_id(
+ {"type": "assist_pipeline/pipeline_debug/list", "pipeline_id": pipeline_id}
+ )
+ msg = await client.receive_json()
+ assert msg["success"]
+ assert msg["result"] == {"pipeline_runs": [ANY]}
- pipeline_run_id = msg["result"]["pipeline_runs"][0]["pipeline_run_id"]
+ pipeline_run_id = msg["result"]["pipeline_runs"][0]["pipeline_run_id"]
- await client.send_json_auto_id(
- {
- "type": "assist_pipeline/pipeline_debug/get",
- "pipeline_id": pipeline_id,
- "pipeline_run_id": pipeline_run_id,
- }
- )
- msg = await client.receive_json()
- assert msg["success"]
- assert msg["result"] == {"events": events}
+ await client.send_json_auto_id(
+ {
+ "type": "assist_pipeline/pipeline_debug/get",
+ "pipeline_id": pipeline_id,
+ "pipeline_run_id": pipeline_run_id,
+ }
+ )
+ msg = await client.receive_json()
+ assert msg["success"]
+ assert msg["result"] == {"events": events}
async def test_pipeline_debug_list_runs_wrong_pipeline(
@@ -1777,94 +1799,97 @@ async def test_audio_pipeline_with_enhancements(
events = []
client = await hass_ws_client(hass)
- await client.send_json_auto_id(
- {
- "type": "assist_pipeline/run",
- "start_stage": "stt",
- "end_stage": "tts",
- "input": {
- "sample_rate": SAMPLE_RATE,
- # Enhancements
- "noise_suppression_level": 2,
- "auto_gain_dbfs": 15,
- "volume_multiplier": 2.0,
- },
- }
- )
+ with patch(
+ "homeassistant.components.tts.secrets.token_urlsafe", return_value="test_token"
+ ):
+ await client.send_json_auto_id(
+ {
+ "type": "assist_pipeline/run",
+ "start_stage": "stt",
+ "end_stage": "tts",
+ "input": {
+ "sample_rate": SAMPLE_RATE,
+ # Enhancements
+ "noise_suppression_level": 2,
+ "auto_gain_dbfs": 15,
+ "volume_multiplier": 2.0,
+ },
+ }
+ )
- # result
- msg = await client.receive_json()
- assert msg["success"]
+ # result
+ msg = await client.receive_json()
+ assert msg["success"]
- # run start
- msg = await client.receive_json()
- assert msg["event"]["type"] == "run-start"
- msg["event"]["data"]["pipeline"] = ANY
- assert msg["event"]["data"] == snapshot
- handler_id = msg["event"]["data"]["runner_data"]["stt_binary_handler_id"]
- events.append(msg["event"])
+ # run start
+ msg = await client.receive_json()
+ assert msg["event"]["type"] == "run-start"
+ msg["event"]["data"]["pipeline"] = ANY
+ assert msg["event"]["data"] == snapshot
+ handler_id = msg["event"]["data"]["runner_data"]["stt_binary_handler_id"]
+ events.append(msg["event"])
- # stt
- msg = await client.receive_json()
- assert msg["event"]["type"] == "stt-start"
- assert msg["event"]["data"] == snapshot
- events.append(msg["event"])
+ # stt
+ msg = await client.receive_json()
+ assert msg["event"]["type"] == "stt-start"
+ assert msg["event"]["data"] == snapshot
+ events.append(msg["event"])
- # One second of silence.
- # This will pass through the audio enhancement pipeline, but we don't test
- # the actual output.
- await client.send_bytes(bytes([handler_id]) + bytes(BYTES_ONE_SECOND))
+ # One second of silence.
+ # This will pass through the audio enhancement pipeline, but we don't test
+ # the actual output.
+ await client.send_bytes(bytes([handler_id]) + bytes(BYTES_ONE_SECOND))
- # End of audio stream (handler id + empty payload)
- await client.send_bytes(bytes([handler_id]))
+ # End of audio stream (handler id + empty payload)
+ await client.send_bytes(bytes([handler_id]))
- msg = await client.receive_json()
- assert msg["event"]["type"] == "stt-end"
- assert msg["event"]["data"] == snapshot
- events.append(msg["event"])
+ msg = await client.receive_json()
+ assert msg["event"]["type"] == "stt-end"
+ assert msg["event"]["data"] == snapshot
+ events.append(msg["event"])
- # intent
- msg = await client.receive_json()
- assert msg["event"]["type"] == "intent-start"
- assert msg["event"]["data"] == snapshot
- events.append(msg["event"])
+ # intent
+ msg = await client.receive_json()
+ assert msg["event"]["type"] == "intent-start"
+ assert msg["event"]["data"] == snapshot
+ events.append(msg["event"])
- msg = await client.receive_json()
- assert msg["event"]["type"] == "intent-end"
- assert msg["event"]["data"] == snapshot
- events.append(msg["event"])
+ msg = await client.receive_json()
+ assert msg["event"]["type"] == "intent-end"
+ assert msg["event"]["data"] == snapshot
+ events.append(msg["event"])
- # text-to-speech
- msg = await client.receive_json()
- assert msg["event"]["type"] == "tts-start"
- assert msg["event"]["data"] == snapshot
- events.append(msg["event"])
+ # text-to-speech
+ msg = await client.receive_json()
+ assert msg["event"]["type"] == "tts-start"
+ assert msg["event"]["data"] == snapshot
+ events.append(msg["event"])
- msg = await client.receive_json()
- assert msg["event"]["type"] == "tts-end"
- assert msg["event"]["data"] == snapshot
- events.append(msg["event"])
+ msg = await client.receive_json()
+ assert msg["event"]["type"] == "tts-end"
+ assert msg["event"]["data"] == snapshot
+ events.append(msg["event"])
- # run end
- msg = await client.receive_json()
- assert msg["event"]["type"] == "run-end"
- assert msg["event"]["data"] == snapshot
- events.append(msg["event"])
+ # run end
+ msg = await client.receive_json()
+ assert msg["event"]["type"] == "run-end"
+ assert msg["event"]["data"] == snapshot
+ events.append(msg["event"])
- pipeline_data: PipelineData = hass.data[DOMAIN]
- pipeline_id = list(pipeline_data.pipeline_debug)[0]
- pipeline_run_id = list(pipeline_data.pipeline_debug[pipeline_id])[0]
+ pipeline_data: PipelineData = hass.data[DOMAIN]
+ pipeline_id = list(pipeline_data.pipeline_debug)[0]
+ pipeline_run_id = list(pipeline_data.pipeline_debug[pipeline_id])[0]
- await client.send_json_auto_id(
- {
- "type": "assist_pipeline/pipeline_debug/get",
- "pipeline_id": pipeline_id,
- "pipeline_run_id": pipeline_run_id,
- }
- )
- msg = await client.receive_json()
- assert msg["success"]
- assert msg["result"] == {"events": events}
+ await client.send_json_auto_id(
+ {
+ "type": "assist_pipeline/pipeline_debug/get",
+ "pipeline_id": pipeline_id,
+ "pipeline_run_id": pipeline_run_id,
+ }
+ )
+ msg = await client.receive_json()
+ assert msg["success"]
+ assert msg["result"] == {"events": events}
async def test_wake_word_cooldown_same_id(
diff --git a/tests/components/atag/test_sensors.py b/tests/components/atag/test_sensor.py
similarity index 100%
rename from tests/components/atag/test_sensors.py
rename to tests/components/atag/test_sensor.py
diff --git a/tests/components/august/test_lock.py b/tests/components/august/test_lock.py
index 1b8c98e299c..eb177a35cfb 100644
--- a/tests/components/august/test_lock.py
+++ b/tests/components/august/test_lock.py
@@ -20,8 +20,9 @@ from homeassistant.const import (
STATE_UNKNOWN,
)
from homeassistant.core import HomeAssistant
-from homeassistant.exceptions import HomeAssistantError
+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 .mocks import (
@@ -453,8 +454,9 @@ async def test_open_throws_hass_service_not_supported_error(
hass: HomeAssistant,
) -> None:
"""Test open throws correct error on entity does not support this service error."""
+ await async_setup_component(hass, "homeassistant", {})
mocked_lock_detail = await _mock_operative_august_lock_detail(hass)
await _create_august_with_devices(hass, [mocked_lock_detail])
data = {ATTR_ENTITY_ID: "lock.a6697750d607098bae8d6baa11ef8063_name"}
- with pytest.raises(HomeAssistantError, match="does not support this service"):
+ with pytest.raises(ServiceNotSupported, match="does not support action"):
await hass.services.async_call(LOCK_DOMAIN, SERVICE_OPEN, data, blocking=True)
diff --git a/tests/components/autarco/test_config_flow.py b/tests/components/autarco/test_config_flow.py
index 621ad7f55c8..47c6a2fb084 100644
--- a/tests/components/autarco/test_config_flow.py
+++ b/tests/components/autarco/test_config_flow.py
@@ -1,6 +1,6 @@
"""Test the Autarco config flow."""
-from unittest.mock import AsyncMock
+from unittest.mock import AsyncMock, patch
from autarco import AutarcoAuthenticationError, AutarcoConnectionError
import pytest
@@ -92,6 +92,7 @@ async def test_exceptions(
assert result.get("type") is FlowResultType.FORM
assert result.get("errors") == {"base": error}
+ # Recover from error
mock_autarco_client.get_account.side_effect = None
result = await hass.config_entries.flow.async_configure(
@@ -99,3 +100,72 @@ async def test_exceptions(
user_input={CONF_EMAIL: "test@autarco.com", CONF_PASSWORD: "test-password"},
)
assert result.get("type") is FlowResultType.CREATE_ENTRY
+
+
+async def test_step_reauth(
+ hass: HomeAssistant,
+ mock_config_entry: MockConfigEntry,
+ mock_setup_entry: AsyncMock,
+) -> None:
+ """Test reauth flow."""
+ mock_config_entry.add_to_hass(hass)
+ result = await mock_config_entry.start_reauth_flow(hass)
+
+ assert result.get("type") is FlowResultType.FORM
+ assert result.get("step_id") == "reauth_confirm"
+
+ with patch("homeassistant.components.autarco.config_flow.Autarco", autospec=True):
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ user_input={CONF_PASSWORD: "new-password"},
+ )
+
+ assert result.get("type") is FlowResultType.ABORT
+ assert result.get("reason") == "reauth_successful"
+
+ assert len(hass.config_entries.async_entries()) == 1
+ assert mock_config_entry.data[CONF_PASSWORD] == "new-password"
+
+
+@pytest.mark.parametrize(
+ ("exception", "error"),
+ [
+ (AutarcoConnectionError, "cannot_connect"),
+ (AutarcoAuthenticationError, "invalid_auth"),
+ ],
+)
+async def test_step_reauth_exceptions(
+ hass: HomeAssistant,
+ mock_autarco_client: AsyncMock,
+ mock_config_entry: MockConfigEntry,
+ mock_setup_entry: AsyncMock,
+ exception: Exception,
+ error: str,
+) -> None:
+ """Test exceptions in reauth flow."""
+ mock_autarco_client.get_account.side_effect = exception
+ mock_config_entry.add_to_hass(hass)
+ result = await mock_config_entry.start_reauth_flow(hass)
+
+ assert result.get("type") is FlowResultType.FORM
+ assert result.get("step_id") == "reauth_confirm"
+
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ user_input={CONF_PASSWORD: "new-password"},
+ )
+ assert result.get("type") is FlowResultType.FORM
+ assert result.get("errors") == {"base": error}
+
+ # Recover from error
+ mock_autarco_client.get_account.side_effect = None
+
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ user_input={CONF_PASSWORD: "new-password"},
+ )
+ assert result.get("type") is FlowResultType.ABORT
+ assert result.get("reason") == "reauth_successful"
+
+ assert len(hass.config_entries.async_entries()) == 1
+ assert mock_config_entry.data[CONF_PASSWORD] == "new-password"
diff --git a/tests/components/autarco/test_init.py b/tests/components/autarco/test_init.py
index 81c5f947251..6c71eca5ef1 100644
--- a/tests/components/autarco/test_init.py
+++ b/tests/components/autarco/test_init.py
@@ -4,6 +4,8 @@ from __future__ import annotations
from unittest.mock import AsyncMock
+from autarco import AutarcoAuthenticationError, AutarcoConnectionError
+
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
@@ -26,3 +28,35 @@ async def test_load_unload_entry(
await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.NOT_LOADED
+
+
+async def test_config_entry_not_ready(
+ hass: HomeAssistant,
+ mock_autarco_client: AsyncMock,
+ mock_config_entry: MockConfigEntry,
+) -> None:
+ """Test the Autarco configuration entry not ready."""
+ mock_autarco_client.get_account.side_effect = AutarcoConnectionError
+ mock_config_entry.add_to_hass(hass)
+
+ await hass.config_entries.async_setup(mock_config_entry.entry_id)
+ await hass.async_block_till_done()
+
+ assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
+
+
+async def test_setup_entry_exception(
+ hass: HomeAssistant,
+ mock_autarco_client: AsyncMock,
+ mock_config_entry: MockConfigEntry,
+) -> None:
+ """Test ConfigEntryNotReady when API raises an exception during entry setup."""
+ mock_config_entry.add_to_hass(hass)
+ mock_autarco_client.get_site.side_effect = AutarcoAuthenticationError
+
+ await hass.config_entries.async_setup(mock_config_entry.entry_id)
+ assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR
+
+ flows = hass.config_entries.flow.async_progress()
+ assert len(flows) == 1
+ assert flows[0]["step_id"] == "reauth_confirm"
diff --git a/tests/components/automation/test_init.py b/tests/components/automation/test_init.py
index 2bdc0f7516b..98d8bf0396e 100644
--- a/tests/components/automation/test_init.py
+++ b/tests/components/automation/test_init.py
@@ -50,7 +50,6 @@ from homeassistant.helpers.script import (
SCRIPT_MODE_SINGLE,
_async_stop_scripts_at_shutdown,
)
-from homeassistant.helpers.trigger import TriggerActionType, TriggerData, TriggerInfo
from homeassistant.setup import async_setup_component
from homeassistant.util import yaml
import homeassistant.util.dt as dt_util
@@ -62,8 +61,6 @@ from tests.common import (
async_capture_events,
async_fire_time_changed,
async_mock_service,
- help_test_all,
- import_and_test_deprecated_constant,
mock_restore_cache,
)
from tests.components.logbook.common import MockRow, mock_humanify
@@ -3153,30 +3150,6 @@ async def test_websocket_config(
assert msg["error"]["code"] == "not_found"
-def test_all() -> None:
- """Test module.__all__ is correctly set."""
- help_test_all(automation)
-
-
-@pytest.mark.parametrize(
- ("constant_name", "replacement"),
- [
- ("AutomationActionType", TriggerActionType),
- ("AutomationTriggerData", TriggerData),
- ("AutomationTriggerInfo", TriggerInfo),
- ],
-)
-def test_deprecated_constants(
- caplog: pytest.LogCaptureFixture,
- constant_name: str,
- replacement: Any,
-) -> None:
- """Test deprecated automation constants."""
- import_and_test_deprecated_constant(
- caplog, automation, constant_name, replacement.__name__, replacement, "2025.1"
- )
-
-
async def test_automation_turns_off_other_automation(hass: HomeAssistant) -> None:
"""Test an automation that turns off another automation."""
hass.set_state(CoreState.not_running)
diff --git a/tests/components/azure_data_explorer/test_config_flow.py b/tests/components/azure_data_explorer/test_config_flow.py
index a700299be33..13ff6a8bb13 100644
--- a/tests/components/azure_data_explorer/test_config_flow.py
+++ b/tests/components/azure_data_explorer/test_config_flow.py
@@ -25,7 +25,7 @@ async def test_config_flow(hass: HomeAssistant, mock_setup_entry: AsyncMock) ->
BASE_CONFIG.copy(),
)
- assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+ assert result2["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY
assert result2["title"] == "cluster.region.kusto.windows.net"
mock_setup_entry.assert_called_once()
@@ -59,12 +59,12 @@ async def test_config_flow_errors(
result["flow_id"],
BASE_CONFIG.copy(),
)
- assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM
+ assert result2["type"] == data_entry_flow.FlowResultType.FORM
assert result2["errors"] == {"base": expected}
await hass.async_block_till_done()
- assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM
+ assert result2["type"] == data_entry_flow.FlowResultType.FORM
# Retest error handling if error is corrected and connection is successful
@@ -77,4 +77,4 @@ async def test_config_flow_errors(
await hass.async_block_till_done()
- assert result3["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+ assert result3["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY
diff --git a/tests/components/azure_event_hub/test_init.py b/tests/components/azure_event_hub/test_init.py
index 1b0550b147b..5ffc6106c11 100644
--- a/tests/components/azure_event_hub/test_init.py
+++ b/tests/components/azure_event_hub/test_init.py
@@ -112,6 +112,7 @@ async def test_send_batch_error(
)
await hass.async_block_till_done()
mock_send_batch.assert_called_once()
+ mock_send_batch.side_effect = None # Reset to avoid error in teardown
async def test_late_event(
diff --git a/tests/components/backup/common.py b/tests/components/backup/common.py
index 70b33d2de3f..4f456cc6d72 100644
--- a/tests/components/backup/common.py
+++ b/tests/components/backup/common.py
@@ -2,29 +2,179 @@
from __future__ import annotations
+from collections.abc import AsyncIterator, Callable, Coroutine
from pathlib import Path
-from unittest.mock import patch
+from typing import Any
+from unittest.mock import ANY, AsyncMock, Mock, patch
-from homeassistant.components.backup import DOMAIN
-from homeassistant.components.backup.manager import Backup
+from homeassistant.components.backup import (
+ DOMAIN,
+ AddonInfo,
+ AgentBackup,
+ BackupAgent,
+ BackupAgentPlatformProtocol,
+ Folder,
+)
+from homeassistant.components.backup.const import DATA_MANAGER
from homeassistant.core import HomeAssistant
from homeassistant.helpers.typing import ConfigType
from homeassistant.setup import async_setup_component
-TEST_BACKUP = Backup(
- slug="abc123",
- name="Test",
+from tests.common import MockPlatform, mock_platform
+
+LOCAL_AGENT_ID = f"{DOMAIN}.local"
+
+TEST_BACKUP_ABC123 = AgentBackup(
+ addons=[AddonInfo(name="Test", slug="test", version="1.0.0")],
+ backup_id="abc123",
+ database_included=True,
date="1970-01-01T00:00:00.000Z",
- path=Path("abc123.tar"),
- size=0.0,
+ extra_metadata={"instance_id": ANY, "with_automatic_settings": True},
+ folders=[Folder.MEDIA, Folder.SHARE],
+ homeassistant_included=True,
+ homeassistant_version="2024.12.0",
+ name="Test",
+ protected=False,
+ size=0,
)
+TEST_BACKUP_PATH_ABC123 = Path("abc123.tar")
+
+TEST_BACKUP_DEF456 = AgentBackup(
+ addons=[],
+ backup_id="def456",
+ database_included=False,
+ date="1980-01-01T00:00:00.000Z",
+ extra_metadata={"instance_id": "unknown_uuid", "with_automatic_settings": True},
+ folders=[Folder.MEDIA, Folder.SHARE],
+ homeassistant_included=True,
+ homeassistant_version="2024.12.0",
+ name="Test 2",
+ protected=False,
+ size=1,
+)
+
+TEST_DOMAIN = "test"
+
+
+class BackupAgentTest(BackupAgent):
+ """Test backup agent."""
+
+ domain = "test"
+
+ def __init__(self, name: str, backups: list[AgentBackup] | None = None) -> None:
+ """Initialize the backup agent."""
+ self.name = name
+ if backups is None:
+ backups = [
+ 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=False,
+ size=13,
+ )
+ ]
+
+ self._backup_data: bytearray | None = None
+ self._backups = {backup.backup_id: backup for backup in backups}
+
+ async def async_download_backup(
+ self,
+ backup_id: str,
+ **kwargs: Any,
+ ) -> AsyncIterator[bytes]:
+ """Download a backup file."""
+ return AsyncMock(spec_set=["__aiter__"])
+
+ async def async_upload_backup(
+ self,
+ *,
+ open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]],
+ backup: AgentBackup,
+ **kwargs: Any,
+ ) -> None:
+ """Upload a backup."""
+ self._backups[backup.backup_id] = backup
+ backup_stream = await open_stream()
+ self._backup_data = bytearray()
+ async for chunk in backup_stream:
+ self._backup_data += chunk
+
+ async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]:
+ """List backups."""
+ return list(self._backups.values())
+
+ async def async_get_backup(
+ self,
+ backup_id: str,
+ **kwargs: Any,
+ ) -> AgentBackup | None:
+ """Return a backup."""
+ return self._backups.get(backup_id)
+
+ async def async_delete_backup(
+ self,
+ backup_id: str,
+ **kwargs: Any,
+ ) -> None:
+ """Delete a backup file."""
async def setup_backup_integration(
hass: HomeAssistant,
with_hassio: bool = False,
configuration: ConfigType | None = None,
+ *,
+ backups: dict[str, list[AgentBackup]] | None = None,
+ remote_agents: list[str] | None = None,
) -> bool:
"""Set up the Backup integration."""
- with patch("homeassistant.components.backup.is_hassio", return_value=with_hassio):
- return await async_setup_component(hass, DOMAIN, configuration or {})
+ with (
+ patch("homeassistant.components.backup.is_hassio", return_value=with_hassio),
+ patch(
+ "homeassistant.components.backup.backup.is_hassio", return_value=with_hassio
+ ),
+ ):
+ remote_agents = remote_agents or []
+ platform = Mock(
+ async_get_backup_agents=AsyncMock(
+ return_value=[BackupAgentTest(agent, []) for agent in remote_agents]
+ ),
+ spec_set=BackupAgentPlatformProtocol,
+ )
+
+ mock_platform(hass, f"{TEST_DOMAIN}.backup", platform or MockPlatform())
+ assert await async_setup_component(hass, TEST_DOMAIN, {})
+
+ result = await async_setup_component(hass, DOMAIN, configuration or {})
+ await hass.async_block_till_done()
+ if not backups:
+ return result
+
+ for agent_id, agent_backups in backups.items():
+ 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}
+ if agent_id == LOCAL_AGENT_ID:
+ agent._loaded_backups = True
+
+ return result
+
+
+async def setup_backup_platform(
+ hass: HomeAssistant,
+ *,
+ domain: str,
+ platform: Any,
+) -> None:
+ """Set up a mock domain."""
+ mock_platform(hass, f"{domain}.backup", platform)
+ assert await async_setup_component(hass, domain, {})
+ await hass.async_block_till_done()
diff --git a/tests/components/backup/conftest.py b/tests/components/backup/conftest.py
new file mode 100644
index 00000000000..ee855fb70f2
--- /dev/null
+++ b/tests/components/backup/conftest.py
@@ -0,0 +1,115 @@
+"""Test fixtures for the Backup integration."""
+
+from __future__ import annotations
+
+from asyncio import Future
+from collections.abc import Generator
+from pathlib import Path
+from unittest.mock import AsyncMock, MagicMock, Mock, patch
+
+import pytest
+
+from homeassistant.components.backup.manager import NewBackup, WrittenBackup
+from homeassistant.core import HomeAssistant
+
+from .common import TEST_BACKUP_PATH_ABC123
+
+
+@pytest.fixture(name="mocked_json_bytes")
+def mocked_json_bytes_fixture() -> Generator[Mock]:
+ """Mock json_bytes."""
+ with patch(
+ "homeassistant.components.backup.manager.json_bytes",
+ return_value=b"{}", # Empty JSON
+ ) as mocked_json_bytes:
+ yield mocked_json_bytes
+
+
+@pytest.fixture(name="mocked_tarfile")
+def mocked_tarfile_fixture() -> Generator[Mock]:
+ """Mock tarfile."""
+ with patch(
+ "homeassistant.components.backup.manager.SecureTarFile"
+ ) as mocked_tarfile:
+ yield mocked_tarfile
+
+
+@pytest.fixture(name="path_glob")
+def path_glob_fixture() -> Generator[MagicMock]:
+ """Mock path glob."""
+ with patch(
+ "pathlib.Path.glob", return_value=[TEST_BACKUP_PATH_ABC123]
+ ) as path_glob:
+ yield path_glob
+
+
+CONFIG_DIR = {
+ "testing_config": [
+ Path("test.txt"),
+ Path(".DS_Store"),
+ Path(".storage"),
+ Path("backups"),
+ Path("tmp_backups"),
+ Path("home-assistant_v2.db"),
+ ],
+ "backups": [
+ Path("backups/backup.tar"),
+ Path("backups/not_backup"),
+ ],
+ "tmp_backups": [
+ Path("tmp_backups/forgotten_backup.tar"),
+ Path("tmp_backups/not_backup"),
+ ],
+}
+CONFIG_DIR_DIRS = {Path(".storage"), Path("backups"), Path("tmp_backups")}
+
+
+@pytest.fixture(name="create_backup")
+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.open_stream = AsyncMock()
+ mock_written_backup.release_stream = AsyncMock()
+ fut = Future()
+ fut.set_result(mock_written_backup)
+ with patch(
+ "homeassistant.components.backup.CoreBackupReaderWriter.async_create_backup"
+ ) as mock_create_backup:
+ mock_create_backup.return_value = (NewBackup(backup_job_id="abc123"), fut)
+ yield mock_create_backup
+
+
+@pytest.fixture(name="mock_backup_generation")
+def mock_backup_generation_fixture(
+ hass: HomeAssistant, mocked_json_bytes: Mock, mocked_tarfile: Mock
+) -> Generator[None]:
+ """Mock backup generator."""
+
+ with (
+ patch("pathlib.Path.iterdir", lambda x: CONFIG_DIR.get(x.name, [])),
+ patch("pathlib.Path.stat", return_value=MagicMock(st_size=123)),
+ patch("pathlib.Path.is_file", lambda x: x not in CONFIG_DIR_DIRS),
+ patch("pathlib.Path.is_dir", lambda x: x in CONFIG_DIR_DIRS),
+ patch(
+ "pathlib.Path.exists",
+ lambda x: x
+ not in (
+ Path(hass.config.path("backups")),
+ Path(hass.config.path("tmp_backups")),
+ ),
+ ),
+ patch(
+ "pathlib.Path.is_symlink",
+ lambda _: False,
+ ),
+ patch(
+ "pathlib.Path.mkdir",
+ MagicMock(),
+ ),
+ patch(
+ "homeassistant.components.backup.manager.HAVERSION",
+ "2025.1.0",
+ ),
+ ):
+ yield
diff --git a/tests/components/backup/snapshots/test_backup.ambr b/tests/components/backup/snapshots/test_backup.ambr
new file mode 100644
index 00000000000..f21de9d9fad
--- /dev/null
+++ b/tests/components/backup/snapshots/test_backup.ambr
@@ -0,0 +1,206 @@
+# serializer version: 1
+# name: test_delete_backup[found_backups0-True-1]
+ dict({
+ 'id': 1,
+ 'result': dict({
+ 'agent_errors': dict({
+ }),
+ }),
+ 'success': True,
+ 'type': 'result',
+ })
+# ---
+# name: test_delete_backup[found_backups1-False-0]
+ dict({
+ 'id': 1,
+ 'result': dict({
+ 'agent_errors': dict({
+ }),
+ }),
+ 'success': True,
+ 'type': 'result',
+ })
+# ---
+# name: test_delete_backup[found_backups2-True-0]
+ dict({
+ 'id': 1,
+ 'result': dict({
+ 'agent_errors': dict({
+ }),
+ }),
+ 'success': True,
+ 'type': 'result',
+ })
+# ---
+# name: test_load_backups[None]
+ dict({
+ 'id': 1,
+ 'result': dict({
+ 'agents': list([
+ dict({
+ 'agent_id': 'backup.local',
+ }),
+ ]),
+ }),
+ 'success': True,
+ 'type': 'result',
+ })
+# ---
+# name: test_load_backups[None].1
+ dict({
+ 'id': 2,
+ 'result': dict({
+ 'agent_errors': dict({
+ }),
+ 'backups': list([
+ dict({
+ 'addons': list([
+ dict({
+ 'name': 'Test',
+ 'slug': 'test',
+ 'version': '1.0.0',
+ }),
+ ]),
+ 'agent_ids': list([
+ 'backup.local',
+ ]),
+ 'backup_id': 'abc123',
+ 'database_included': True,
+ 'date': '1970-01-01T00:00:00.000Z',
+ 'failed_agent_ids': list([
+ ]),
+ 'folders': list([
+ 'media',
+ 'share',
+ ]),
+ '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,
+ }),
+ 'success': True,
+ 'type': 'result',
+ })
+# ---
+# name: test_load_backups[side_effect1]
+ dict({
+ 'id': 1,
+ 'result': dict({
+ 'agents': list([
+ dict({
+ 'agent_id': 'backup.local',
+ }),
+ ]),
+ }),
+ 'success': True,
+ 'type': 'result',
+ })
+# ---
+# name: test_load_backups[side_effect1].1
+ dict({
+ 'id': 2,
+ 'result': dict({
+ 'agent_errors': dict({
+ }),
+ 'backups': list([
+ ]),
+ 'last_attempted_automatic_backup': None,
+ 'last_completed_automatic_backup': None,
+ }),
+ 'success': True,
+ 'type': 'result',
+ })
+# ---
+# name: test_load_backups[side_effect2]
+ dict({
+ 'id': 1,
+ 'result': dict({
+ 'agents': list([
+ dict({
+ 'agent_id': 'backup.local',
+ }),
+ ]),
+ }),
+ 'success': True,
+ 'type': 'result',
+ })
+# ---
+# name: test_load_backups[side_effect2].1
+ dict({
+ 'id': 2,
+ 'result': dict({
+ 'agent_errors': dict({
+ }),
+ 'backups': list([
+ ]),
+ 'last_attempted_automatic_backup': None,
+ 'last_completed_automatic_backup': None,
+ }),
+ 'success': True,
+ 'type': 'result',
+ })
+# ---
+# name: test_load_backups[side_effect3]
+ dict({
+ 'id': 1,
+ 'result': dict({
+ 'agents': list([
+ dict({
+ 'agent_id': 'backup.local',
+ }),
+ ]),
+ }),
+ 'success': True,
+ 'type': 'result',
+ })
+# ---
+# name: test_load_backups[side_effect3].1
+ dict({
+ 'id': 2,
+ 'result': dict({
+ 'agent_errors': dict({
+ }),
+ 'backups': list([
+ ]),
+ 'last_attempted_automatic_backup': None,
+ 'last_completed_automatic_backup': None,
+ }),
+ 'success': True,
+ 'type': 'result',
+ })
+# ---
+# name: test_load_backups[side_effect4]
+ dict({
+ 'id': 1,
+ 'result': dict({
+ 'agents': list([
+ dict({
+ 'agent_id': 'backup.local',
+ }),
+ ]),
+ }),
+ 'success': True,
+ 'type': 'result',
+ })
+# ---
+# name: test_load_backups[side_effect4].1
+ dict({
+ 'id': 2,
+ 'result': dict({
+ 'agent_errors': dict({
+ }),
+ 'backups': list([
+ ]),
+ 'last_attempted_automatic_backup': None,
+ 'last_completed_automatic_backup': None,
+ }),
+ 'success': True,
+ 'type': 'result',
+ })
+# ---
diff --git a/tests/components/backup/snapshots/test_websocket.ambr b/tests/components/backup/snapshots/test_websocket.ambr
index 096df37d704..98b2f764d43 100644
--- a/tests/components/backup/snapshots/test_websocket.ambr
+++ b/tests/components/backup/snapshots/test_websocket.ambr
@@ -1,4 +1,32 @@
# serializer version: 1
+# name: test_agent_delete_backup
+ dict({
+ 'id': 1,
+ 'result': dict({
+ 'agent_errors': dict({
+ }),
+ }),
+ 'success': True,
+ 'type': 'result',
+ })
+# ---
+# name: test_agents_info
+ dict({
+ 'id': 1,
+ 'result': dict({
+ 'agents': list([
+ dict({
+ 'agent_id': 'backup.local',
+ }),
+ dict({
+ 'agent_id': 'domain.test',
+ }),
+ ]),
+ }),
+ 'success': True,
+ 'type': 'result',
+ })
+# ---
# name: test_backup_end[with_hassio-hass_access_token]
dict({
'error': dict({
@@ -40,7 +68,7 @@
'type': 'result',
})
# ---
-# name: test_backup_end_excepion[exception0]
+# name: test_backup_end_exception[exception0]
dict({
'error': dict({
'code': 'post_backup_actions_failed',
@@ -51,7 +79,7 @@
'type': 'result',
})
# ---
-# name: test_backup_end_excepion[exception1]
+# name: test_backup_end_exception[exception1]
dict({
'error': dict({
'code': 'post_backup_actions_failed',
@@ -62,7 +90,7 @@
'type': 'result',
})
# ---
-# name: test_backup_end_excepion[exception2]
+# name: test_backup_end_exception[exception2]
dict({
'error': dict({
'code': 'post_backup_actions_failed',
@@ -114,7 +142,7 @@
'type': 'result',
})
# ---
-# name: test_backup_start_excepion[exception0]
+# name: test_backup_start_exception[exception0]
dict({
'error': dict({
'code': 'pre_backup_actions_failed',
@@ -125,7 +153,7 @@
'type': 'result',
})
# ---
-# name: test_backup_start_excepion[exception1]
+# name: test_backup_start_exception[exception1]
dict({
'error': dict({
'code': 'pre_backup_actions_failed',
@@ -136,7 +164,7 @@
'type': 'result',
})
# ---
-# name: test_backup_start_excepion[exception2]
+# name: test_backup_start_exception[exception2]
dict({
'error': dict({
'code': 'pre_backup_actions_failed',
@@ -147,121 +175,2846 @@
'type': 'result',
})
# ---
-# name: test_details[with_hassio-with_backup_content]
- dict({
- 'error': dict({
- 'code': 'unknown_command',
- 'message': 'Unknown command.',
- }),
- 'id': 1,
- 'success': False,
- 'type': 'result',
- })
-# ---
-# name: test_details[with_hassio-without_backup_content]
- dict({
- 'error': dict({
- 'code': 'unknown_command',
- 'message': 'Unknown command.',
- }),
- 'id': 1,
- 'success': False,
- 'type': 'result',
- })
-# ---
-# name: test_details[without_hassio-with_backup_content]
+# name: test_config_info[None]
dict({
'id': 1,
'result': dict({
- 'backup': dict({
- 'date': '1970-01-01T00:00:00.000Z',
- 'name': 'Test',
- 'path': 'abc123.tar',
- 'size': 0.0,
- 'slug': 'abc123',
+ '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_details[without_hassio-without_backup_content]
+# name: test_config_info[storage_data1]
dict({
'id': 1,
'result': dict({
+ 'config': dict({
+ 'create_backup': dict({
+ 'agent_ids': list([
+ 'test-agent',
+ ]),
+ 'include_addons': list([
+ 'test-addon',
+ ]),
+ 'include_all_addons': True,
+ 'include_database': True,
+ 'include_folders': list([
+ 'media',
+ ]),
+ 'name': 'test-name',
+ 'password': 'test-password',
+ }),
+ 'last_attempted_automatic_backup': '2024-10-26T04:45:00+01:00',
+ 'last_completed_automatic_backup': '2024-10-26T04:45:00+01:00',
+ 'retention': dict({
+ 'copies': 3,
+ 'days': 7,
+ }),
+ 'schedule': dict({
+ 'state': 'daily',
+ }),
+ }),
+ }),
+ 'success': True,
+ 'type': 'result',
+ })
+# ---
+# name: test_config_info[storage_data2]
+ dict({
+ 'id': 1,
+ 'result': dict({
+ 'config': 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,
+ 'retention': dict({
+ 'copies': 3,
+ 'days': None,
+ }),
+ 'schedule': dict({
+ 'state': 'never',
+ }),
+ }),
+ }),
+ 'success': True,
+ 'type': 'result',
+ })
+# ---
+# name: test_config_info[storage_data3]
+ dict({
+ 'id': 1,
+ 'result': dict({
+ 'config': 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': '2024-10-27T04:45:00+01:00',
+ 'last_completed_automatic_backup': '2024-10-26T04:45:00+01:00',
+ 'retention': dict({
+ 'copies': None,
+ 'days': 7,
+ }),
+ 'schedule': dict({
+ 'state': 'never',
+ }),
+ }),
+ }),
+ 'success': True,
+ 'type': 'result',
+ })
+# ---
+# name: test_config_info[storage_data4]
+ dict({
+ 'id': 1,
+ 'result': dict({
+ 'config': 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,
+ 'retention': dict({
+ 'copies': None,
+ 'days': None,
+ }),
+ 'schedule': dict({
+ 'state': 'mon',
+ }),
+ }),
+ }),
+ 'success': True,
+ 'type': 'result',
+ })
+# ---
+# name: test_config_info[storage_data5]
+ dict({
+ 'id': 1,
+ 'result': dict({
+ 'config': 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,
+ 'retention': dict({
+ 'copies': None,
+ 'days': None,
+ }),
+ 'schedule': dict({
+ 'state': 'sat',
+ }),
+ }),
+ }),
+ 'success': True,
+ 'type': 'result',
+ })
+# ---
+# name: test_config_update[command0]
+ 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[command0].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': 7,
+ }),
+ 'schedule': dict({
+ 'state': 'never',
+ }),
+ }),
+ }),
+ 'success': True,
+ 'type': 'result',
+ })
+# ---
+# name: test_config_update[command0].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': None,
+ 'days': 7,
+ }),
+ 'schedule': dict({
+ 'state': 'never',
+ }),
+ }),
+ }),
+ 'key': 'backup',
+ 'minor_version': 1,
+ 'version': 1,
+ })
+# ---
+# name: test_config_update[command10]
+ 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[command10].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': 7,
+ }),
+ 'schedule': dict({
+ 'state': 'daily',
+ }),
+ }),
+ }),
+ 'success': True,
+ 'type': 'result',
+ })
+# ---
+# name: test_config_update[command10].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': None,
+ 'days': 7,
+ }),
+ 'schedule': dict({
+ 'state': 'daily',
+ }),
+ }),
+ }),
+ 'key': 'backup',
+ 'minor_version': 1,
+ 'version': 1,
+ })
+# ---
+# name: test_config_update[command1]
+ 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[command1].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[command1].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': None,
+ 'days': None,
+ }),
+ 'schedule': dict({
+ 'state': 'daily',
+ }),
+ }),
+ }),
+ 'key': 'backup',
+ 'minor_version': 1,
+ 'version': 1,
+ })
+# ---
+# name: test_config_update[command2]
+ 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[command2].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': 'mon',
+ }),
+ }),
+ }),
+ 'success': True,
+ 'type': 'result',
+ })
+# ---
+# name: test_config_update[command2].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': None,
+ 'days': None,
+ }),
+ 'schedule': dict({
+ 'state': 'mon',
+ }),
+ }),
+ }),
+ 'key': 'backup',
+ 'minor_version': 1,
+ 'version': 1,
+ })
+# ---
+# name: test_config_update[command3]
+ 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[command3].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': 'never',
+ }),
+ }),
+ }),
+ 'success': True,
+ 'type': 'result',
+ })
+# ---
+# name: test_config_update[command3].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': None,
+ 'days': None,
+ }),
+ 'schedule': dict({
+ 'state': 'never',
+ }),
+ }),
+ }),
+ 'key': 'backup',
+ 'minor_version': 1,
+ 'version': 1,
+ })
+# ---
+# name: test_config_update[command4]
+ 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[command4].1
+ dict({
+ 'id': 3,
+ 'result': dict({
+ 'config': 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,
+ 'retention': dict({
+ 'copies': None,
+ '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',
+ ]),
+ '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',
+ }),
+ }),
+ }),
+ 'key': 'backup',
+ 'minor_version': 1,
+ 'version': 1,
+ })
+# ---
+# name: test_config_update[command5]
+ 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[command5].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': 7,
+ }),
+ 'schedule': dict({
+ 'state': 'daily',
+ }),
+ }),
+ }),
+ 'success': True,
+ 'type': 'result',
+ })
+# ---
+# name: test_config_update[command5].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': 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([
+ ]),
+ '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[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
+ 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': 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',
+ ]),
+ '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',
+ }),
+ }),
+ }),
+ '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',
+ ]),
+ '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({
+ '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',
+ }),
+ }),
+ }),
+ 'key': 'backup',
+ 'minor_version': 1,
+ 'version': 1,
+ })
+# ---
+# name: test_config_update_errors[command0]
+ 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_errors[command0].1
+ dict({
+ 'id': 3,
+ '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_errors[command1]
+ 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_errors[command1].1
+ dict({
+ 'id': 3,
+ '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_errors[command2]
+ 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_errors[command2].1
+ dict({
+ 'id': 3,
+ '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_errors[command3]
+ 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_errors[command3].1
+ dict({
+ 'id': 3,
+ '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_delete[remote_agents0-backups0]
+ dict({
+ 'id': 1,
+ 'result': dict({
+ 'agent_errors': dict({
+ }),
+ 'backups': list([
+ ]),
+ 'last_attempted_automatic_backup': None,
+ 'last_completed_automatic_backup': None,
+ }),
+ 'success': True,
+ 'type': 'result',
+ })
+# ---
+# name: test_delete[remote_agents0-backups0].1
+ dict({
+ 'id': 2,
+ 'result': dict({
+ 'agent_errors': dict({
+ }),
+ }),
+ 'success': True,
+ 'type': 'result',
+ })
+# ---
+# name: test_delete[remote_agents0-backups0].2
+ dict({
+ 'id': 3,
+ 'result': dict({
+ 'agent_errors': dict({
+ }),
+ 'backups': list([
+ ]),
+ 'last_attempted_automatic_backup': None,
+ 'last_completed_automatic_backup': None,
+ }),
+ 'success': True,
+ 'type': 'result',
+ })
+# ---
+# name: test_delete[remote_agents1-backups1]
+ dict({
+ 'id': 1,
+ 'result': dict({
+ 'agent_errors': dict({
+ }),
+ 'backups': list([
+ dict({
+ 'addons': list([
+ dict({
+ 'name': 'Test',
+ 'slug': 'test',
+ 'version': '1.0.0',
+ }),
+ ]),
+ 'agent_ids': list([
+ 'backup.local',
+ ]),
+ 'backup_id': 'abc123',
+ 'database_included': True,
+ 'date': '1970-01-01T00:00:00.000Z',
+ 'failed_agent_ids': list([
+ ]),
+ 'folders': list([
+ 'media',
+ 'share',
+ ]),
+ '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,
+ }),
+ 'success': True,
+ 'type': 'result',
+ })
+# ---
+# name: test_delete[remote_agents1-backups1].1
+ dict({
+ 'id': 2,
+ 'result': dict({
+ 'agent_errors': dict({
+ }),
+ }),
+ 'success': True,
+ 'type': 'result',
+ })
+# ---
+# name: test_delete[remote_agents1-backups1].2
+ dict({
+ 'id': 3,
+ 'result': dict({
+ 'agent_errors': dict({
+ }),
+ 'backups': list([
+ ]),
+ 'last_attempted_automatic_backup': None,
+ 'last_completed_automatic_backup': None,
+ }),
+ 'success': True,
+ 'type': 'result',
+ })
+# ---
+# name: test_delete[remote_agents2-backups2]
+ dict({
+ 'id': 1,
+ 'result': dict({
+ 'agent_errors': dict({
+ }),
+ 'backups': list([
+ dict({
+ 'addons': list([
+ dict({
+ 'name': 'Test',
+ 'slug': 'test',
+ 'version': '1.0.0',
+ }),
+ ]),
+ 'agent_ids': list([
+ 'test.remote',
+ ]),
+ 'backup_id': 'abc123',
+ 'database_included': True,
+ 'date': '1970-01-01T00:00:00.000Z',
+ 'failed_agent_ids': list([
+ ]),
+ 'folders': list([
+ 'media',
+ 'share',
+ ]),
+ '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,
+ }),
+ 'success': True,
+ 'type': 'result',
+ })
+# ---
+# name: test_delete[remote_agents2-backups2].1
+ dict({
+ 'id': 2,
+ 'result': dict({
+ 'agent_errors': dict({
+ }),
+ }),
+ 'success': True,
+ 'type': 'result',
+ })
+# ---
+# name: test_delete[remote_agents2-backups2].2
+ dict({
+ 'id': 3,
+ 'result': dict({
+ 'agent_errors': dict({
+ }),
+ 'backups': list([
+ dict({
+ 'addons': list([
+ dict({
+ 'name': 'Test',
+ 'slug': 'test',
+ 'version': '1.0.0',
+ }),
+ ]),
+ 'agent_ids': list([
+ 'test.remote',
+ ]),
+ 'backup_id': 'abc123',
+ 'database_included': True,
+ 'date': '1970-01-01T00:00:00.000Z',
+ 'failed_agent_ids': list([
+ ]),
+ 'folders': list([
+ 'media',
+ 'share',
+ ]),
+ '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,
+ }),
+ 'success': True,
+ 'type': 'result',
+ })
+# ---
+# name: test_delete[remote_agents3-backups3]
+ dict({
+ 'id': 1,
+ 'result': dict({
+ 'agent_errors': dict({
+ }),
+ 'backups': list([
+ dict({
+ 'addons': list([
+ ]),
+ 'agent_ids': list([
+ 'test.remote',
+ ]),
+ 'backup_id': 'def456',
+ 'database_included': False,
+ 'date': '1980-01-01T00:00:00.000Z',
+ 'failed_agent_ids': list([
+ ]),
+ 'folders': list([
+ 'media',
+ 'share',
+ ]),
+ '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,
+ }),
+ 'success': True,
+ 'type': 'result',
+ })
+# ---
+# name: test_delete[remote_agents3-backups3].1
+ dict({
+ 'id': 2,
+ 'result': dict({
+ 'agent_errors': dict({
+ }),
+ }),
+ 'success': True,
+ 'type': 'result',
+ })
+# ---
+# name: test_delete[remote_agents3-backups3].2
+ dict({
+ 'id': 3,
+ 'result': dict({
+ 'agent_errors': dict({
+ }),
+ 'backups': list([
+ dict({
+ 'addons': list([
+ ]),
+ 'agent_ids': list([
+ 'test.remote',
+ ]),
+ 'backup_id': 'def456',
+ 'database_included': False,
+ 'date': '1980-01-01T00:00:00.000Z',
+ 'failed_agent_ids': list([
+ ]),
+ 'folders': list([
+ 'media',
+ 'share',
+ ]),
+ '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,
+ }),
+ 'success': True,
+ 'type': 'result',
+ })
+# ---
+# name: test_delete[remote_agents4-backups4]
+ dict({
+ 'id': 1,
+ 'result': dict({
+ 'agent_errors': dict({
+ }),
+ 'backups': list([
+ dict({
+ 'addons': list([
+ dict({
+ 'name': 'Test',
+ 'slug': 'test',
+ 'version': '1.0.0',
+ }),
+ ]),
+ 'agent_ids': list([
+ 'test.remote',
+ 'backup.local',
+ ]),
+ 'backup_id': 'abc123',
+ 'database_included': True,
+ 'date': '1970-01-01T00:00:00.000Z',
+ 'failed_agent_ids': list([
+ ]),
+ 'folders': list([
+ 'media',
+ 'share',
+ ]),
+ '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,
+ }),
+ 'success': True,
+ 'type': 'result',
+ })
+# ---
+# name: test_delete[remote_agents4-backups4].1
+ dict({
+ 'id': 2,
+ 'result': dict({
+ 'agent_errors': dict({
+ }),
+ }),
+ 'success': True,
+ 'type': 'result',
+ })
+# ---
+# name: test_delete[remote_agents4-backups4].2
+ dict({
+ 'id': 3,
+ 'result': dict({
+ 'agent_errors': dict({
+ }),
+ 'backups': list([
+ dict({
+ 'addons': list([
+ dict({
+ 'name': 'Test',
+ 'slug': 'test',
+ 'version': '1.0.0',
+ }),
+ ]),
+ 'agent_ids': list([
+ 'test.remote',
+ ]),
+ 'backup_id': 'abc123',
+ 'database_included': True,
+ 'date': '1970-01-01T00:00:00.000Z',
+ 'failed_agent_ids': list([
+ ]),
+ 'folders': list([
+ 'media',
+ 'share',
+ ]),
+ '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,
+ }),
+ 'success': True,
+ 'type': 'result',
+ })
+# ---
+# name: test_delete_with_errors[BackupAgentUnreachableError-storage_data0]
+ dict({
+ 'id': 1,
+ 'result': dict({
+ 'agent_errors': dict({
+ 'domain.test': 'The backup agent is unreachable.',
+ }),
+ }),
+ 'success': True,
+ 'type': 'result',
+ })
+# ---
+# name: test_delete_with_errors[BackupAgentUnreachableError-storage_data0].1
+ dict({
+ 'id': 2,
+ 'result': dict({
+ 'agent_errors': dict({
+ }),
+ 'backups': list([
+ dict({
+ 'addons': list([
+ dict({
+ 'name': 'Test',
+ 'slug': 'test',
+ 'version': '1.0.0',
+ }),
+ ]),
+ 'agent_ids': list([
+ 'domain.test',
+ ]),
+ 'backup_id': 'abc123',
+ 'database_included': True,
+ 'date': '1970-01-01T00:00:00Z',
+ 'failed_agent_ids': list([
+ ]),
+ 'folders': list([
+ 'media',
+ 'share',
+ ]),
+ '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,
+ }),
+ 'success': True,
+ 'type': 'result',
+ })
+# ---
+# name: test_delete_with_errors[BackupAgentUnreachableError-storage_data1]
+ dict({
+ 'id': 1,
+ 'result': dict({
+ 'agent_errors': dict({
+ 'domain.test': 'The backup agent is unreachable.',
+ }),
+ }),
+ 'success': True,
+ 'type': 'result',
+ })
+# ---
+# name: test_delete_with_errors[BackupAgentUnreachableError-storage_data1].1
+ dict({
+ 'id': 2,
+ 'result': dict({
+ 'agent_errors': dict({
+ }),
+ 'backups': list([
+ dict({
+ 'addons': list([
+ dict({
+ 'name': 'Test',
+ 'slug': 'test',
+ 'version': '1.0.0',
+ }),
+ ]),
+ 'agent_ids': list([
+ 'domain.test',
+ ]),
+ 'backup_id': 'abc123',
+ 'database_included': True,
+ 'date': '1970-01-01T00:00:00Z',
+ 'failed_agent_ids': list([
+ 'test.remote',
+ ]),
+ 'folders': list([
+ 'media',
+ 'share',
+ ]),
+ '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,
+ }),
+ 'success': True,
+ 'type': 'result',
+ })
+# ---
+# name: test_delete_with_errors[None-storage_data0]
+ dict({
+ 'id': 1,
+ 'result': dict({
+ 'agent_errors': dict({
+ }),
+ }),
+ 'success': True,
+ 'type': 'result',
+ })
+# ---
+# name: test_delete_with_errors[None-storage_data0].1
+ dict({
+ 'id': 2,
+ 'result': dict({
+ 'agent_errors': dict({
+ }),
+ 'backups': list([
+ dict({
+ 'addons': list([
+ dict({
+ 'name': 'Test',
+ 'slug': 'test',
+ 'version': '1.0.0',
+ }),
+ ]),
+ 'agent_ids': list([
+ 'domain.test',
+ ]),
+ 'backup_id': 'abc123',
+ 'database_included': True,
+ 'date': '1970-01-01T00:00:00Z',
+ 'failed_agent_ids': list([
+ ]),
+ 'folders': list([
+ 'media',
+ 'share',
+ ]),
+ '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,
+ }),
+ 'success': True,
+ 'type': 'result',
+ })
+# ---
+# name: test_delete_with_errors[None-storage_data1]
+ dict({
+ 'id': 1,
+ 'result': dict({
+ 'agent_errors': dict({
+ }),
+ }),
+ 'success': True,
+ 'type': 'result',
+ })
+# ---
+# name: test_delete_with_errors[None-storage_data1].1
+ dict({
+ 'id': 2,
+ 'result': dict({
+ 'agent_errors': dict({
+ }),
+ 'backups': list([
+ dict({
+ 'addons': list([
+ dict({
+ 'name': 'Test',
+ 'slug': 'test',
+ 'version': '1.0.0',
+ }),
+ ]),
+ 'agent_ids': list([
+ 'domain.test',
+ ]),
+ 'backup_id': 'abc123',
+ 'database_included': True,
+ 'date': '1970-01-01T00:00:00Z',
+ 'failed_agent_ids': list([
+ ]),
+ 'folders': list([
+ 'media',
+ 'share',
+ ]),
+ '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,
+ }),
+ 'success': True,
+ 'type': 'result',
+ })
+# ---
+# name: test_delete_with_errors[side_effect1-storage_data0]
+ dict({
+ 'error': dict({
+ 'code': 'home_assistant_error',
+ 'message': 'Boom!',
+ }),
+ 'id': 1,
+ 'success': False,
+ 'type': 'result',
+ })
+# ---
+# name: test_delete_with_errors[side_effect1-storage_data0].1
+ dict({
+ 'id': 2,
+ 'result': dict({
+ 'agent_errors': dict({
+ }),
+ 'backups': list([
+ dict({
+ 'addons': list([
+ dict({
+ 'name': 'Test',
+ 'slug': 'test',
+ 'version': '1.0.0',
+ }),
+ ]),
+ 'agent_ids': list([
+ 'domain.test',
+ ]),
+ 'backup_id': 'abc123',
+ 'database_included': True,
+ 'date': '1970-01-01T00:00:00Z',
+ 'failed_agent_ids': list([
+ ]),
+ 'folders': list([
+ 'media',
+ 'share',
+ ]),
+ '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,
+ }),
+ 'success': True,
+ 'type': 'result',
+ })
+# ---
+# name: test_delete_with_errors[side_effect1-storage_data1]
+ dict({
+ 'error': dict({
+ 'code': 'home_assistant_error',
+ 'message': 'Boom!',
+ }),
+ 'id': 1,
+ 'success': False,
+ 'type': 'result',
+ })
+# ---
+# name: test_delete_with_errors[side_effect1-storage_data1].1
+ dict({
+ 'id': 2,
+ 'result': dict({
+ 'agent_errors': dict({
+ }),
+ 'backups': list([
+ dict({
+ 'addons': list([
+ dict({
+ 'name': 'Test',
+ 'slug': 'test',
+ 'version': '1.0.0',
+ }),
+ ]),
+ 'agent_ids': list([
+ 'domain.test',
+ ]),
+ 'backup_id': 'abc123',
+ 'database_included': True,
+ 'date': '1970-01-01T00:00:00Z',
+ 'failed_agent_ids': list([
+ 'test.remote',
+ ]),
+ 'folders': list([
+ 'media',
+ 'share',
+ ]),
+ '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,
+ }),
+ 'success': True,
+ 'type': 'result',
+ })
+# ---
+# name: test_details[remote_agents0-backups0]
+ dict({
+ 'id': 1,
+ 'result': dict({
+ 'agent_errors': dict({
+ }),
'backup': None,
}),
'success': True,
'type': 'result',
})
# ---
-# name: test_generate[with_hassio]
- dict({
- 'error': dict({
- 'code': 'unknown_command',
- 'message': 'Unknown command.',
- }),
- 'id': 1,
- 'success': False,
- 'type': 'result',
- })
-# ---
-# name: test_generate[without_hassio]
+# name: test_details[remote_agents1-backups1]
dict({
'id': 1,
'result': dict({
- 'date': '1970-01-01T00:00:00.000Z',
- 'name': 'Test',
- 'path': 'abc123.tar',
- 'size': 0.0,
- 'slug': 'abc123',
+ 'agent_errors': dict({
+ }),
+ 'backup': dict({
+ 'addons': list([
+ dict({
+ 'name': 'Test',
+ 'slug': 'test',
+ 'version': '1.0.0',
+ }),
+ ]),
+ 'agent_ids': list([
+ 'backup.local',
+ ]),
+ 'backup_id': 'abc123',
+ 'database_included': True,
+ 'date': '1970-01-01T00:00:00.000Z',
+ 'failed_agent_ids': list([
+ ]),
+ 'folders': list([
+ 'media',
+ 'share',
+ ]),
+ 'homeassistant_included': True,
+ 'homeassistant_version': '2024.12.0',
+ 'name': 'Test',
+ 'protected': False,
+ 'size': 0,
+ 'with_automatic_settings': True,
+ }),
}),
'success': True,
'type': 'result',
})
# ---
-# name: test_info[with_hassio]
+# name: test_details[remote_agents2-backups2]
+ dict({
+ 'id': 1,
+ 'result': dict({
+ 'agent_errors': dict({
+ }),
+ 'backup': dict({
+ 'addons': list([
+ dict({
+ 'name': 'Test',
+ 'slug': 'test',
+ 'version': '1.0.0',
+ }),
+ ]),
+ 'agent_ids': list([
+ 'test.remote',
+ ]),
+ 'backup_id': 'abc123',
+ 'database_included': True,
+ 'date': '1970-01-01T00:00:00.000Z',
+ 'failed_agent_ids': list([
+ ]),
+ 'folders': list([
+ 'media',
+ 'share',
+ ]),
+ 'homeassistant_included': True,
+ 'homeassistant_version': '2024.12.0',
+ 'name': 'Test',
+ 'protected': False,
+ 'size': 0,
+ 'with_automatic_settings': True,
+ }),
+ }),
+ 'success': True,
+ 'type': 'result',
+ })
+# ---
+# name: test_details[remote_agents3-backups3]
+ dict({
+ 'id': 1,
+ 'result': dict({
+ 'agent_errors': dict({
+ }),
+ 'backup': None,
+ }),
+ 'success': True,
+ 'type': 'result',
+ })
+# ---
+# name: test_details[remote_agents4-backups4]
+ dict({
+ 'id': 1,
+ 'result': dict({
+ 'agent_errors': dict({
+ }),
+ 'backup': dict({
+ 'addons': list([
+ dict({
+ 'name': 'Test',
+ 'slug': 'test',
+ 'version': '1.0.0',
+ }),
+ ]),
+ 'agent_ids': list([
+ 'test.remote',
+ 'backup.local',
+ ]),
+ 'backup_id': 'abc123',
+ 'database_included': True,
+ 'date': '1970-01-01T00:00:00.000Z',
+ 'failed_agent_ids': list([
+ ]),
+ 'folders': list([
+ 'media',
+ 'share',
+ ]),
+ 'homeassistant_included': True,
+ 'homeassistant_version': '2024.12.0',
+ 'name': 'Test',
+ 'protected': False,
+ 'size': 0,
+ 'with_automatic_settings': True,
+ }),
+ }),
+ 'success': True,
+ 'type': 'result',
+ })
+# ---
+# name: test_details_with_errors[BackupAgentUnreachableError]
+ dict({
+ 'id': 1,
+ 'result': dict({
+ 'agent_errors': dict({
+ 'domain.test': 'The backup agent is unreachable.',
+ }),
+ 'backup': dict({
+ 'addons': list([
+ dict({
+ 'name': 'Test',
+ 'slug': 'test',
+ 'version': '1.0.0',
+ }),
+ ]),
+ 'agent_ids': list([
+ 'backup.local',
+ ]),
+ 'backup_id': 'abc123',
+ 'database_included': True,
+ 'date': '1970-01-01T00:00:00.000Z',
+ 'failed_agent_ids': list([
+ ]),
+ 'folders': list([
+ 'media',
+ 'share',
+ ]),
+ 'homeassistant_included': True,
+ 'homeassistant_version': '2024.12.0',
+ 'name': 'Test',
+ 'protected': False,
+ 'size': 0,
+ 'with_automatic_settings': True,
+ }),
+ }),
+ 'success': True,
+ 'type': 'result',
+ })
+# ---
+# name: test_details_with_errors[side_effect0]
dict({
'error': dict({
- 'code': 'unknown_command',
- 'message': 'Unknown command.',
+ 'code': 'home_assistant_error',
+ 'message': 'Boom!',
}),
'id': 1,
'success': False,
'type': 'result',
})
# ---
-# name: test_info[without_hassio]
+# name: test_generate[None]
+ dict({
+ 'event': dict({
+ 'manager_state': 'idle',
+ }),
+ 'id': 1,
+ 'type': 'event',
+ })
+# ---
+# name: test_generate[None].1
+ dict({
+ 'id': 1,
+ 'result': None,
+ 'success': True,
+ 'type': 'result',
+ })
+# ---
+# name: test_generate[None].2
+ dict({
+ 'event': dict({
+ 'manager_state': 'create_backup',
+ 'stage': None,
+ 'state': 'in_progress',
+ }),
+ 'id': 1,
+ 'type': 'event',
+ })
+# ---
+# name: test_generate[None].3
+ dict({
+ 'id': 2,
+ 'result': dict({
+ 'backup_job_id': '64331d85',
+ }),
+ 'success': True,
+ 'type': 'result',
+ })
+# ---
+# name: test_generate[None].4
+ dict({
+ 'event': dict({
+ 'manager_state': 'create_backup',
+ 'stage': 'home_assistant',
+ 'state': 'in_progress',
+ }),
+ 'id': 1,
+ 'type': 'event',
+ })
+# ---
+# name: test_generate[None].5
+ dict({
+ 'event': dict({
+ 'manager_state': 'create_backup',
+ 'stage': 'upload_to_agents',
+ 'state': 'in_progress',
+ }),
+ 'id': 1,
+ 'type': 'event',
+ })
+# ---
+# name: test_generate[None].6
+ dict({
+ 'event': dict({
+ 'manager_state': 'create_backup',
+ 'stage': None,
+ 'state': 'completed',
+ }),
+ 'id': 1,
+ 'type': 'event',
+ })
+# ---
+# name: test_generate[data1]
+ dict({
+ 'event': dict({
+ 'manager_state': 'idle',
+ }),
+ 'id': 1,
+ 'type': 'event',
+ })
+# ---
+# name: test_generate[data1].1
+ dict({
+ 'id': 1,
+ 'result': None,
+ 'success': True,
+ 'type': 'result',
+ })
+# ---
+# name: test_generate[data1].2
+ dict({
+ 'event': dict({
+ 'manager_state': 'create_backup',
+ 'stage': None,
+ 'state': 'in_progress',
+ }),
+ 'id': 1,
+ 'type': 'event',
+ })
+# ---
+# name: test_generate[data1].3
+ dict({
+ 'id': 2,
+ 'result': dict({
+ 'backup_job_id': '64331d85',
+ }),
+ 'success': True,
+ 'type': 'result',
+ })
+# ---
+# name: test_generate[data1].4
+ dict({
+ 'event': dict({
+ 'manager_state': 'create_backup',
+ 'stage': 'home_assistant',
+ 'state': 'in_progress',
+ }),
+ 'id': 1,
+ 'type': 'event',
+ })
+# ---
+# name: test_generate[data1].5
+ dict({
+ 'event': dict({
+ 'manager_state': 'create_backup',
+ 'stage': 'upload_to_agents',
+ 'state': 'in_progress',
+ }),
+ 'id': 1,
+ 'type': 'event',
+ })
+# ---
+# name: test_generate[data1].6
+ dict({
+ 'event': dict({
+ 'manager_state': 'create_backup',
+ 'stage': None,
+ 'state': 'completed',
+ }),
+ 'id': 1,
+ 'type': 'event',
+ })
+# ---
+# name: test_generate[data2]
+ dict({
+ 'event': dict({
+ 'manager_state': 'idle',
+ }),
+ 'id': 1,
+ 'type': 'event',
+ })
+# ---
+# name: test_generate[data2].1
+ dict({
+ 'id': 1,
+ 'result': None,
+ 'success': True,
+ 'type': 'result',
+ })
+# ---
+# name: test_generate[data2].2
+ dict({
+ 'event': dict({
+ 'manager_state': 'create_backup',
+ 'stage': None,
+ 'state': 'in_progress',
+ }),
+ 'id': 1,
+ 'type': 'event',
+ })
+# ---
+# name: test_generate[data2].3
+ dict({
+ 'id': 2,
+ 'result': dict({
+ 'backup_job_id': '64331d85',
+ }),
+ 'success': True,
+ 'type': 'result',
+ })
+# ---
+# name: test_generate[data2].4
+ dict({
+ 'event': dict({
+ 'manager_state': 'create_backup',
+ 'stage': 'home_assistant',
+ 'state': 'in_progress',
+ }),
+ 'id': 1,
+ 'type': 'event',
+ })
+# ---
+# name: test_generate[data2].5
+ dict({
+ 'event': dict({
+ 'manager_state': 'create_backup',
+ 'stage': 'upload_to_agents',
+ 'state': 'in_progress',
+ }),
+ 'id': 1,
+ 'type': 'event',
+ })
+# ---
+# name: test_generate[data2].6
+ dict({
+ 'event': dict({
+ 'manager_state': 'create_backup',
+ 'stage': None,
+ 'state': 'completed',
+ }),
+ 'id': 1,
+ 'type': 'event',
+ })
+# ---
+# name: test_info[remote_agents0-remote_backups0]
dict({
'id': 1,
'result': dict({
- 'backing_up': False,
+ 'agent_errors': dict({
+ }),
'backups': list([
dict({
+ 'addons': list([
+ dict({
+ 'name': 'Test',
+ 'slug': 'test',
+ 'version': '1.0.0',
+ }),
+ ]),
+ 'agent_ids': list([
+ 'backup.local',
+ ]),
+ 'backup_id': 'abc123',
+ 'database_included': True,
'date': '1970-01-01T00:00:00.000Z',
+ 'failed_agent_ids': list([
+ ]),
+ 'folders': list([
+ 'media',
+ 'share',
+ ]),
+ 'homeassistant_included': True,
+ 'homeassistant_version': '2024.12.0',
'name': 'Test',
- 'path': 'abc123.tar',
- 'size': 0.0,
- 'slug': 'abc123',
+ 'protected': False,
+ 'size': 0,
+ 'with_automatic_settings': True,
}),
]),
+ 'last_attempted_automatic_backup': None,
+ 'last_completed_automatic_backup': None,
}),
'success': True,
'type': 'result',
})
# ---
-# name: test_remove[with_hassio]
+# name: test_info[remote_agents1-remote_backups1]
+ dict({
+ 'id': 1,
+ 'result': dict({
+ 'agent_errors': dict({
+ }),
+ 'backups': list([
+ dict({
+ 'addons': list([
+ dict({
+ 'name': 'Test',
+ 'slug': 'test',
+ 'version': '1.0.0',
+ }),
+ ]),
+ 'agent_ids': list([
+ 'backup.local',
+ ]),
+ 'backup_id': 'abc123',
+ 'database_included': True,
+ 'date': '1970-01-01T00:00:00.000Z',
+ 'failed_agent_ids': list([
+ ]),
+ 'folders': list([
+ 'media',
+ 'share',
+ ]),
+ '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,
+ }),
+ 'success': True,
+ 'type': 'result',
+ })
+# ---
+# name: test_info[remote_agents2-remote_backups2]
+ dict({
+ 'id': 1,
+ 'result': dict({
+ 'agent_errors': dict({
+ }),
+ 'backups': list([
+ dict({
+ 'addons': list([
+ dict({
+ 'name': 'Test',
+ 'slug': 'test',
+ 'version': '1.0.0',
+ }),
+ ]),
+ 'agent_ids': list([
+ 'test.remote',
+ 'backup.local',
+ ]),
+ 'backup_id': 'abc123',
+ 'database_included': True,
+ 'date': '1970-01-01T00:00:00.000Z',
+ 'failed_agent_ids': list([
+ ]),
+ 'folders': list([
+ 'media',
+ 'share',
+ ]),
+ '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,
+ }),
+ 'success': True,
+ 'type': 'result',
+ })
+# ---
+# name: test_info[remote_agents3-remote_backups3]
+ dict({
+ 'id': 1,
+ 'result': dict({
+ 'agent_errors': dict({
+ }),
+ 'backups': list([
+ dict({
+ 'addons': list([
+ ]),
+ 'agent_ids': list([
+ 'test.remote',
+ ]),
+ 'backup_id': 'def456',
+ 'database_included': False,
+ 'date': '1980-01-01T00:00:00.000Z',
+ 'failed_agent_ids': list([
+ ]),
+ 'folders': list([
+ 'media',
+ 'share',
+ ]),
+ 'homeassistant_included': True,
+ 'homeassistant_version': '2024.12.0',
+ 'name': 'Test 2',
+ 'protected': False,
+ 'size': 1,
+ 'with_automatic_settings': None,
+ }),
+ dict({
+ 'addons': list([
+ dict({
+ 'name': 'Test',
+ 'slug': 'test',
+ 'version': '1.0.0',
+ }),
+ ]),
+ 'agent_ids': list([
+ 'backup.local',
+ ]),
+ 'backup_id': 'abc123',
+ 'database_included': True,
+ 'date': '1970-01-01T00:00:00.000Z',
+ 'failed_agent_ids': list([
+ ]),
+ 'folders': list([
+ 'media',
+ 'share',
+ ]),
+ '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,
+ }),
+ 'success': True,
+ 'type': 'result',
+ })
+# ---
+# name: test_info_with_errors[BackupAgentUnreachableError]
+ dict({
+ 'id': 1,
+ 'result': dict({
+ 'agent_errors': dict({
+ 'domain.test': 'The backup agent is unreachable.',
+ }),
+ 'backups': list([
+ dict({
+ 'addons': list([
+ dict({
+ 'name': 'Test',
+ 'slug': 'test',
+ 'version': '1.0.0',
+ }),
+ ]),
+ 'agent_ids': list([
+ 'backup.local',
+ ]),
+ 'backup_id': 'abc123',
+ 'database_included': True,
+ 'date': '1970-01-01T00:00:00.000Z',
+ 'failed_agent_ids': list([
+ ]),
+ 'folders': list([
+ 'media',
+ 'share',
+ ]),
+ '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,
+ }),
+ 'success': True,
+ 'type': 'result',
+ })
+# ---
+# name: test_info_with_errors[side_effect0]
dict({
'error': dict({
- 'code': 'unknown_command',
- 'message': 'Unknown command.',
+ 'code': 'home_assistant_error',
+ 'message': 'Boom!',
}),
'id': 1,
'success': False,
'type': 'result',
})
# ---
-# name: test_remove[without_hassio]
+# name: test_restore_local_agent[backups0]
+ dict({
+ 'error': dict({
+ 'code': 'home_assistant_error',
+ 'message': 'Backup abc123 not found in agent backup.local',
+ }),
+ 'id': 1,
+ 'success': False,
+ 'type': 'result',
+ })
+# ---
+# name: test_restore_local_agent[backups0].1
+ 0
+# ---
+# name: test_restore_local_agent[backups1]
dict({
'id': 1,
'result': None,
@@ -269,18 +3022,24 @@
'type': 'result',
})
# ---
-# name: test_restore[with_hassio]
+# name: test_restore_local_agent[backups1].1
+ 1
+# ---
+# name: test_restore_remote_agent[remote_agents0-backups0]
dict({
'error': dict({
- 'code': 'unknown_command',
- 'message': 'Unknown command.',
+ 'code': 'home_assistant_error',
+ 'message': 'Backup abc123 not found in agent test.remote',
}),
'id': 1,
'success': False,
'type': 'result',
})
# ---
-# name: test_restore[without_hassio]
+# name: test_restore_remote_agent[remote_agents0-backups0].1
+ 0
+# ---
+# name: test_restore_remote_agent[remote_agents1-backups1]
dict({
'id': 1,
'result': None,
@@ -288,3 +3047,45 @@
'type': 'result',
})
# ---
+# name: test_restore_remote_agent[remote_agents1-backups1].1
+ 1
+# ---
+# name: test_restore_wrong_password
+ dict({
+ 'error': dict({
+ 'code': 'password_incorrect',
+ 'message': 'Incorrect password',
+ }),
+ 'id': 1,
+ 'success': False,
+ 'type': 'result',
+ })
+# ---
+# name: test_subscribe_event
+ dict({
+ 'event': dict({
+ 'manager_state': 'idle',
+ }),
+ 'id': 1,
+ 'type': 'event',
+ })
+# ---
+# name: test_subscribe_event.1
+ dict({
+ 'id': 1,
+ 'result': None,
+ 'success': True,
+ 'type': 'result',
+ })
+# ---
+# name: test_subscribe_event.2
+ dict({
+ 'event': dict({
+ 'manager_state': 'create_backup',
+ 'stage': None,
+ 'state': 'in_progress',
+ }),
+ 'id': 1,
+ 'type': 'event',
+ })
+# ---
diff --git a/tests/components/backup/test_backup.py b/tests/components/backup/test_backup.py
new file mode 100644
index 00000000000..02252ef6fa5
--- /dev/null
+++ b/tests/components/backup/test_backup.py
@@ -0,0 +1,129 @@
+"""Test the builtin backup platform."""
+
+from __future__ import annotations
+
+from collections.abc import Generator
+from io import StringIO
+import json
+from pathlib import Path
+from tarfile import TarError
+from unittest.mock import MagicMock, mock_open, patch
+
+import pytest
+from syrupy import SnapshotAssertion
+
+from homeassistant.components.backup import DOMAIN
+from homeassistant.core import HomeAssistant
+from homeassistant.setup import async_setup_component
+
+from .common import TEST_BACKUP_ABC123, TEST_BACKUP_PATH_ABC123
+
+from tests.typing import ClientSessionGenerator, WebSocketGenerator
+
+
+@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,
+ ) as read_backup:
+ yield read_backup
+
+
+@pytest.mark.parametrize(
+ "side_effect",
+ [
+ None,
+ OSError("Boom"),
+ TarError("Boom"),
+ json.JSONDecodeError("Boom", "test", 1),
+ KeyError("Boom"),
+ ],
+)
+async def test_load_backups(
+ hass: HomeAssistant,
+ hass_ws_client: WebSocketGenerator,
+ snapshot: SnapshotAssertion,
+ read_backup: MagicMock,
+ side_effect: Exception | None,
+) -> None:
+ """Test load backups."""
+ assert await async_setup_component(hass, DOMAIN, {})
+ await hass.async_block_till_done()
+ client = await hass_ws_client(hass)
+ read_backup.side_effect = side_effect
+
+ # list agents
+ await client.send_json_auto_id({"type": "backup/agents/info"})
+ assert await client.receive_json() == snapshot
+
+ # load and list backups
+ await client.send_json_auto_id({"type": "backup/info"})
+ assert await client.receive_json() == snapshot
+
+
+async def test_upload(
+ hass: HomeAssistant,
+ hass_client: ClientSessionGenerator,
+) -> None:
+ """Test upload backup."""
+ assert await async_setup_component(hass, DOMAIN, {})
+ await hass.async_block_till_done()
+ client = await hass_client()
+ open_mock = mock_open()
+
+ with (
+ patch("pathlib.Path.open", open_mock),
+ patch("shutil.move") as move_mock,
+ patch(
+ "homeassistant.components.backup.manager.read_backup",
+ return_value=TEST_BACKUP_ABC123,
+ ),
+ ):
+ resp = await client.post(
+ "/api/backup/upload?agent_id=backup.local",
+ data={"file": StringIO("test")},
+ )
+
+ 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"
+
+
+@pytest.mark.usefixtures("read_backup")
+@pytest.mark.parametrize(
+ ("found_backups", "backup_exists", "unlink_calls"),
+ [
+ ([TEST_BACKUP_PATH_ABC123], True, 1),
+ ([TEST_BACKUP_PATH_ABC123], False, 0),
+ (([], True, 0)),
+ ],
+)
+async def test_delete_backup(
+ hass: HomeAssistant,
+ caplog: pytest.LogCaptureFixture,
+ hass_ws_client: WebSocketGenerator,
+ snapshot: SnapshotAssertion,
+ path_glob: MagicMock,
+ found_backups: list[Path],
+ backup_exists: bool,
+ unlink_calls: int,
+) -> None:
+ """Test delete backup."""
+ assert await async_setup_component(hass, DOMAIN, {})
+ await hass.async_block_till_done()
+ client = await hass_ws_client(hass)
+ path_glob.return_value = found_backups
+
+ with (
+ patch("pathlib.Path.exists", return_value=backup_exists),
+ patch("pathlib.Path.unlink") as unlink,
+ ):
+ await client.send_json_auto_id(
+ {"type": "backup/delete", "backup_id": TEST_BACKUP_ABC123.backup_id}
+ )
+ assert await client.receive_json() == snapshot
+
+ assert unlink.call_count == unlink_calls
diff --git a/tests/components/backup/test_http.py b/tests/components/backup/test_http.py
index 93ecb27bc97..c071a0d8386 100644
--- a/tests/components/backup/test_http.py
+++ b/tests/components/backup/test_http.py
@@ -1,30 +1,34 @@
"""Tests for the Backup integration."""
+import asyncio
+from io import StringIO
from unittest.mock import patch
from aiohttp import web
+import pytest
+from homeassistant.components.backup.const import DATA_MANAGER
from homeassistant.core import HomeAssistant
-from .common import TEST_BACKUP, setup_backup_integration
+from .common import TEST_BACKUP_ABC123, BackupAgentTest, setup_backup_integration
from tests.common import MockUser
from tests.typing import ClientSessionGenerator
-async def test_downloading_backup(
+async def test_downloading_local_backup(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
) -> None:
- """Test downloading a backup file."""
+ """Test downloading a local backup file."""
await setup_backup_integration(hass)
client = await hass_client()
with (
patch(
- "homeassistant.components.backup.manager.BackupManager.async_get_backup",
- return_value=TEST_BACKUP,
+ "homeassistant.components.backup.backup.CoreLocalBackupAgent.async_get_backup",
+ return_value=TEST_BACKUP_ABC123,
),
patch("pathlib.Path.exists", return_value=True),
patch(
@@ -32,10 +36,29 @@ async def test_downloading_backup(
return_value=web.Response(text=""),
),
):
- resp = await client.get("/api/backup/download/abc123")
+ resp = await client.get("/api/backup/download/abc123?agent_id=backup.local")
assert resp.status == 200
+async def test_downloading_remote_backup(
+ hass: HomeAssistant,
+ hass_client: ClientSessionGenerator,
+) -> None:
+ """Test downloading a remote backup."""
+ await setup_backup_integration(hass)
+ hass.data[DATA_MANAGER].backup_agents["domain.test"] = BackupAgentTest("test")
+
+ client = await hass_client()
+
+ with (
+ 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")
+ assert resp.status == 200
+ assert await resp.content.read() == b"backup data"
+
+
async def test_downloading_backup_not_found(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
@@ -45,20 +68,70 @@ async def test_downloading_backup_not_found(
client = await hass_client()
- resp = await client.get("/api/backup/download/abc123")
+ resp = await client.get("/api/backup/download/abc123?agent_id=backup.local")
assert resp.status == 404
-async def test_non_admin(
+async def test_downloading_as_non_admin(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
hass_admin_user: MockUser,
) -> None:
- """Test downloading a backup file that does not exist."""
+ """Test downloading a backup file when you are not an admin."""
hass_admin_user.groups = []
await setup_backup_integration(hass)
client = await hass_client()
- resp = await client.get("/api/backup/download/abc123")
+ resp = await client.get("/api/backup/download/abc123?agent_id=backup.local")
assert resp.status == 401
+
+
+async def test_uploading_a_backup_file(
+ hass: HomeAssistant,
+ hass_client: ClientSessionGenerator,
+) -> None:
+ """Test uploading a backup file."""
+ await setup_backup_integration(hass)
+
+ client = await hass_client()
+
+ with patch(
+ "homeassistant.components.backup.manager.BackupManager.async_receive_backup",
+ ) 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 async_receive_backup_mock.called
+
+
+@pytest.mark.parametrize(
+ ("error", "message"),
+ [
+ (OSError("Boom!"), "Can't write backup file: Boom!"),
+ (asyncio.CancelledError("Boom!"), ""),
+ ],
+)
+async def test_error_handling_uploading_a_backup_file(
+ hass: HomeAssistant,
+ hass_client: ClientSessionGenerator,
+ error: Exception,
+ message: str,
+) -> None:
+ """Test error handling when uploading a backup file."""
+ await setup_backup_integration(hass)
+
+ client = await hass_client()
+
+ with patch(
+ "homeassistant.components.backup.manager.BackupManager.async_receive_backup",
+ side_effect=error,
+ ):
+ resp = await client.post(
+ "/api/backup/upload?agent_id=backup.local",
+ data={"file": StringIO("test")},
+ )
+ assert resp.status == 500
+ assert await resp.text() == message
diff --git a/tests/components/backup/test_init.py b/tests/components/backup/test_init.py
index e064939d618..16a49af9647 100644
--- a/tests/components/backup/test_init.py
+++ b/tests/components/backup/test_init.py
@@ -1,15 +1,18 @@
"""Tests for the Backup integration."""
+from typing import Any
from unittest.mock import patch
import pytest
-from homeassistant.components.backup.const import DOMAIN
+from homeassistant.components.backup.const import DATA_MANAGER, DOMAIN
from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import ServiceNotFound
from .common import setup_backup_integration
+@pytest.mark.usefixtures("supervisor_client")
async def test_setup_with_hassio(
hass: HomeAssistant,
caplog: pytest.LogCaptureFixture,
@@ -20,14 +23,14 @@ async def test_setup_with_hassio(
with_hassio=True,
configuration={DOMAIN: {}},
)
- assert (
- "The backup integration is not supported on this installation method, please"
- " remove it from your configuration"
- ) in caplog.text
+ manager = hass.data[DATA_MANAGER]
+ assert not manager.backup_agents
+@pytest.mark.parametrize("service_data", [None, {}])
async def test_create_service(
hass: HomeAssistant,
+ service_data: dict[str, Any] | None,
) -> None:
"""Test generate backup."""
await setup_backup_integration(hass)
@@ -39,6 +42,15 @@ async def test_create_service(
DOMAIN,
"create",
blocking=True,
+ service_data=service_data,
)
assert generate_backup.called
+
+
+async def test_create_service_with_hassio(hass: HomeAssistant) -> None:
+ """Test action backup.create does not exist with hassio."""
+ await setup_backup_integration(hass, with_hassio=True)
+
+ with pytest.raises(ServiceNotFound):
+ await hass.services.async_call(DOMAIN, "create", blocking=True)
diff --git a/tests/components/backup/test_manager.py b/tests/components/backup/test_manager.py
index a4dba5c6936..ad90e2e23bf 100644
--- a/tests/components/backup/test_manager.py
+++ b/tests/components/backup/test_manager.py
@@ -2,197 +2,1155 @@
from __future__ import annotations
+import asyncio
+from collections.abc import Generator
+from dataclasses import replace
+from io import StringIO
+import json
from pathlib import Path
-from unittest.mock import AsyncMock, MagicMock, Mock, patch
+from typing import Any
+from unittest.mock import ANY, AsyncMock, MagicMock, Mock, call, mock_open, patch
import pytest
-from homeassistant.components.backup import BackupManager
-from homeassistant.components.backup.manager import BackupPlatformProtocol
+from homeassistant.components.backup import (
+ DOMAIN,
+ AgentBackup,
+ BackupAgentPlatformProtocol,
+ BackupManager,
+ BackupReaderWriterError,
+ Folder,
+ LocalBackupAgent,
+ backup as local_backup_platform,
+)
+from homeassistant.components.backup.agent import BackupAgentError
+from homeassistant.components.backup.const import DATA_MANAGER
+from homeassistant.components.backup.manager import (
+ BackupManagerError,
+ BackupManagerState,
+ CoreBackupReaderWriter,
+ CreateBackupEvent,
+ CreateBackupStage,
+ CreateBackupState,
+ NewBackup,
+ WrittenBackup,
+)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers import issue_registry as ir
from homeassistant.setup import async_setup_component
-from .common import TEST_BACKUP
+from .common import (
+ LOCAL_AGENT_ID,
+ TEST_BACKUP_ABC123,
+ TEST_BACKUP_DEF456,
+ BackupAgentTest,
+ setup_backup_platform,
+)
-from tests.common import MockPlatform, mock_platform
+from tests.typing import ClientSessionGenerator, WebSocketGenerator
+
+_EXPECTED_FILES = [
+ "test.txt",
+ ".storage",
+ "backups",
+ "backups/not_backup",
+ "tmp_backups",
+ "tmp_backups/not_backup",
+]
+_EXPECTED_FILES_WITH_DATABASE = {
+ True: [*_EXPECTED_FILES, "home-assistant_v2.db"],
+ False: _EXPECTED_FILES,
+}
-async def _mock_backup_generation(manager: BackupManager):
- """Mock backup generator."""
+@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
- def _mock_iterdir(path: Path) -> list[Path]:
- if not path.name.endswith("testing_config"):
- return []
- return [
- Path("test.txt"),
- Path(".DS_Store"),
- Path(".storage"),
- ]
- with (
- patch(
- "homeassistant.components.backup.manager.SecureTarFile"
- ) as mocked_tarfile,
- patch("pathlib.Path.iterdir", _mock_iterdir),
- patch("pathlib.Path.stat", MagicMock(st_size=123)),
- patch("pathlib.Path.is_file", lambda x: x.name != ".storage"),
- patch(
- "pathlib.Path.is_dir",
- lambda x: x.name == ".storage",
+@pytest.fixture(name="generate_backup_id")
+def generate_backup_id_fixture() -> Generator[MagicMock]:
+ """Mock generate backup id."""
+ with patch("homeassistant.components.backup.manager._generate_backup_id") as mock:
+ mock.return_value = "abc123"
+ yield mock
+
+
+@pytest.mark.usefixtures("mock_backup_generation")
+async def test_async_create_backup(
+ hass: HomeAssistant,
+ caplog: pytest.LogCaptureFixture,
+ mocked_json_bytes: Mock,
+ mocked_tarfile: Mock,
+) -> None:
+ """Test create backup."""
+ assert await async_setup_component(hass, DOMAIN, {})
+ await hass.async_block_till_done()
+
+ new_backup = NewBackup(backup_job_id="time-123")
+ backup_task = AsyncMock(
+ return_value=WrittenBackup(
+ backup=TEST_BACKUP_ABC123,
+ open_stream=AsyncMock(),
+ release_stream=AsyncMock(),
),
- patch(
- "pathlib.Path.exists",
- lambda x: x != manager.backup_dir,
- ),
- patch(
- "pathlib.Path.is_symlink",
- lambda _: False,
- ),
- patch(
- "pathlib.Path.mkdir",
- MagicMock(),
- ),
- patch(
- "homeassistant.components.backup.manager.json_bytes",
- return_value=b"{}", # Empty JSON
- ) as mocked_json_bytes,
- patch(
- "homeassistant.components.backup.manager.HAVERSION",
- "2025.1.0",
- ),
- ):
- await manager.async_create_backup()
+ )() # call it so that it can be awaited
- assert mocked_json_bytes.call_count == 1
- backup_json_dict = mocked_json_bytes.call_args[0][0]
- assert isinstance(backup_json_dict, dict)
- assert backup_json_dict["homeassistant"] == {"version": "2025.1.0"}
- assert manager.backup_dir.as_posix() in str(
- mocked_tarfile.call_args_list[0][0][0]
+ with patch(
+ "homeassistant.components.backup.manager.CoreBackupReaderWriter.async_create_backup",
+ return_value=(new_backup, backup_task),
+ ) as create_backup:
+ await hass.services.async_call(
+ DOMAIN,
+ "create",
+ blocking=True,
)
-
-async def _setup_mock_domain(
- hass: HomeAssistant,
- platform: BackupPlatformProtocol | None = None,
-) -> None:
- """Set up a mock domain."""
- mock_platform(hass, "some_domain.backup", platform or MockPlatform())
- assert await async_setup_component(hass, "some_domain", {})
-
-
-async def test_constructor(hass: HomeAssistant) -> None:
- """Test BackupManager constructor."""
- manager = BackupManager(hass)
- assert manager.backup_dir.as_posix() == hass.config.path("backups")
-
-
-async def test_load_backups(hass: HomeAssistant) -> None:
- """Test loading backups."""
- manager = BackupManager(hass)
- with (
- patch("pathlib.Path.glob", return_value=[TEST_BACKUP.path]),
- patch("tarfile.open", return_value=MagicMock()),
- patch(
- "homeassistant.components.backup.manager.json_loads_object",
- return_value={
- "slug": TEST_BACKUP.slug,
- "name": TEST_BACKUP.name,
- "date": TEST_BACKUP.date,
- },
- ),
- patch(
- "pathlib.Path.stat",
- return_value=MagicMock(st_size=TEST_BACKUP.size),
- ),
- ):
- await manager.load_backups()
- backups = await manager.async_get_backups()
- assert backups == {TEST_BACKUP.slug: TEST_BACKUP}
-
-
-async def test_load_backups_with_exception(
- hass: HomeAssistant,
- caplog: pytest.LogCaptureFixture,
-) -> None:
- """Test loading backups with exception."""
- manager = BackupManager(hass)
- with (
- patch("pathlib.Path.glob", return_value=[TEST_BACKUP.path]),
- patch("tarfile.open", side_effect=OSError("Test exception")),
- ):
- await manager.load_backups()
- backups = await manager.async_get_backups()
- assert f"Unable to read backup {TEST_BACKUP.path}: Test exception" in caplog.text
- assert backups == {}
-
-
-async def test_removing_backup(
- hass: HomeAssistant,
- caplog: pytest.LogCaptureFixture,
-) -> None:
- """Test removing backup."""
- manager = BackupManager(hass)
- manager.backups = {TEST_BACKUP.slug: TEST_BACKUP}
- manager.loaded_backups = True
-
- with patch("pathlib.Path.exists", return_value=True):
- await manager.async_remove_backup(slug=TEST_BACKUP.slug)
- assert "Removed backup located at" in caplog.text
-
-
-async def test_removing_non_existing_backup(
- hass: HomeAssistant,
- caplog: pytest.LogCaptureFixture,
-) -> None:
- """Test removing not existing backup."""
- manager = BackupManager(hass)
-
- await manager.async_remove_backup(slug="non_existing")
- assert "Removed backup located at" not in caplog.text
-
-
-async def test_getting_backup_that_does_not_exist(
- hass: HomeAssistant,
- caplog: pytest.LogCaptureFixture,
-) -> None:
- """Test getting backup that does not exist."""
- manager = BackupManager(hass)
- manager.backups = {TEST_BACKUP.slug: TEST_BACKUP}
- manager.loaded_backups = True
-
- with patch("pathlib.Path.exists", return_value=False):
- backup = await manager.async_get_backup(slug=TEST_BACKUP.slug)
- assert backup is None
-
- assert (
- f"Removing tracked backup ({TEST_BACKUP.slug}) that "
- f"does not exists on the expected path {TEST_BACKUP.path}"
- ) in caplog.text
+ assert create_backup.called
+ assert create_backup.call_args == call(
+ agent_ids=["backup.local"],
+ backup_name="Custom backup 2025.1.0",
+ extra_metadata={
+ "instance_id": hass.data["core.uuid"],
+ "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_when_backing_up(hass: HomeAssistant) -> None:
"""Test generate backup."""
- manager = BackupManager(hass)
- manager.backing_up = True
- with pytest.raises(HomeAssistantError, match="Backup already in progress"):
- await manager.async_create_backup()
+ manager = BackupManager(hass, CoreBackupReaderWriter(hass))
+ manager.last_event = CreateBackupEvent(
+ stage=None, state=CreateBackupState.IN_PROGRESS
+ )
+ 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,
+ )
-async def test_async_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']"),
+ (
+ {"include_addons": ["ssl"], "include_all_addons": True},
+ "Cannot include all addons and specify specific addons",
+ ),
+ (
+ {"include_homeassistant": False},
+ "Home Assistant must be included in backup",
+ ),
+ ],
+)
+async def test_create_backup_wrong_parameters(
hass: HomeAssistant,
+ hass_ws_client: WebSocketGenerator,
+ parameters: dict[str, Any],
+ expected_error: str,
+) -> None:
+ """Test create backup with wrong parameters."""
+ assert await async_setup_component(hass, DOMAIN, {})
+ await hass.async_block_till_done()
+
+ ws_client = await hass_ws_client(hass)
+
+ default_parameters = {
+ "agent_ids": [LOCAL_AGENT_ID],
+ "include_addons": [],
+ "include_all_addons": False,
+ "include_database": True,
+ "include_folders": [],
+ "include_homeassistant": True,
+ }
+
+ await ws_client.send_json_auto_id(
+ {"type": "backup/generate"} | default_parameters | parameters
+ )
+ result = await ws_client.receive_json()
+
+ assert result["success"] is False
+ assert result["error"]["code"] == "home_assistant_error"
+ assert result["error"]["message"] == expected_error
+
+
+@pytest.mark.usefixtures("mock_backup_generation")
+@pytest.mark.parametrize(
+ ("agent_ids", "backup_directory", "temp_file_unlink_call_count"),
+ [
+ ([LOCAL_AGENT_ID], "backups", 0),
+ (["test.remote"], "tmp_backups", 1),
+ ([LOCAL_AGENT_ID, "test.remote"], "backups", 0),
+ ],
+)
+@pytest.mark.parametrize(
+ "params",
+ [
+ {},
+ {"include_database": True, "name": "abc123"},
+ {"include_database": False},
+ {"password": "pass123"},
+ ],
+)
+async def test_async_initiate_backup(
+ hass: HomeAssistant,
+ hass_ws_client: WebSocketGenerator,
caplog: pytest.LogCaptureFixture,
+ mocked_json_bytes: Mock,
+ mocked_tarfile: Mock,
+ generate_backup_id: MagicMock,
+ path_glob: MagicMock,
+ params: dict[str, Any],
+ agent_ids: list[str],
+ backup_directory: str,
+ temp_file_unlink_call_count: int,
) -> None:
"""Test generate backup."""
- manager = BackupManager(hass)
- manager.loaded_backups = True
+ 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:
+ 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,
+ ),
+ )
- await _mock_backup_generation(manager)
+ ws_client = await hass_ws_client(hass)
- assert "Generated new backup with slug " in caplog.text
- assert "Creating backup directory" in caplog.text
- assert "Loaded 0 platforms" in caplog.text
+ include_database = params.get("include_database", True)
+ name = params.get("name", "Custom backup 2025.1.0")
+ password = params.get("password")
+ 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,
+ }
+
+ 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")),
+ patch("pathlib.Path.unlink") as unlink_mock,
+ ):
+ await ws_client.send_json_auto_id(
+ {"type": "backup/generate", "agent_ids": agent_ids} | params
+ )
+ result = await ws_client.receive_json()
+ assert result["event"] == {
+ "manager_state": BackupManagerState.CREATE_BACKUP,
+ "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()
+
+ result = await ws_client.receive_json()
+ assert result["event"] == {
+ "manager_state": BackupManagerState.CREATE_BACKUP,
+ "stage": CreateBackupStage.HOME_ASSISTANT,
+ "state": CreateBackupState.IN_PROGRESS,
+ }
+
+ result = await ws_client.receive_json()
+ assert result["event"] == {
+ "manager_state": BackupManagerState.CREATE_BACKUP,
+ "stage": CreateBackupStage.UPLOAD_TO_AGENTS,
+ "state": CreateBackupState.IN_PROGRESS,
+ }
+
+ result = await ws_client.receive_json()
+ assert result["event"] == {
+ "manager_state": BackupManagerState.CREATE_BACKUP,
+ "stage": None,
+ "state": CreateBackupState.COMPLETED,
+ }
+
+ result = await ws_client.receive_json()
+ assert result["event"] == {"manager_state": BackupManagerState.IDLE}
+
+ assert unlink_mock.call_count == temp_file_unlink_call_count
+
+ assert mocked_json_bytes.call_count == 1
+ backup_json_dict = mocked_json_bytes.call_args[0][0]
+ assert isinstance(backup_json_dict, dict)
+ assert backup_json_dict == {
+ "compressed": True,
+ "date": ANY,
+ "extra": {
+ "instance_id": hass.data["core.uuid"],
+ "with_automatic_settings": False,
+ },
+ "homeassistant": {
+ "exclude_database": not include_database,
+ "version": "2025.1.0",
+ },
+ "name": name,
+ "protected": bool(password),
+ "slug": ANY,
+ "type": "partial",
+ "version": 2,
+ }
+
+ 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"]
+ backup_agent_ids = backup_data.pop("agent_ids")
+
+ assert backup_agent_ids == agent_ids
+ assert backup_data == {
+ "addons": [],
+ "backup_id": ANY,
+ "database_included": include_database,
+ "date": ANY,
+ "failed_agent_ids": [],
+ "folders": [],
+ "homeassistant_included": True,
+ "homeassistant_version": "2025.1.0",
+ "name": name,
+ "protected": bool(password),
+ "size": ANY,
+ "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)] + [
+ call(file, arcname=f"data/{file}", recursive=False)
+ for file in _EXPECTED_FILES_WITH_DATABASE[include_database]
+ ]
+ assert core_tar.add.call_args_list == expected_files
+
+ 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"
+
+
+@pytest.mark.usefixtures("mock_backup_generation")
+@pytest.mark.parametrize("exception", [BackupAgentError("Boom!"), Exception("Boom!")])
+async def test_async_initiate_backup_with_agent_error(
+ hass: HomeAssistant,
+ hass_ws_client: WebSocketGenerator,
+ generate_backup_id: MagicMock,
+ path_glob: MagicMock,
+ hass_storage: dict[str, Any],
+ exception: Exception,
+) -> None:
+ """Test agent upload error during backup generation."""
+ agent_ids = [LOCAL_AGENT_ID, "test.remote"]
+ 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",
+ },
+ ],
+ "agent_ids": [
+ "test.remote",
+ ],
+ "backup_id": "backup1",
+ "database_included": True,
+ "date": "1970-01-01T00:00:00.000Z",
+ "failed_agent_ids": [],
+ "folders": [
+ "media",
+ "share",
+ ],
+ "homeassistant_included": True,
+ "homeassistant_version": "2024.12.0",
+ "name": "Test",
+ "protected": False,
+ "size": 0,
+ "with_automatic_settings": True,
+ },
+ {
+ "addons": [],
+ "agent_ids": [
+ "test.remote",
+ ],
+ "backup_id": "backup2",
+ "database_included": False,
+ "date": "1980-01-01T00:00:00.000Z",
+ "failed_agent_ids": [],
+ "folders": [
+ "media",
+ "share",
+ ],
+ "homeassistant_included": True,
+ "homeassistant_version": "2024.12.0",
+ "name": "Test 2",
+ "protected": False,
+ "size": 1,
+ "with_automatic_settings": None,
+ },
+ {
+ "addons": [
+ {
+ "name": "Test",
+ "slug": "test",
+ "version": "1.0.0",
+ },
+ ],
+ "agent_ids": [
+ "test.remote",
+ ],
+ "backup_id": "backup3",
+ "database_included": True,
+ "date": "1970-01-01T00:00:00.000Z",
+ "failed_agent_ids": [],
+ "folders": [
+ "media",
+ "share",
+ ],
+ "homeassistant_included": True,
+ "homeassistant_version": "2024.12.0",
+ "name": "Test",
+ "protected": False,
+ "size": 0,
+ "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,
+ ),
+ )
+
+ 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,
+ }
+
+ 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()
+
+ with (
+ patch("pathlib.Path.open", mock_open(read_data=b"test")),
+ patch.object(
+ remote_agent,
+ "async_upload_backup",
+ side_effect=exception,
+ ),
+ patch.object(remote_agent, "async_delete_backup", delete_backup),
+ ):
+ await ws_client.send_json_auto_id(
+ {"type": "backup/generate", "agent_ids": agent_ids}
+ )
+ result = await ws_client.receive_json()
+ assert result["event"] == {
+ "manager_state": BackupManagerState.CREATE_BACKUP,
+ "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()
+
+ result = await ws_client.receive_json()
+ assert result["event"] == {
+ "manager_state": BackupManagerState.CREATE_BACKUP,
+ "stage": CreateBackupStage.HOME_ASSISTANT,
+ "state": CreateBackupState.IN_PROGRESS,
+ }
+
+ result = await ws_client.receive_json()
+ assert result["event"] == {
+ "manager_state": BackupManagerState.CREATE_BACKUP,
+ "stage": CreateBackupStage.UPLOAD_TO_AGENTS,
+ "state": CreateBackupState.IN_PROGRESS,
+ }
+
+ result = await ws_client.receive_json()
+ assert result["event"] == {
+ "manager_state": BackupManagerState.CREATE_BACKUP,
+ "stage": None,
+ "state": CreateBackupState.FAILED,
+ }
+
+ result = await ws_client.receive_json()
+ assert result["event"] == {"manager_state": BackupManagerState.IDLE}
+
+ new_expected_backup_data = {
+ "addons": [],
+ "agent_ids": ["backup.local"],
+ "backup_id": "abc123",
+ "database_included": True,
+ "date": ANY,
+ "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,
+ }
+
+ await ws_client.send_json_auto_id({"type": "backup/info"})
+ result = await ws_client.receive_json()
+ backups_response = result["result"].pop("backups")
+
+ assert len(backups_response) == 4
+ assert new_expected_backup_data in backups_response
+ assert result["result"] == {
+ "agent_errors": {},
+ "last_attempted_automatic_backup": None,
+ "last_completed_automatic_backup": None,
+ }
+
+ await hass.async_block_till_done()
+ assert hass_storage[DOMAIN]["data"]["backups"] == [
+ {
+ "backup_id": "abc123",
+ "failed_agent_ids": ["test.remote"],
+ }
+ ]
+
+ # one of the two matching backups with the remote agent should have been deleted
+ assert delete_backup.call_count == 1
+
+
+@pytest.mark.usefixtures("mock_backup_generation")
+@pytest.mark.parametrize(
+ ("create_backup_command", "issues_after_create_backup"),
+ [
+ (
+ {"type": "backup/generate", "agent_ids": [LOCAL_AGENT_ID]},
+ {(DOMAIN, "automatic_backup_failed")},
+ ),
+ (
+ {"type": "backup/generate_with_automatic_settings"},
+ set(),
+ ),
+ ],
+)
+async def test_create_backup_success_clears_issue(
+ hass: HomeAssistant,
+ hass_ws_client: WebSocketGenerator,
+ create_backup_command: dict[str, Any],
+ issues_after_create_backup: set[tuple[str, str]],
+) -> None:
+ """Test backup issue is cleared after backup is created."""
+ await async_setup_component(hass, DOMAIN, {})
+ await hass.async_block_till_done()
+
+ # Create a backup issue
+ ir.async_create_issue(
+ hass,
+ DOMAIN,
+ "automatic_backup_failed",
+ is_fixable=False,
+ is_persistent=True,
+ severity=ir.IssueSeverity.WARNING,
+ translation_key="automatic_backup_failed_create",
+ )
+
+ ws_client = await hass_ws_client(hass)
+
+ await ws_client.send_json_auto_id(
+ {
+ "type": "backup/config/update",
+ "create_backup": {"agent_ids": [LOCAL_AGENT_ID]},
+ }
+ )
+ result = await ws_client.receive_json()
+ assert result["success"] is True
+
+ await ws_client.send_json_auto_id(create_backup_command)
+ result = await ws_client.receive_json()
+ assert result["success"] is True
+
+ await hass.async_block_till_done()
+
+ issue_registry = ir.async_get(hass)
+ assert set(issue_registry.issues) == issues_after_create_backup
+
+
+async def delayed_boom(*args, **kwargs) -> None:
+ """Raise an exception after a delay."""
+
+ async def delayed_boom() -> None:
+ await asyncio.sleep(0)
+ raise Exception("Boom!") # noqa: TRY002
+
+ return (NewBackup(backup_job_id="abc123"), delayed_boom())
+
+
+@pytest.mark.parametrize(
+ (
+ "create_backup_command",
+ "create_backup_side_effect",
+ "agent_upload_side_effect",
+ "create_backup_result",
+ "issues_after_create_backup",
+ ),
+ [
+ # No error
+ (
+ {"type": "backup/generate", "agent_ids": ["test.remote"]},
+ None,
+ None,
+ True,
+ {},
+ ),
+ (
+ {"type": "backup/generate_with_automatic_settings"},
+ None,
+ None,
+ True,
+ {},
+ ),
+ # Error raised in async_initiate_backup
+ (
+ {"type": "backup/generate", "agent_ids": ["test.remote"]},
+ Exception("Boom!"),
+ None,
+ False,
+ {},
+ ),
+ (
+ {"type": "backup/generate_with_automatic_settings"},
+ Exception("Boom!"),
+ None,
+ False,
+ {
+ (DOMAIN, "automatic_backup_failed"): {
+ "translation_key": "automatic_backup_failed_create",
+ "translation_placeholders": None,
+ }
+ },
+ ),
+ # Error raised when awaiting the backup task
+ (
+ {"type": "backup/generate", "agent_ids": ["test.remote"]},
+ delayed_boom,
+ None,
+ True,
+ {},
+ ),
+ (
+ {"type": "backup/generate_with_automatic_settings"},
+ delayed_boom,
+ None,
+ True,
+ {
+ (DOMAIN, "automatic_backup_failed"): {
+ "translation_key": "automatic_backup_failed_create",
+ "translation_placeholders": None,
+ }
+ },
+ ),
+ # Error raised in async_upload_backup
+ (
+ {"type": "backup/generate", "agent_ids": ["test.remote"]},
+ None,
+ Exception("Boom!"),
+ True,
+ {},
+ ),
+ (
+ {"type": "backup/generate_with_automatic_settings"},
+ None,
+ Exception("Boom!"),
+ True,
+ {
+ (DOMAIN, "automatic_backup_failed"): {
+ "translation_key": "automatic_backup_failed_upload_agents",
+ "translation_placeholders": {"failed_agents": "test.remote"},
+ }
+ },
+ ),
+ ],
+)
+async def test_create_backup_failure_raises_issue(
+ hass: HomeAssistant,
+ hass_ws_client: WebSocketGenerator,
+ create_backup: AsyncMock,
+ create_backup_command: dict[str, Any],
+ create_backup_side_effect: Exception | None,
+ agent_upload_side_effect: Exception | None,
+ create_backup_result: bool,
+ issues_after_create_backup: dict[tuple[str, str], dict[str, Any]],
+) -> None:
+ """Test backup issue is cleared after backup is created."""
+ remote_agent = BackupAgentTest("remote", backups=[])
+
+ 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,
+ ),
+ )
+
+ await async_setup_component(hass, DOMAIN, {})
+ await hass.async_block_till_done()
+
+ ws_client = await hass_ws_client(hass)
+
+ create_backup.side_effect = create_backup_side_effect
+
+ await ws_client.send_json_auto_id(
+ {
+ "type": "backup/config/update",
+ "create_backup": {"agent_ids": ["test.remote"]},
+ }
+ )
+ result = await ws_client.receive_json()
+ assert result["success"] is True
+
+ with patch.object(
+ remote_agent, "async_upload_backup", side_effect=agent_upload_side_effect
+ ):
+ await ws_client.send_json_auto_id(create_backup_command)
+ result = await ws_client.receive_json()
+ assert result["success"] == create_backup_result
+ await hass.async_block_till_done()
+
+ issue_registry = ir.async_get(hass)
+ assert set(issue_registry.issues) == set(issues_after_create_backup)
+ for issue_id, issue_data in issues_after_create_backup.items():
+ issue = issue_registry.issues[issue_id]
+ assert issue.translation_key == issue_data["translation_key"]
+ assert issue.translation_placeholders == issue_data["translation_placeholders"]
+
+
+@pytest.mark.usefixtures("mock_backup_generation")
+@pytest.mark.parametrize(
+ "exception", [BackupReaderWriterError("Boom!"), BaseException("Boom!")]
+)
+async def test_async_initiate_backup_non_agent_upload_error(
+ hass: HomeAssistant,
+ hass_ws_client: WebSocketGenerator,
+ generate_backup_id: MagicMock,
+ path_glob: MagicMock,
+ hass_storage: dict[str, Any],
+ 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=[])
+
+ 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,
+ }
+
+ 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")),
+ patch.object(
+ remote_agent,
+ "async_upload_backup",
+ side_effect=exception,
+ ),
+ ):
+ await ws_client.send_json_auto_id(
+ {"type": "backup/generate", "agent_ids": agent_ids}
+ )
+ result = await ws_client.receive_json()
+ assert result["event"] == {
+ "manager_state": BackupManagerState.CREATE_BACKUP,
+ "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()
+
+ result = await ws_client.receive_json()
+ assert result["event"] == {
+ "manager_state": BackupManagerState.CREATE_BACKUP,
+ "stage": CreateBackupStage.HOME_ASSISTANT,
+ "state": CreateBackupState.IN_PROGRESS,
+ }
+
+ result = await ws_client.receive_json()
+ assert result["event"] == {
+ "manager_state": BackupManagerState.CREATE_BACKUP,
+ "stage": CreateBackupStage.UPLOAD_TO_AGENTS,
+ "state": CreateBackupState.IN_PROGRESS,
+ }
+
+ result = await ws_client.receive_json()
+ assert result["event"] == {
+ "manager_state": BackupManagerState.CREATE_BACKUP,
+ "stage": None,
+ "state": CreateBackupState.FAILED,
+ }
+
+ result = await ws_client.receive_json()
+ assert result["event"] == {"manager_state": BackupManagerState.IDLE}
+
+ assert not hass_storage[DOMAIN]["data"]
+
+
+@pytest.mark.usefixtures("mock_backup_generation")
+@pytest.mark.parametrize(
+ "exception", [BackupReaderWriterError("Boom!"), Exception("Boom!")]
+)
+async def test_async_initiate_backup_with_task_error(
+ hass: HomeAssistant,
+ hass_ws_client: WebSocketGenerator,
+ generate_backup_id: MagicMock,
+ path_glob: MagicMock,
+ create_backup: AsyncMock,
+ exception: Exception,
+) -> None:
+ """Test backup task error during backup generation."""
+ backup_task: asyncio.Future[Any] = asyncio.Future()
+ backup_task.set_exception(exception)
+ create_backup.return_value = (NewBackup(backup_job_id="abc123"), backup_task)
+ agent_ids = [LOCAL_AGENT_ID, "test.remote"]
+ 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,
+ }
+
+ 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
+
+ await ws_client.send_json_auto_id(
+ {"type": "backup/generate", "agent_ids": agent_ids}
+ )
+ await hass.async_block_till_done()
+
+ result = await ws_client.receive_json()
+ assert result["event"] == {
+ "manager_state": BackupManagerState.CREATE_BACKUP,
+ "stage": None,
+ "state": CreateBackupState.IN_PROGRESS,
+ }
+
+ result = await ws_client.receive_json()
+ assert result["event"] == {
+ "manager_state": BackupManagerState.CREATE_BACKUP,
+ "stage": None,
+ "state": CreateBackupState.FAILED,
+ }
+
+ result = await ws_client.receive_json()
+ assert result["event"] == {"manager_state": BackupManagerState.IDLE}
+
+ 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
+
+
+@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",
+ ),
+ [
+ (1, OSError("Boom!"), 0, None, 0, None, 1, None),
+ (1, None, 1, OSError("Boom!"), 1, None, 1, None),
+ (1, None, 1, None, 1, OSError("Boom!"), 1, None),
+ (1, None, 1, None, 1, None, 1, OSError("Boom!")),
+ ],
+)
+async def test_initiate_backup_file_error(
+ hass: HomeAssistant,
+ hass_ws_client: WebSocketGenerator,
+ generate_backup_id: MagicMock,
+ path_glob: MagicMock,
+ open_call_count: int,
+ open_exception: Exception | None,
+ read_call_count: int,
+ read_exception: Exception | None,
+ close_call_count: int,
+ close_exception: Exception | None,
+ unlink_call_count: int,
+ unlink_exception: Exception | None,
+) -> None:
+ """Test file error during generate backup."""
+ agent_ids = ["test.remote"]
+ 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,
+ }
+
+ 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(read_data=b"test")
+ 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,
+ ):
+ await ws_client.send_json_auto_id(
+ {"type": "backup/generate", "agent_ids": agent_ids}
+ )
+
+ result = await ws_client.receive_json()
+ assert result["event"] == {
+ "manager_state": BackupManagerState.CREATE_BACKUP,
+ "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()
+
+ result = await ws_client.receive_json()
+ assert result["event"] == {
+ "manager_state": BackupManagerState.CREATE_BACKUP,
+ "stage": CreateBackupStage.HOME_ASSISTANT,
+ "state": CreateBackupState.IN_PROGRESS,
+ }
+
+ result = await ws_client.receive_json()
+ assert result["event"] == {
+ "manager_state": BackupManagerState.CREATE_BACKUP,
+ "stage": CreateBackupStage.UPLOAD_TO_AGENTS,
+ "state": CreateBackupState.IN_PROGRESS,
+ }
+
+ result = await ws_client.receive_json()
+ assert result["event"] == {
+ "manager_state": BackupManagerState.CREATE_BACKUP,
+ "stage": None,
+ "state": CreateBackupState.FAILED,
+ }
+
+ result = await ws_client.receive_json()
+ assert result["event"] == {"manager_state": BackupManagerState.IDLE}
+
+ 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
async def test_loading_platforms(
@@ -200,164 +1158,510 @@ async def test_loading_platforms(
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test loading backup platforms."""
- manager = BackupManager(hass)
+ manager = BackupManager(hass, CoreBackupReaderWriter(hass))
- assert not manager.loaded_platforms
assert not manager.platforms
- await _setup_mock_domain(
+ get_agents_mock = AsyncMock(return_value=[])
+
+ await setup_backup_platform(
hass,
- Mock(
+ 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 manager.loaded_platforms
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 Path("test.tar")
+
+
+@pytest.mark.parametrize(
+ ("agent_class", "num_local_agents"),
+ [(LocalBackupAgentTest, 2), (BackupAgentTest, 1)],
+)
+async def test_loading_platform_with_listener(
+ hass: HomeAssistant,
+ hass_ws_client: WebSocketGenerator,
+ agent_class: type[BackupAgentTest],
+ num_local_agents: int,
+) -> None:
+ """Test loading a backup agent platform which can be listened to."""
+ ws_client = await hass_ws_client(hass)
+ assert await async_setup_component(hass, DOMAIN, {})
+ manager = hass.data[DATA_MANAGER]
+
+ get_agents_mock = AsyncMock(return_value=[agent_class("remote1", backups=[])])
+ register_listener_mock = Mock()
+
+ await setup_backup_platform(
+ hass,
+ domain="test",
+ platform=Mock(
+ async_get_backup_agents=get_agents_mock,
+ async_register_backup_agents_listener=register_listener_mock,
+ ),
+ )
+ await hass.async_block_till_done()
+
+ 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"},
+ ]
+ assert len(manager.local_backup_agents) == num_local_agents
+
+ get_agents_mock.assert_called_once_with(hass)
+ register_listener_mock.assert_called_once_with(hass, listener=ANY)
+
+ get_agents_mock.reset_mock()
+ get_agents_mock.return_value = [agent_class("remote2", backups=[])]
+ listener = register_listener_mock.call_args[1]["listener"]
+ listener()
+
+ get_agents_mock.assert_called_once_with(hass)
+ 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"},
+ ]
+ assert len(manager.local_backup_agents) == num_local_agents
+
+
+@pytest.mark.parametrize(
+ "platform_mock",
+ [
+ Mock(async_pre_backup=AsyncMock(), spec=["async_pre_backup"]),
+ Mock(async_post_backup=AsyncMock(), spec=["async_post_backup"]),
+ Mock(spec=[]),
+ ],
+)
async def test_not_loading_bad_platforms(
hass: HomeAssistant,
- caplog: pytest.LogCaptureFixture,
+ platform_mock: Mock,
) -> None:
- """Test loading backup platforms."""
- manager = BackupManager(hass)
-
- assert not manager.loaded_platforms
- assert not manager.platforms
-
- await _setup_mock_domain(hass)
- await manager.load_platforms()
+ """Test not loading bad backup platforms."""
+ await setup_backup_platform(
+ hass,
+ domain="test",
+ platform=platform_mock,
+ )
+ assert await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
- assert manager.loaded_platforms
- assert len(manager.platforms) == 0
-
- assert "Loaded 0 platforms" in caplog.text
- assert (
- "some_domain does not implement required functions for the backup platform"
- in caplog.text
- )
+ assert platform_mock.mock_calls == []
-async def test_exception_plaform_pre(hass: HomeAssistant) -> None:
+async def test_exception_platform_pre(hass: HomeAssistant) -> None:
"""Test exception in pre step."""
- manager = BackupManager(hass)
- manager.loaded_backups = True
async def _mock_step(hass: HomeAssistant) -> None:
raise HomeAssistantError("Test exception")
- await _setup_mock_domain(
+ remote_agent = BackupAgentTest("remote", backups=[])
+ await setup_backup_platform(
hass,
- Mock(
+ domain="test",
+ platform=Mock(
async_pre_backup=_mock_step,
async_post_backup=AsyncMock(),
+ async_get_backup_agents=AsyncMock(return_value=[remote_agent]),
),
)
+ assert await async_setup_component(hass, DOMAIN, {})
+ await hass.async_block_till_done()
- with pytest.raises(HomeAssistantError):
- await _mock_backup_generation(manager)
+ with pytest.raises(BackupManagerError) as err:
+ await hass.services.async_call(
+ DOMAIN,
+ "create",
+ blocking=True,
+ )
+
+ assert str(err.value) == "Error during pre-backup: Test exception"
-async def test_exception_plaform_post(hass: HomeAssistant) -> None:
+@pytest.mark.usefixtures("mock_backup_generation")
+async def test_exception_platform_post(hass: HomeAssistant) -> None:
"""Test exception in post step."""
- manager = BackupManager(hass)
- manager.loaded_backups = True
async def _mock_step(hass: HomeAssistant) -> None:
raise HomeAssistantError("Test exception")
- await _setup_mock_domain(
+ remote_agent = BackupAgentTest("remote", backups=[])
+ await setup_backup_platform(
hass,
- Mock(
+ domain="test",
+ platform=Mock(
async_pre_backup=AsyncMock(),
async_post_backup=_mock_step,
+ async_get_backup_agents=AsyncMock(return_value=[remote_agent]),
),
)
+ assert await async_setup_component(hass, DOMAIN, {})
+ await hass.async_block_till_done()
- with pytest.raises(HomeAssistantError):
- await _mock_backup_generation(manager)
+ with pytest.raises(BackupManagerError) as err:
+ await hass.services.async_call(
+ DOMAIN,
+ "create",
+ blocking=True,
+ )
+
+ assert str(err.value) == "Error during post-backup: Test exception"
-async def test_loading_platforms_when_running_async_pre_backup_actions(
+@pytest.mark.parametrize(
+ (
+ "agent_id_params",
+ "open_call_count",
+ "move_call_count",
+ "move_path_names",
+ "remote_agent_backups",
+ "remote_agent_backup_data",
+ "temp_file_unlink_call_count",
+ ),
+ [
+ (
+ "agent_id=backup.local&agent_id=test.remote",
+ 2,
+ 1,
+ ["abc123.tar"],
+ {TEST_BACKUP_ABC123.backup_id: TEST_BACKUP_ABC123},
+ b"test",
+ 0,
+ ),
+ (
+ "agent_id=backup.local",
+ 1,
+ 1,
+ ["abc123.tar"],
+ {},
+ None,
+ 0,
+ ),
+ (
+ "agent_id=test.remote",
+ 2,
+ 0,
+ [],
+ {TEST_BACKUP_ABC123.backup_id: TEST_BACKUP_ABC123},
+ b"test",
+ 1,
+ ),
+ ],
+)
+async def test_receive_backup(
hass: HomeAssistant,
- caplog: pytest.LogCaptureFixture,
+ hass_client: ClientSessionGenerator,
+ agent_id_params: str,
+ open_call_count: int,
+ move_call_count: int,
+ move_path_names: list[str],
+ remote_agent_backups: dict[str, AgentBackup],
+ remote_agent_backup_data: bytes | None,
+ temp_file_unlink_call_count: int,
) -> None:
- """Test loading backup platforms when running post backup actions."""
- manager = BackupManager(hass)
-
- assert not manager.loaded_platforms
- assert not manager.platforms
-
- await _setup_mock_domain(
+ """Test receive backup and upload to the local and a remote agent."""
+ remote_agent = BackupAgentTest("remote", backups=[])
+ await setup_backup_platform(
hass,
- Mock(
- async_pre_backup=AsyncMock(),
- async_post_backup=AsyncMock(),
+ domain="test",
+ platform=Mock(
+ async_get_backup_agents=AsyncMock(return_value=[remote_agent]),
+ spec_set=BackupAgentPlatformProtocol,
),
)
- await manager.async_pre_backup_actions()
+ assert await async_setup_component(hass, DOMAIN, {})
+ await hass.async_block_till_done()
+ client = await hass_client()
- assert manager.loaded_platforms
- assert len(manager.platforms) == 1
+ upload_data = "test"
+ open_mock = mock_open(read_data=upload_data.encode(encoding="utf-8"))
- assert "Loaded 1 platforms" in caplog.text
+ with (
+ patch("pathlib.Path.open", open_mock),
+ patch(
+ "homeassistant.components.backup.manager.make_backup_dir"
+ ) as make_backup_dir_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(
+ f"/api/backup/upload?{agent_id_params}",
+ data={"file": StringIO(upload_data)},
+ )
+ await hass.async_block_till_done()
+
+ assert resp.status == 201
+ assert open_mock.call_count == open_call_count
+ assert make_backup_dir_mock.call_count == move_call_count + 1
+ assert move_mock.call_count == move_call_count
+ for index, name in enumerate(move_path_names):
+ assert move_mock.call_args_list[index].args[1].name == name
+ assert remote_agent._backups == remote_agent_backups
+ assert remote_agent._backup_data == remote_agent_backup_data
+ assert unlink_mock.call_count == temp_file_unlink_call_count
-async def test_loading_platforms_when_running_async_post_backup_actions(
+@pytest.mark.usefixtures("mock_backup_generation")
+async def test_receive_backup_busy_manager(
hass: HomeAssistant,
- caplog: pytest.LogCaptureFixture,
+ hass_client: ClientSessionGenerator,
+ hass_ws_client: WebSocketGenerator,
) -> None:
- """Test loading backup platforms when running post backup actions."""
- manager = BackupManager(hass)
+ """Test receive backup with a busy manager."""
+ assert await async_setup_component(hass, DOMAIN, {})
+ await hass.async_block_till_done()
+ client = await hass_client()
+ ws_client = await hass_ws_client(hass)
- assert not manager.loaded_platforms
- assert not manager.platforms
+ upload_data = "test"
- await _setup_mock_domain(
- hass,
- Mock(
- async_pre_backup=AsyncMock(),
- async_post_backup=AsyncMock(),
- ),
+ await ws_client.send_json_auto_id({"type": "backup/subscribe_events"})
+ result = await ws_client.receive_json()
+ assert result["event"] == {"manager_state": "idle"}
+
+ 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"}
+
+ assert create_backup.call_count == 1
+
+ resp = await client.post(
+ "/api/backup/upload?agent_id=backup.local",
+ data={"file": StringIO(upload_data)},
)
- await manager.async_post_backup_actions()
- assert manager.loaded_platforms
- assert len(manager.platforms) == 1
+ assert resp.status == 500
+ assert (
+ await resp.text()
+ == "Can't upload backup file: Backup manager busy: create_backup"
+ )
- assert "Loaded 1 platforms" in caplog.text
+ # finish the backup
+ backup_task.set_result(
+ WrittenBackup(
+ backup=TEST_BACKUP_ABC123,
+ open_stream=AsyncMock(),
+ release_stream=AsyncMock(),
+ )
+ )
+ await hass.async_block_till_done()
+@pytest.mark.parametrize(
+ ("agent_id", "password", "restore_database", "restore_homeassistant", "dir"),
+ [
+ (LOCAL_AGENT_ID, None, True, False, "backups"),
+ (LOCAL_AGENT_ID, "abc123", False, True, "backups"),
+ ("test.remote", None, True, True, "tmp_backups"),
+ ],
+)
async def test_async_trigger_restore(
hass: HomeAssistant,
- caplog: pytest.LogCaptureFixture,
+ agent_id: str,
+ password: str | None,
+ restore_database: bool,
+ restore_homeassistant: bool,
+ dir: str,
) -> None:
"""Test trigger restore."""
- manager = BackupManager(hass)
- manager.loaded_backups = True
- manager.backups = {TEST_BACKUP.slug: TEST_BACKUP}
+ manager = BackupManager(hass, CoreBackupReaderWriter(hass))
+ hass.data[DATA_MANAGER] = manager
+
+ await setup_backup_platform(hass, domain=DOMAIN, platform=local_backup_platform)
+ await setup_backup_platform(
+ hass,
+ domain="test",
+ platform=Mock(
+ async_get_backup_agents=AsyncMock(
+ return_value=[BackupAgentTest("remote", backups=[TEST_BACKUP_ABC123])]
+ ),
+ 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
+
+ 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(BackupAgentTest, "async_download_backup") as download_mock,
+ ):
+ 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(
+ {
+ "path": 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(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."""
+ password = "hunter2"
+ manager = BackupManager(hass, CoreBackupReaderWriter(hass))
+ hass.data[DATA_MANAGER] = manager
+
+ await setup_backup_platform(hass, domain=DOMAIN, platform=local_backup_platform)
+ await setup_backup_platform(
+ hass,
+ domain="test",
+ platform=Mock(
+ async_get_backup_agents=AsyncMock(
+ return_value=[BackupAgentTest("remote", backups=[TEST_BACKUP_ABC123])]
+ ),
+ 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
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,
):
- await manager.async_restore_backup(TEST_BACKUP.slug)
- assert mocked_write_text.call_args[0][0] == '{"path": "abc123.tar"}'
- assert mocked_service_call.called
+ 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,
+ )
+
+ 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()
-async def test_async_trigger_restore_missing_backup(hass: HomeAssistant) -> None:
+@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)
- manager.loaded_backups = True
+ manager = BackupManager(hass, CoreBackupReaderWriter(hass))
- with pytest.raises(HomeAssistantError, match="Backup abc123 not found"):
- await manager.async_restore_backup(TEST_BACKUP.slug)
+ 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))
+
+ mocked_write_text.assert_not_called()
+ mocked_service_call.assert_not_called()
diff --git a/tests/components/backup/test_models.py b/tests/components/backup/test_models.py
new file mode 100644
index 00000000000..6a547f40dc3
--- /dev/null
+++ b/tests/components/backup/test_models.py
@@ -0,0 +1,11 @@
+"""Tests for the Backup integration."""
+
+from homeassistant.components.backup import AgentBackup
+
+from .common import TEST_BACKUP_ABC123
+
+
+async def test_agent_backup_serialization() -> None:
+ """Test AgentBackup serialization."""
+
+ assert AgentBackup.from_dict(TEST_BACKUP_ABC123.as_dict()) == TEST_BACKUP_ABC123
diff --git a/tests/components/backup/test_util.py b/tests/components/backup/test_util.py
new file mode 100644
index 00000000000..60cfc77b1aa
--- /dev/null
+++ b/tests/components/backup/test_util.py
@@ -0,0 +1,132 @@
+"""Tests for the Backup integration's utility functions."""
+
+from __future__ import annotations
+
+import tarfile
+from unittest.mock import Mock, patch
+
+import pytest
+
+from homeassistant.components.backup import AddonInfo, AgentBackup, Folder
+from homeassistant.components.backup.util import read_backup, validate_password
+
+
+@pytest.mark.parametrize(
+ ("backup_json_content", "expected_backup"),
+ [
+ (
+ b'{"compressed":true,"date":"2024-12-02T07:23:58.261875-05:00","homeassistant":'
+ b'{"exclude_database":true,"version":"2024.12.0.dev0"},"name":"test",'
+ b'"protected":true,"slug":"455645fe","type":"partial","version":2}',
+ AgentBackup(
+ addons=[],
+ backup_id="455645fe",
+ 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=1234,
+ ),
+ ),
+ (
+ b'{"slug":"d4b8fdc6","version":2,"name":"Core 2025.1.0.dev0",'
+ b'"date":"2024-12-20T11:27:51.119062+00:00","type":"partial",'
+ b'"supervisor_version":"2024.12.1.dev1803",'
+ b'"extra":{"instance_id":"6b453733d2d74d2a9ae432ff2fbaaa64",'
+ b'"with_automatic_settings":false},"homeassistant":'
+ b'{"version":"2025.1.0.dev202412200230","exclude_database":false,"size":0.0},'
+ b'"compressed":true,"protected":true,"repositories":['
+ b'"https://github.com/home-assistant/hassio-addons-development","local",'
+ b'"https://github.com/esphome/home-assistant-addon","core",'
+ b'"https://github.com/music-assistant/home-assistant-addon",'
+ b'"https://github.com/hassio-addons/repository"],"crypto":"aes128",'
+ b'"folders":["share","media"],"addons":[{"slug":"core_configurator",'
+ b'"name":"File editor","version":"5.5.0","size":0.0},'
+ b'{"slug":"ae6e943c_remote_api","name":"Remote API proxy",'
+ b'"version":"1.3.0","size":0.0}],"docker":{"registries":{}}}',
+ AgentBackup(
+ addons=[
+ AddonInfo(
+ name="File editor",
+ slug="core_configurator",
+ version="5.5.0",
+ ),
+ AddonInfo(
+ name="Remote API proxy",
+ slug="ae6e943c_remote_api",
+ version="1.3.0",
+ ),
+ ],
+ backup_id="d4b8fdc6",
+ date="2024-12-20T11:27:51.119062+00:00",
+ database_included=True,
+ extra_metadata={
+ "instance_id": "6b453733d2d74d2a9ae432ff2fbaaa64",
+ "with_automatic_settings": False,
+ },
+ folders=[Folder.SHARE, Folder.MEDIA],
+ homeassistant_included=True,
+ homeassistant_version="2025.1.0.dev202412200230",
+ name="Core 2025.1.0.dev0",
+ protected=True,
+ size=1234,
+ ),
+ ),
+ ],
+)
+def test_read_backup(backup_json_content: bytes, expected_backup: AgentBackup) -> None:
+ """Test reading a backup."""
+ mock_path = Mock()
+ mock_path.stat.return_value.st_size = 1234
+
+ with patch("homeassistant.components.backup.util.tarfile.open") as mock_open_tar:
+ mock_open_tar.return_value.__enter__.return_value.extractfile.return_value.read.return_value = backup_json_content
+ backup = read_backup(mock_path)
+ assert backup == expected_backup
+
+
+@pytest.mark.parametrize("password", [None, "hunter2"])
+def test_validate_password(password: str | None) -> None:
+ """Test validating a password."""
+ mock_path = Mock()
+
+ with (
+ patch("homeassistant.components.backup.util.tarfile.open"),
+ patch("homeassistant.components.backup.util.SecureTarFile"),
+ ):
+ assert validate_password(mock_path, password) is True
+
+
+@pytest.mark.parametrize("password", [None, "hunter2"])
+@pytest.mark.parametrize("secure_tar_side_effect", [tarfile.ReadError, Exception])
+def test_validate_password_wrong_password(
+ password: str | None, secure_tar_side_effect: Exception
+) -> None:
+ """Test validating a password."""
+ mock_path = Mock()
+
+ with (
+ patch("homeassistant.components.backup.util.tarfile.open"),
+ patch(
+ "homeassistant.components.backup.util.SecureTarFile",
+ ) as mock_secure_tar,
+ ):
+ mock_secure_tar.return_value.__enter__.side_effect = secure_tar_side_effect
+ assert validate_password(mock_path, password) is False
+
+
+def test_validate_password_no_homeassistant() -> None:
+ """Test validating a password."""
+ mock_path = Mock()
+
+ with (
+ patch("homeassistant.components.backup.util.tarfile.open") as mock_open_tar,
+ ):
+ mock_open_tar.return_value.__enter__.return_value.extractfile.side_effect = (
+ KeyError
+ )
+ assert validate_password(mock_path, "hunter2") is False
diff --git a/tests/components/backup/test_websocket.py b/tests/components/backup/test_websocket.py
index 125ba8adaad..307a1d79e0c 100644
--- a/tests/components/backup/test_websocket.py
+++ b/tests/components/backup/test_websocket.py
@@ -1,18 +1,81 @@
"""Tests for the Backup integration."""
-from unittest.mock import patch
+from collections.abc import Generator
+from typing import Any
+from unittest.mock import ANY, AsyncMock, MagicMock, Mock, call, patch
+from freezegun.api import FrozenDateTimeFactory
import pytest
from syrupy import SnapshotAssertion
-from homeassistant.components.backup.manager import Backup
+from homeassistant.components.backup import (
+ AgentBackup,
+ BackupAgentError,
+ BackupAgentPlatformProtocol,
+ BackupReaderWriterError,
+ Folder,
+)
+from homeassistant.components.backup.agent import BackupAgentUnreachableError
+from homeassistant.components.backup.const import DATA_MANAGER, DOMAIN
+from homeassistant.components.backup.manager import (
+ CreateBackupEvent,
+ CreateBackupState,
+ ManagerBackup,
+ NewBackup,
+)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
+from homeassistant.setup import async_setup_component
-from .common import TEST_BACKUP, setup_backup_integration
+from .common import (
+ LOCAL_AGENT_ID,
+ TEST_BACKUP_ABC123,
+ TEST_BACKUP_DEF456,
+ BackupAgentTest,
+ setup_backup_integration,
+ setup_backup_platform,
+)
+from tests.common import async_fire_time_changed, async_mock_service
from tests.typing import WebSocketGenerator
+BACKUP_CALL = call(
+ agent_ids=["test.test-agent"],
+ backup_name="test-name",
+ extra_metadata={"instance_id": ANY, "with_automatic_settings": True},
+ include_addons=["test-addon"],
+ include_all_addons=False,
+ include_database=True,
+ include_folders=["media"],
+ include_homeassistant=True,
+ password="test-password",
+ on_progress=ANY,
+)
+
+DEFAULT_STORAGE_DATA: dict[str, Any] = {
+ "backups": {},
+ "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",
+ },
+ },
+}
+
@pytest.fixture
def sync_access_token_proxy(
@@ -26,145 +89,673 @@ def sync_access_token_proxy(
return request.getfixturevalue(access_token_fixture_name)
+@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.fixture(name="delete_backup")
+def mock_delete_backup() -> Generator[AsyncMock]:
+ """Mock manager delete backup."""
+ with patch(
+ "homeassistant.components.backup.BackupManager.async_delete_backup"
+ ) as mock_delete_backup:
+ yield mock_delete_backup
+
+
+@pytest.fixture(name="get_backups")
+def mock_get_backups() -> Generator[AsyncMock]:
+ """Mock manager get backups."""
+ with patch(
+ "homeassistant.components.backup.BackupManager.async_get_backups"
+ ) as mock_get_backups:
+ yield mock_get_backups
+
+
@pytest.mark.parametrize(
- "with_hassio",
+ ("remote_agents", "remote_backups"),
[
- pytest.param(True, id="with_hassio"),
- pytest.param(False, id="without_hassio"),
+ ([], {}),
+ (["remote"], {}),
+ (["remote"], {"test.remote": [TEST_BACKUP_ABC123]}),
+ (["remote"], {"test.remote": [TEST_BACKUP_DEF456]}),
],
)
async def test_info(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
+ remote_agents: list[str],
+ remote_backups: dict[str, list[AgentBackup]],
snapshot: SnapshotAssertion,
- with_hassio: bool,
) -> None:
"""Test getting backup info."""
- await setup_backup_integration(hass, with_hassio=with_hassio)
+ await setup_backup_integration(
+ hass,
+ with_hassio=False,
+ backups={LOCAL_AGENT_ID: [TEST_BACKUP_ABC123]} | remote_backups,
+ remote_agents=remote_agents,
+ )
client = await hass_ws_client(hass)
await hass.async_block_till_done()
- with patch(
- "homeassistant.components.backup.manager.BackupManager.async_get_backups",
- return_value={TEST_BACKUP.slug: TEST_BACKUP},
- ):
- await client.send_json_auto_id({"type": "backup/info"})
- assert snapshot == await client.receive_json()
+ await client.send_json_auto_id({"type": "backup/info"})
+ assert await client.receive_json() == snapshot
@pytest.mark.parametrize(
- "backup_content",
- [
- pytest.param(TEST_BACKUP, id="with_backup_content"),
- pytest.param(None, id="without_backup_content"),
- ],
+ "side_effect", [HomeAssistantError("Boom!"), BackupAgentUnreachableError]
)
+async def test_info_with_errors(
+ hass: HomeAssistant,
+ hass_ws_client: WebSocketGenerator,
+ side_effect: Exception,
+ snapshot: SnapshotAssertion,
+) -> None:
+ """Test getting backup info with one unavailable agent."""
+ await setup_backup_integration(
+ hass, with_hassio=False, backups={LOCAL_AGENT_ID: [TEST_BACKUP_ABC123]}
+ )
+ hass.data[DATA_MANAGER].backup_agents["domain.test"] = BackupAgentTest("test")
+
+ client = await hass_ws_client(hass)
+ await hass.async_block_till_done()
+
+ with patch.object(BackupAgentTest, "async_list_backups", side_effect=side_effect):
+ await client.send_json_auto_id({"type": "backup/info"})
+ assert await client.receive_json() == snapshot
+
+
@pytest.mark.parametrize(
- "with_hassio",
+ ("remote_agents", "backups"),
[
- pytest.param(True, id="with_hassio"),
- pytest.param(False, id="without_hassio"),
+ ([], {}),
+ (["remote"], {LOCAL_AGENT_ID: [TEST_BACKUP_ABC123]}),
+ (["remote"], {"test.remote": [TEST_BACKUP_ABC123]}),
+ (["remote"], {"test.remote": [TEST_BACKUP_DEF456]}),
+ (
+ ["remote"],
+ {
+ LOCAL_AGENT_ID: [TEST_BACKUP_ABC123],
+ "test.remote": [TEST_BACKUP_ABC123],
+ },
+ ),
],
)
async def test_details(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
+ remote_agents: list[str],
+ backups: dict[str, list[AgentBackup]],
snapshot: SnapshotAssertion,
- with_hassio: bool,
- backup_content: Backup | None,
) -> None:
"""Test getting backup info."""
- await setup_backup_integration(hass, with_hassio=with_hassio)
+ await setup_backup_integration(
+ hass, with_hassio=False, backups=backups, remote_agents=remote_agents
+ )
client = await hass_ws_client(hass)
await hass.async_block_till_done()
- with patch(
- "homeassistant.components.backup.manager.BackupManager.async_get_backup",
- return_value=backup_content,
- ):
- await client.send_json_auto_id({"type": "backup/details", "slug": "abc123"})
+ with patch("pathlib.Path.exists", return_value=True):
+ await client.send_json_auto_id(
+ {"type": "backup/details", "backup_id": "abc123"}
+ )
assert await client.receive_json() == snapshot
@pytest.mark.parametrize(
- "with_hassio",
- [
- pytest.param(True, id="with_hassio"),
- pytest.param(False, id="without_hassio"),
- ],
+ "side_effect", [HomeAssistantError("Boom!"), BackupAgentUnreachableError]
)
-async def test_remove(
+async def test_details_with_errors(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
+ side_effect: Exception,
snapshot: SnapshotAssertion,
- with_hassio: bool,
) -> None:
- """Test removing a backup file."""
- await setup_backup_integration(hass, with_hassio=with_hassio)
+ """Test getting backup info with one unavailable agent."""
+ await setup_backup_integration(
+ hass, with_hassio=False, backups={LOCAL_AGENT_ID: [TEST_BACKUP_ABC123]}
+ )
+ hass.data[DATA_MANAGER].backup_agents["domain.test"] = BackupAgentTest("test")
client = await hass_ws_client(hass)
await hass.async_block_till_done()
- with patch(
- "homeassistant.components.backup.manager.BackupManager.async_remove_backup",
+ with (
+ patch("pathlib.Path.exists", return_value=True),
+ patch.object(BackupAgentTest, "async_get_backup", side_effect=side_effect),
):
- await client.send_json_auto_id({"type": "backup/remove", "slug": "abc123"})
- assert snapshot == await client.receive_json()
+ await client.send_json_auto_id(
+ {"type": "backup/details", "backup_id": "abc123"}
+ )
+ assert await client.receive_json() == snapshot
@pytest.mark.parametrize(
- "with_hassio",
+ ("remote_agents", "backups"),
[
- pytest.param(True, id="with_hassio"),
- pytest.param(False, id="without_hassio"),
+ ([], {}),
+ (["remote"], {LOCAL_AGENT_ID: [TEST_BACKUP_ABC123]}),
+ (["remote"], {"test.remote": [TEST_BACKUP_ABC123]}),
+ (["remote"], {"test.remote": [TEST_BACKUP_DEF456]}),
+ (
+ ["remote"],
+ {
+ LOCAL_AGENT_ID: [TEST_BACKUP_ABC123],
+ "test.remote": [TEST_BACKUP_ABC123],
+ },
+ ),
],
)
+async def test_delete(
+ hass: HomeAssistant,
+ hass_ws_client: WebSocketGenerator,
+ remote_agents: list[str],
+ backups: dict[str, list[AgentBackup]],
+ snapshot: SnapshotAssertion,
+) -> None:
+ """Test deleting a backup file."""
+ await setup_backup_integration(
+ hass, with_hassio=False, backups=backups, remote_agents=remote_agents
+ )
+
+ client = await hass_ws_client(hass)
+ await hass.async_block_till_done()
+
+ await client.send_json_auto_id({"type": "backup/info"})
+ assert await client.receive_json() == snapshot
+
+ await client.send_json_auto_id({"type": "backup/delete", "backup_id": "abc123"})
+ assert await client.receive_json() == snapshot
+
+ await client.send_json_auto_id({"type": "backup/info"})
+ assert await client.receive_json() == snapshot
+
+
+@pytest.mark.parametrize(
+ "storage_data",
+ [
+ DEFAULT_STORAGE_DATA,
+ DEFAULT_STORAGE_DATA
+ | {
+ "backups": [
+ {
+ "backup_id": "abc123",
+ "failed_agent_ids": ["test.remote"],
+ }
+ ]
+ },
+ ],
+)
+@pytest.mark.parametrize(
+ "side_effect", [None, HomeAssistantError("Boom!"), BackupAgentUnreachableError]
+)
+async def test_delete_with_errors(
+ hass: HomeAssistant,
+ hass_ws_client: WebSocketGenerator,
+ hass_storage: dict[str, Any],
+ side_effect: Exception,
+ storage_data: dict[str, Any] | None,
+ snapshot: SnapshotAssertion,
+) -> None:
+ """Test deleting a backup with one unavailable agent."""
+ hass_storage[DOMAIN] = {
+ "data": storage_data,
+ "key": DOMAIN,
+ "version": 1,
+ }
+ await setup_backup_integration(
+ hass, with_hassio=False, backups={LOCAL_AGENT_ID: [TEST_BACKUP_ABC123]}
+ )
+ hass.data[DATA_MANAGER].backup_agents["domain.test"] = BackupAgentTest("test")
+
+ client = await hass_ws_client(hass)
+ await hass.async_block_till_done()
+
+ with patch.object(BackupAgentTest, "async_delete_backup", side_effect=side_effect):
+ await client.send_json_auto_id({"type": "backup/delete", "backup_id": "abc123"})
+ assert await client.receive_json() == snapshot
+
+ await client.send_json_auto_id({"type": "backup/info"})
+ assert await client.receive_json() == snapshot
+
+
+async def test_agent_delete_backup(
+ hass: HomeAssistant,
+ hass_ws_client: WebSocketGenerator,
+ snapshot: SnapshotAssertion,
+) -> None:
+ """Test deleting a backup file with a mock agent."""
+ await setup_backup_integration(hass)
+ hass.data[DATA_MANAGER].backup_agents = {"domain.test": BackupAgentTest("test")}
+
+ client = await hass_ws_client(hass)
+ await hass.async_block_till_done()
+
+ with patch.object(BackupAgentTest, "async_delete_backup") as delete_mock:
+ await client.send_json_auto_id(
+ {
+ "type": "backup/delete",
+ "backup_id": "abc123",
+ }
+ )
+ assert await client.receive_json() == snapshot
+
+ assert delete_mock.call_args == call("abc123")
+
+
+@pytest.mark.parametrize(
+ "data",
+ [
+ None,
+ {},
+ {"password": "abc123"},
+ ],
+)
+@pytest.mark.usefixtures("mock_backup_generation")
async def test_generate(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
+ data: dict[str, Any] | None,
+ freezer: FrozenDateTimeFactory,
snapshot: SnapshotAssertion,
- with_hassio: bool,
) -> None:
"""Test generating a backup."""
- await setup_backup_integration(hass, with_hassio=with_hassio)
+ await setup_backup_integration(hass, with_hassio=False)
client = await hass_ws_client(hass)
+ freezer.move_to("2024-11-13 12:01:00+01:00")
await hass.async_block_till_done()
- with patch(
- "homeassistant.components.backup.manager.BackupManager.async_create_backup",
- return_value=TEST_BACKUP,
- ):
- await client.send_json_auto_id({"type": "backup/generate"})
- assert snapshot == await client.receive_json()
+ await client.send_json_auto_id({"type": "backup/subscribe_events"})
+ assert await client.receive_json() == snapshot
+ await client.send_json_auto_id(
+ {"type": "backup/generate", **{"agent_ids": ["backup.local"]} | (data or {})}
+ )
+ for _ in range(6):
+ assert await client.receive_json() == snapshot
@pytest.mark.parametrize(
- "with_hassio",
+ ("parameters", "expected_error"),
[
- pytest.param(True, id="with_hassio"),
- pytest.param(False, id="without_hassio"),
+ (
+ {"include_homeassistant": False},
+ "Home Assistant must be included in backup",
+ ),
+ (
+ {"include_addons": ["blah"]},
+ "Addons and folders are not supported by core backup",
+ ),
+ (
+ {"include_all_addons": True},
+ "Addons and folders are not supported by core backup",
+ ),
+ (
+ {"include_folders": ["ssl"]},
+ "Addons and folders are not supported by core backup",
+ ),
],
)
-async def test_restore(
+async def test_generate_wrong_parameters(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
+ parameters: dict[str, Any],
+ expected_error: str,
+) -> None:
+ """Test generating a backup."""
+ await setup_backup_integration(hass, with_hassio=False)
+
+ client = await hass_ws_client(hass)
+
+ default_parameters = {"type": "backup/generate", "agent_ids": ["backup.local"]}
+
+ await client.send_json_auto_id(default_parameters | parameters)
+ response = await client.receive_json()
+ assert not response["success"]
+ assert response["error"] == {
+ "code": "home_assistant_error",
+ "message": expected_error,
+ }
+
+
+@pytest.mark.usefixtures("mock_backup_generation")
+@pytest.mark.parametrize(
+ ("params", "expected_extra_call_params"),
+ [
+ ({"agent_ids": ["backup.local"]}, {"agent_ids": ["backup.local"]}),
+ (
+ {
+ "agent_ids": ["backup.local"],
+ "include_database": False,
+ "name": "abc123",
+ },
+ {
+ "agent_ids": ["backup.local"],
+ "include_addons": None,
+ "include_database": False,
+ "include_folders": None,
+ "name": "abc123",
+ },
+ ),
+ ],
+)
+async def test_generate_calls_create(
+ hass: HomeAssistant,
+ hass_ws_client: WebSocketGenerator,
+ freezer: FrozenDateTimeFactory,
+ snapshot: SnapshotAssertion,
+ params: dict[str, Any],
+ expected_extra_call_params: dict[str, Any],
+) -> None:
+ """Test translation of WS parameter to backup/generate to async_initiate_backup."""
+ await setup_backup_integration(hass, with_hassio=False)
+
+ client = await hass_ws_client(hass)
+ freezer.move_to("2024-11-13 12:01:00+01:00")
+ await hass.async_block_till_done()
+
+ with patch(
+ "homeassistant.components.backup.manager.BackupManager.async_initiate_backup",
+ return_value=NewBackup(backup_job_id="abc123"),
+ ) as generate_backup:
+ await client.send_json_auto_id({"type": "backup/generate"} | params)
+ result = await client.receive_json()
+ assert result["success"]
+ assert result["result"] == {"backup_job_id": "abc123"}
+ generate_backup.assert_called_once_with(
+ **{
+ "include_all_addons": False,
+ "include_homeassistant": True,
+ "include_addons": None,
+ "include_database": True,
+ "include_folders": None,
+ "name": None,
+ "password": None,
+ }
+ | expected_extra_call_params
+ )
+
+
+@pytest.mark.parametrize(
+ (
+ "create_backup_settings",
+ "expected_call_params",
+ "side_effect",
+ "last_completed_automatic_backup",
+ ),
+ [
+ (
+ {
+ "agent_ids": ["test.remote"],
+ "include_addons": None,
+ "include_all_addons": False,
+ "include_database": True,
+ "include_folders": None,
+ "name": None,
+ "password": None,
+ },
+ {
+ "agent_ids": ["test.remote"],
+ "backup_name": ANY,
+ "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,
+ },
+ None,
+ "2024-11-13T12:01:01+01:00",
+ ),
+ (
+ {
+ "agent_ids": ["test.remote"],
+ "include_addons": ["test-addon"],
+ "include_all_addons": False,
+ "include_database": True,
+ "include_folders": ["media"],
+ "name": "test-name",
+ "password": "test-password",
+ },
+ {
+ "agent_ids": ["test.remote"],
+ "backup_name": "test-name",
+ "extra_metadata": {
+ "instance_id": ANY,
+ "with_automatic_settings": True,
+ },
+ "include_addons": ["test-addon"],
+ "include_all_addons": False,
+ "include_database": True,
+ "include_folders": [Folder.MEDIA],
+ "include_homeassistant": True,
+ "on_progress": ANY,
+ "password": "test-password",
+ },
+ None,
+ "2024-11-13T12:01:01+01:00",
+ ),
+ (
+ {
+ "agent_ids": ["test.remote"],
+ "include_addons": None,
+ "include_all_addons": False,
+ "include_database": True,
+ "include_folders": None,
+ "name": None,
+ "password": None,
+ },
+ {
+ "agent_ids": ["test.remote"],
+ "backup_name": ANY,
+ "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,
+ },
+ BackupAgentError("Boom!"),
+ None,
+ ),
+ ],
+)
+async def test_generate_with_default_settings_calls_create(
+ hass: HomeAssistant,
+ hass_ws_client: WebSocketGenerator,
+ hass_storage: dict[str, Any],
+ freezer: FrozenDateTimeFactory,
+ create_backup: AsyncMock,
+ create_backup_settings: dict[str, Any],
+ expected_call_params: dict[str, Any],
+ side_effect: Exception | None,
+ last_completed_automatic_backup: str,
+) -> None:
+ """Test backup/generate_with_automatic_settings calls async_initiate_backup."""
+ 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")
+ remote_agent = BackupAgentTest("remote", backups=[])
+ await setup_backup_platform(
+ hass,
+ domain="test",
+ platform=Mock(
+ async_get_backup_agents=AsyncMock(return_value=[remote_agent]),
+ spec_set=BackupAgentPlatformProtocol,
+ ),
+ )
+ await async_setup_component(hass, DOMAIN, {})
+ await hass.async_block_till_done()
+
+ await client.send_json_auto_id(
+ {"type": "backup/config/update", "create_backup": create_backup_settings}
+ )
+ result = await client.receive_json()
+ assert result["success"]
+
+ freezer.tick()
+ async_fire_time_changed(hass)
+ await hass.async_block_till_done()
+
+ assert (
+ hass_storage[DOMAIN]["data"]["config"]["create_backup"]
+ == create_backup_settings
+ )
+ assert (
+ hass_storage[DOMAIN]["data"]["config"]["last_attempted_automatic_backup"]
+ is None
+ )
+ assert (
+ hass_storage[DOMAIN]["data"]["config"]["last_completed_automatic_backup"]
+ is None
+ )
+
+ with patch.object(remote_agent, "async_upload_backup", side_effect=side_effect):
+ await client.send_json_auto_id(
+ {"type": "backup/generate_with_automatic_settings"}
+ )
+ result = await client.receive_json()
+ assert result["success"]
+ assert result["result"] == {"backup_job_id": "abc123"}
+
+ await hass.async_block_till_done()
+
+ create_backup.assert_called_once_with(**expected_call_params)
+
+ freezer.tick()
+ async_fire_time_changed(hass)
+ await hass.async_block_till_done()
+
+ assert (
+ hass_storage[DOMAIN]["data"]["config"]["last_attempted_automatic_backup"]
+ == "2024-11-13T12:01:01+01:00"
+ )
+ assert (
+ hass_storage[DOMAIN]["data"]["config"]["last_completed_automatic_backup"]
+ == last_completed_automatic_backup
+ )
+
+
+@pytest.mark.parametrize(
+ "backups",
+ [
+ {},
+ {LOCAL_AGENT_ID: [TEST_BACKUP_ABC123]},
+ ],
+)
+async def test_restore_local_agent(
+ hass: HomeAssistant,
+ hass_ws_client: WebSocketGenerator,
+ backups: dict[str, list[AgentBackup]],
snapshot: SnapshotAssertion,
- with_hassio: bool,
) -> None:
"""Test calling the restore command."""
- await setup_backup_integration(hass, with_hassio=with_hassio)
+ await setup_backup_integration(hass, with_hassio=False, backups=backups)
+ restart_calls = async_mock_service(hass, "homeassistant", "restart")
client = await hass_ws_client(hass)
await hass.async_block_till_done()
- with patch(
- "homeassistant.components.backup.manager.BackupManager.async_restore_backup",
+ with (
+ patch("pathlib.Path.exists", return_value=True),
+ patch("pathlib.Path.write_text"),
+ patch("homeassistant.components.backup.manager.validate_password"),
):
- await client.send_json_auto_id({"type": "backup/restore", "slug": "abc123"})
+ await client.send_json_auto_id(
+ {
+ "type": "backup/restore",
+ "backup_id": "abc123",
+ "agent_id": "backup.local",
+ }
+ )
assert await client.receive_json() == snapshot
+ assert len(restart_calls) == snapshot
+
+
+@pytest.mark.parametrize(
+ ("remote_agents", "backups"),
+ [
+ (["remote"], {}),
+ (["remote"], {"test.remote": [TEST_BACKUP_ABC123]}),
+ ],
+)
+async def test_restore_remote_agent(
+ hass: HomeAssistant,
+ hass_ws_client: WebSocketGenerator,
+ remote_agents: list[str],
+ backups: dict[str, list[AgentBackup]],
+ snapshot: SnapshotAssertion,
+) -> None:
+ """Test calling the restore command."""
+ await setup_backup_integration(
+ hass, with_hassio=False, backups=backups, remote_agents=remote_agents
+ )
+ restart_calls = async_mock_service(hass, "homeassistant", "restart")
+
+ client = await hass_ws_client(hass)
+ await hass.async_block_till_done()
+
+ with (
+ patch("pathlib.Path.write_text"),
+ patch("pathlib.Path.open"),
+ patch("homeassistant.components.backup.manager.validate_password"),
+ ):
+ await client.send_json_auto_id(
+ {
+ "type": "backup/restore",
+ "backup_id": "abc123",
+ "agent_id": "test.remote",
+ }
+ )
+ assert await client.receive_json() == snapshot
+ assert len(restart_calls) == snapshot
+
+
+async def test_restore_wrong_password(
+ hass: HomeAssistant,
+ hass_ws_client: WebSocketGenerator,
+ snapshot: SnapshotAssertion,
+) -> None:
+ """Test calling the restore command."""
+ await setup_backup_integration(
+ hass, with_hassio=False, backups={LOCAL_AGENT_ID: [TEST_BACKUP_ABC123]}
+ )
+ restart_calls = async_mock_service(hass, "homeassistant", "restart")
+
+ client = await hass_ws_client(hass)
+ await hass.async_block_till_done()
+
+ with (
+ patch("pathlib.Path.exists", return_value=True),
+ patch("pathlib.Path.write_text"),
+ patch(
+ "homeassistant.components.backup.manager.validate_password",
+ return_value=False,
+ ),
+ ):
+ await client.send_json_auto_id(
+ {
+ "type": "backup/restore",
+ "backup_id": "abc123",
+ "agent_id": "backup.local",
+ }
+ )
+ assert await client.receive_json() == snapshot
+ assert len(restart_calls) == 0
@pytest.mark.parametrize(
@@ -178,6 +769,7 @@ async def test_restore(
pytest.param(False, id="without_hassio"),
],
)
+@pytest.mark.usefixtures("supervisor_client")
async def test_backup_end(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
@@ -197,7 +789,7 @@ async def test_backup_end(
"homeassistant.components.backup.manager.BackupManager.async_post_backup_actions",
):
await client.send_json_auto_id({"type": "backup/end"})
- assert snapshot == await client.receive_json()
+ assert await client.receive_json() == snapshot
@pytest.mark.parametrize(
@@ -211,6 +803,7 @@ async def test_backup_end(
pytest.param(False, id="without_hassio"),
],
)
+@pytest.mark.usefixtures("supervisor_client")
async def test_backup_start(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
@@ -230,7 +823,7 @@ async def test_backup_start(
"homeassistant.components.backup.manager.BackupManager.async_pre_backup_actions",
):
await client.send_json_auto_id({"type": "backup/start"})
- assert snapshot == await client.receive_json()
+ assert await client.receive_json() == snapshot
@pytest.mark.parametrize(
@@ -241,7 +834,8 @@ async def test_backup_start(
Exception("Boom"),
],
)
-async def test_backup_end_excepion(
+@pytest.mark.usefixtures("supervisor_client")
+async def test_backup_end_exception(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
snapshot: SnapshotAssertion,
@@ -259,7 +853,7 @@ async def test_backup_end_excepion(
side_effect=exception,
):
await client.send_json_auto_id({"type": "backup/end"})
- assert snapshot == await client.receive_json()
+ assert await client.receive_json() == snapshot
@pytest.mark.parametrize(
@@ -270,7 +864,8 @@ async def test_backup_end_excepion(
Exception("Boom"),
],
)
-async def test_backup_start_excepion(
+@pytest.mark.usefixtures("supervisor_client")
+async def test_backup_start_exception(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
snapshot: SnapshotAssertion,
@@ -288,4 +883,1672 @@ async def test_backup_start_excepion(
side_effect=exception,
):
await client.send_json_auto_id({"type": "backup/start"})
- assert snapshot == await client.receive_json()
+ assert await client.receive_json() == snapshot
+
+
+async def test_agents_info(
+ hass: HomeAssistant,
+ hass_ws_client: WebSocketGenerator,
+ snapshot: SnapshotAssertion,
+) -> None:
+ """Test getting backup agents info."""
+ await setup_backup_integration(hass, with_hassio=False)
+ hass.data[DATA_MANAGER].backup_agents["domain.test"] = BackupAgentTest("test")
+
+ client = await hass_ws_client(hass)
+ await hass.async_block_till_done()
+
+ await client.send_json_auto_id({"type": "backup/agents/info"})
+ assert await client.receive_json() == snapshot
+
+
+@pytest.mark.usefixtures("create_backup", "delete_backup", "get_backups")
+@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",
+ },
+ "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"},
+ },
+ },
+ {
+ "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,
+ },
+ "retention": {"copies": 3, "days": None},
+ "last_attempted_automatic_backup": None,
+ "last_completed_automatic_backup": None,
+ "schedule": {"state": "never"},
+ },
+ },
+ {
+ "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,
+ },
+ "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"},
+ },
+ },
+ {
+ "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,
+ },
+ "retention": {"copies": None, "days": None},
+ "last_attempted_automatic_backup": None,
+ "last_completed_automatic_backup": None,
+ "schedule": {"state": "mon"},
+ },
+ },
+ {
+ "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,
+ },
+ "retention": {"copies": None, "days": None},
+ "last_attempted_automatic_backup": None,
+ "last_completed_automatic_backup": None,
+ "schedule": {"state": "sat"},
+ },
+ },
+ ],
+)
+async def test_config_info(
+ hass: HomeAssistant,
+ hass_ws_client: WebSocketGenerator,
+ 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,
+ }
+
+ 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",
+ [
+ {
+ "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",
+ },
+ "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",
+ },
+ ],
+)
+async def test_config_update(
+ hass: HomeAssistant,
+ hass_ws_client: WebSocketGenerator,
+ snapshot: SnapshotAssertion,
+ command: dict[str, Any],
+ hass_storage: dict[str, Any],
+) -> None:
+ """Test updating the backup config."""
+ 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()
+
+ 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()
+
+ assert hass_storage[DOMAIN] == snapshot
+
+
+@pytest.mark.usefixtures("create_backup", "delete_backup", "get_backups")
+@pytest.mark.parametrize(
+ "command",
+ [
+ {
+ "type": "backup/config/update",
+ "create_backup": {"agent_ids": ["test-agent"]},
+ "schedule": "someday",
+ },
+ {
+ "type": "backup/config/update",
+ "create_backup": {"agent_ids": ["test-agent", "test-agent"]},
+ },
+ {
+ "type": "backup/config/update",
+ "create_backup": {"include_addons": ["my-addon", "my-addon"]},
+ },
+ {
+ "type": "backup/config/update",
+ "create_backup": {"include_folders": ["media", "media"]},
+ },
+ ],
+)
+async def test_config_update_errors(
+ hass: HomeAssistant,
+ hass_ws_client: WebSocketGenerator,
+ snapshot: SnapshotAssertion,
+ command: dict[str, Any],
+) -> None:
+ """Test errors when updating the backup config."""
+ 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()
+
+ assert not result["success"]
+
+ await client.send_json_auto_id({"type": "backup/config/info"})
+ assert await client.receive_json() == snapshot
+ await hass.async_block_till_done()
+
+
+@pytest.mark.parametrize(
+ (
+ "commands",
+ "last_completed_automatic_backup",
+ "time_1",
+ "time_2",
+ "attempted_backup_time",
+ "completed_backup_time",
+ "backup_calls_1",
+ "backup_calls_2",
+ "call_args",
+ "create_backup_side_effect",
+ ),
+ [
+ (
+ # 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",
+ 1,
+ 2,
+ BACKUP_CALL,
+ None,
+ ),
+ (
+ # Unchanged schedule
+ [
+ {
+ "type": "backup/config/update",
+ "create_backup": {"agent_ids": ["test.test-agent"]},
+ "schedule": "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",
+ 1,
+ 2,
+ BACKUP_CALL,
+ None,
+ ),
+ (
+ [
+ {
+ "type": "backup/config/update",
+ "create_backup": {"agent_ids": ["test.test-agent"]},
+ "schedule": "mon",
+ }
+ ],
+ "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",
+ 1,
+ 2,
+ BACKUP_CALL,
+ None,
+ ),
+ (
+ [
+ {
+ "type": "backup/config/update",
+ "create_backup": {"agent_ids": ["test.test-agent"]},
+ "schedule": "never",
+ }
+ ],
+ "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",
+ 0,
+ 0,
+ None,
+ None,
+ ),
+ (
+ [
+ {
+ "type": "backup/config/update",
+ "create_backup": {"agent_ids": ["test.test-agent"]},
+ "schedule": "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",
+ 1,
+ 2,
+ BACKUP_CALL,
+ None,
+ ),
+ (
+ [
+ {
+ "type": "backup/config/update",
+ "create_backup": {"agent_ids": ["test.test-agent"]},
+ "schedule": "mon",
+ }
+ ],
+ "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
+ 1,
+ 1,
+ BACKUP_CALL,
+ None,
+ ),
+ (
+ [
+ {
+ "type": "backup/config/update",
+ "create_backup": {"agent_ids": ["test.test-agent"]},
+ "schedule": "never",
+ }
+ ],
+ "2024-10-26T04:45:00+01:00",
+ "2034-11-11T12:00:00+01:00", # ten years later and still no backups
+ "2034-11-12T12:00:00+01:00",
+ "2024-10-26T04:45:00+01:00",
+ "2024-10-26T04:45:00+01:00",
+ 0,
+ 0,
+ None,
+ None,
+ ),
+ (
+ [
+ {
+ "type": "backup/config/update",
+ "create_backup": {"agent_ids": ["test.test-agent"]},
+ "schedule": "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-11T04:45:00+01:00",
+ 1,
+ 2,
+ BACKUP_CALL,
+ [BackupReaderWriterError("Boom"), None],
+ ),
+ (
+ [
+ {
+ "type": "backup/config/update",
+ "create_backup": {"agent_ids": ["test.test-agent"]},
+ "schedule": "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-11T04:45:00+01:00",
+ 1,
+ 2,
+ BACKUP_CALL,
+ [Exception("Boom"), None], # unknown error
+ ),
+ ],
+)
+async def test_config_schedule_logic(
+ hass: HomeAssistant,
+ hass_ws_client: WebSocketGenerator,
+ freezer: FrozenDateTimeFactory,
+ hass_storage: dict[str, Any],
+ create_backup: AsyncMock,
+ commands: list[dict[str, Any]],
+ last_completed_automatic_backup: str,
+ time_1: str,
+ time_2: str,
+ attempted_backup_time: str,
+ completed_backup_time: str,
+ backup_calls_1: int,
+ backup_calls_2: int,
+ call_args: Any,
+ create_backup_side_effect: list[Exception | None] | None,
+) -> None:
+ """Test config schedule logic."""
+ client = await hass_ws_client(hass)
+ storage_data = {
+ "backups": {},
+ "config": {
+ "create_backup": {
+ "agent_ids": ["test.test-agent"],
+ "include_addons": ["test-addon"],
+ "include_all_addons": False,
+ "include_database": True,
+ "include_folders": ["media"],
+ "name": "test-name",
+ "password": "test-password",
+ },
+ "retention": {"copies": None, "days": None},
+ "last_attempted_automatic_backup": last_completed_automatic_backup,
+ "last_completed_automatic_backup": last_completed_automatic_backup,
+ "schedule": {"state": "daily"},
+ },
+ }
+ hass_storage[DOMAIN] = {
+ "data": storage_data,
+ "key": DOMAIN,
+ "version": 1,
+ }
+ create_backup.side_effect = create_backup_side_effect
+ await hass.config.async_set_time_zone("Europe/Amsterdam")
+ freezer.move_to("2024-11-11 12:00:00+01:00")
+
+ await setup_backup_integration(hass, remote_agents=["test-agent"])
+ await hass.async_block_till_done()
+
+ for command in commands:
+ await client.send_json_auto_id(command)
+ result = await client.receive_json()
+ assert result["success"]
+
+ freezer.move_to(time_1)
+ async_fire_time_changed(hass)
+ await hass.async_block_till_done()
+
+ assert create_backup.call_count == backup_calls_1
+ assert create_backup.call_args == call_args
+ async_fire_time_changed(hass, fire_all=True) # flush out storage save
+ await hass.async_block_till_done()
+ assert (
+ hass_storage[DOMAIN]["data"]["config"]["last_attempted_automatic_backup"]
+ == attempted_backup_time
+ )
+ assert (
+ hass_storage[DOMAIN]["data"]["config"]["last_completed_automatic_backup"]
+ == completed_backup_time
+ )
+
+ freezer.move_to(time_2)
+ async_fire_time_changed(hass)
+ await hass.async_block_till_done()
+
+ assert create_backup.call_count == backup_calls_2
+ assert create_backup.call_args == call_args
+
+
+@pytest.mark.parametrize(
+ (
+ "command",
+ "backups",
+ "get_backups_agent_errors",
+ "delete_backup_agent_errors",
+ "last_backup_time",
+ "next_time",
+ "backup_time",
+ "backup_calls",
+ "get_backups_calls",
+ "delete_calls",
+ "delete_args_list",
+ ),
+ [
+ (
+ {
+ "type": "backup/config/update",
+ "create_backup": {"agent_ids": ["test.test-agent"]},
+ "retention": {"copies": None, "days": None},
+ "schedule": "daily",
+ },
+ {
+ "backup-1": MagicMock(
+ date="2024-11-10T04:45:00+01:00",
+ with_automatic_settings=True,
+ spec=ManagerBackup,
+ ),
+ "backup-2": MagicMock(
+ date="2024-11-11T04:45:00+01:00",
+ with_automatic_settings=True,
+ spec=ManagerBackup,
+ ),
+ "backup-3": MagicMock(
+ date="2024-11-12T04:45:00+01:00",
+ with_automatic_settings=True,
+ spec=ManagerBackup,
+ ),
+ "backup-4": MagicMock(
+ date="2024-11-12T04:45:00+01:00",
+ with_automatic_settings=False,
+ spec=ManagerBackup,
+ ),
+ },
+ {},
+ {},
+ "2024-11-11T04:45:00+01:00",
+ "2024-11-12T04:45:00+01:00",
+ "2024-11-12T04:45:00+01:00",
+ 1,
+ 1, # we get backups even if backup retention copies is None
+ 0,
+ [],
+ ),
+ (
+ {
+ "type": "backup/config/update",
+ "create_backup": {"agent_ids": ["test.test-agent"]},
+ "retention": {"copies": 3, "days": None},
+ "schedule": "daily",
+ },
+ {
+ "backup-1": MagicMock(
+ date="2024-11-10T04:45:00+01:00",
+ with_automatic_settings=True,
+ spec=ManagerBackup,
+ ),
+ "backup-2": MagicMock(
+ date="2024-11-11T04:45:00+01:00",
+ with_automatic_settings=True,
+ spec=ManagerBackup,
+ ),
+ "backup-3": MagicMock(
+ date="2024-11-12T04:45:00+01:00",
+ with_automatic_settings=True,
+ spec=ManagerBackup,
+ ),
+ "backup-4": MagicMock(
+ date="2024-11-12T04:45:00+01:00",
+ with_automatic_settings=False,
+ spec=ManagerBackup,
+ ),
+ },
+ {},
+ {},
+ "2024-11-11T04:45:00+01:00",
+ "2024-11-12T04:45:00+01:00",
+ "2024-11-12T04:45:00+01:00",
+ 1,
+ 1,
+ 0,
+ [],
+ ),
+ (
+ {
+ "type": "backup/config/update",
+ "create_backup": {"agent_ids": ["test.test-agent"]},
+ "retention": {"copies": 3, "days": None},
+ "schedule": "daily",
+ },
+ {
+ "backup-1": MagicMock(
+ date="2024-11-10T04:45:00+01:00",
+ with_automatic_settings=True,
+ spec=ManagerBackup,
+ ),
+ "backup-2": MagicMock(
+ date="2024-11-11T04:45:00+01:00",
+ with_automatic_settings=True,
+ spec=ManagerBackup,
+ ),
+ },
+ {},
+ {},
+ "2024-11-11T04:45:00+01:00",
+ "2024-11-12T04:45:00+01:00",
+ "2024-11-12T04:45:00+01:00",
+ 1,
+ 1,
+ 0,
+ [],
+ ),
+ (
+ {
+ "type": "backup/config/update",
+ "create_backup": {"agent_ids": ["test.test-agent"]},
+ "retention": {"copies": 3, "days": None},
+ "schedule": "daily",
+ },
+ {
+ "backup-1": MagicMock(
+ date="2024-11-09T04:45:00+01:00",
+ with_automatic_settings=True,
+ spec=ManagerBackup,
+ ),
+ "backup-2": MagicMock(
+ date="2024-11-10T04:45:00+01:00",
+ with_automatic_settings=True,
+ spec=ManagerBackup,
+ ),
+ "backup-3": MagicMock(
+ date="2024-11-11T04:45:00+01:00",
+ with_automatic_settings=True,
+ spec=ManagerBackup,
+ ),
+ "backup-4": MagicMock(
+ date="2024-11-12T04:45:00+01:00",
+ with_automatic_settings=True,
+ spec=ManagerBackup,
+ ),
+ "backup-5": MagicMock(
+ date="2024-11-12T04:45:00+01:00",
+ with_automatic_settings=False,
+ spec=ManagerBackup,
+ ),
+ },
+ {},
+ {},
+ "2024-11-11T04:45:00+01:00",
+ "2024-11-12T04:45:00+01:00",
+ "2024-11-12T04:45:00+01:00",
+ 1,
+ 1,
+ 1,
+ [call("backup-1")],
+ ),
+ (
+ {
+ "type": "backup/config/update",
+ "create_backup": {"agent_ids": ["test.test-agent"]},
+ "retention": {"copies": 2, "days": None},
+ "schedule": "daily",
+ },
+ {
+ "backup-1": MagicMock(
+ date="2024-11-09T04:45:00+01:00",
+ with_automatic_settings=True,
+ spec=ManagerBackup,
+ ),
+ "backup-2": MagicMock(
+ date="2024-11-10T04:45:00+01:00",
+ with_automatic_settings=True,
+ spec=ManagerBackup,
+ ),
+ "backup-3": MagicMock(
+ date="2024-11-11T04:45:00+01:00",
+ with_automatic_settings=True,
+ spec=ManagerBackup,
+ ),
+ "backup-4": MagicMock(
+ date="2024-11-12T04:45:00+01:00",
+ with_automatic_settings=True,
+ spec=ManagerBackup,
+ ),
+ "backup-5": MagicMock(
+ date="2024-11-12T04:45:00+01:00",
+ with_automatic_settings=False,
+ spec=ManagerBackup,
+ ),
+ },
+ {},
+ {},
+ "2024-11-11T04:45:00+01:00",
+ "2024-11-12T04:45:00+01:00",
+ "2024-11-12T04:45:00+01:00",
+ 1,
+ 1,
+ 2,
+ [call("backup-1"), call("backup-2")],
+ ),
+ (
+ {
+ "type": "backup/config/update",
+ "create_backup": {"agent_ids": ["test.test-agent"]},
+ "retention": {"copies": 2, "days": None},
+ "schedule": "daily",
+ },
+ {
+ "backup-1": MagicMock(
+ date="2024-11-10T04:45:00+01:00",
+ with_automatic_settings=True,
+ spec=ManagerBackup,
+ ),
+ "backup-2": MagicMock(
+ date="2024-11-11T04:45:00+01:00",
+ with_automatic_settings=True,
+ spec=ManagerBackup,
+ ),
+ "backup-3": MagicMock(
+ date="2024-11-12T04:45:00+01:00",
+ with_automatic_settings=True,
+ spec=ManagerBackup,
+ ),
+ "backup-4": MagicMock(
+ date="2024-11-12T04:45:00+01:00",
+ with_automatic_settings=False,
+ spec=ManagerBackup,
+ ),
+ },
+ {"test-agent": BackupAgentError("Boom!")},
+ {},
+ "2024-11-11T04:45:00+01:00",
+ "2024-11-12T04:45:00+01:00",
+ "2024-11-12T04:45:00+01:00",
+ 1,
+ 1,
+ 1,
+ [call("backup-1")],
+ ),
+ (
+ {
+ "type": "backup/config/update",
+ "create_backup": {"agent_ids": ["test.test-agent"]},
+ "retention": {"copies": 2, "days": None},
+ "schedule": "daily",
+ },
+ {
+ "backup-1": MagicMock(
+ date="2024-11-10T04:45:00+01:00",
+ with_automatic_settings=True,
+ spec=ManagerBackup,
+ ),
+ "backup-2": MagicMock(
+ date="2024-11-11T04:45:00+01:00",
+ with_automatic_settings=True,
+ spec=ManagerBackup,
+ ),
+ "backup-3": MagicMock(
+ date="2024-11-12T04:45:00+01:00",
+ with_automatic_settings=True,
+ spec=ManagerBackup,
+ ),
+ "backup-4": MagicMock(
+ date="2024-11-12T04:45:00+01:00",
+ with_automatic_settings=False,
+ spec=ManagerBackup,
+ ),
+ },
+ {},
+ {"test-agent": BackupAgentError("Boom!")},
+ "2024-11-11T04:45:00+01:00",
+ "2024-11-12T04:45:00+01:00",
+ "2024-11-12T04:45:00+01:00",
+ 1,
+ 1,
+ 1,
+ [call("backup-1")],
+ ),
+ (
+ {
+ "type": "backup/config/update",
+ "create_backup": {"agent_ids": ["test.test-agent"]},
+ "retention": {"copies": 0, "days": None},
+ "schedule": "daily",
+ },
+ {
+ "backup-1": MagicMock(
+ date="2024-11-09T04:45:00+01:00",
+ with_automatic_settings=True,
+ spec=ManagerBackup,
+ ),
+ "backup-2": MagicMock(
+ date="2024-11-10T04:45:00+01:00",
+ with_automatic_settings=True,
+ spec=ManagerBackup,
+ ),
+ "backup-3": MagicMock(
+ date="2024-11-11T04:45:00+01:00",
+ with_automatic_settings=True,
+ spec=ManagerBackup,
+ ),
+ "backup-4": MagicMock(
+ date="2024-11-12T04:45:00+01:00",
+ with_automatic_settings=True,
+ spec=ManagerBackup,
+ ),
+ "backup-5": MagicMock(
+ date="2024-11-12T04:45:00+01:00",
+ with_automatic_settings=False,
+ spec=ManagerBackup,
+ ),
+ },
+ {},
+ {},
+ "2024-11-11T04:45:00+01:00",
+ "2024-11-12T04:45:00+01:00",
+ "2024-11-12T04:45:00+01:00",
+ 1,
+ 1,
+ 3,
+ [call("backup-1"), call("backup-2"), call("backup-3")],
+ ),
+ (
+ {
+ "type": "backup/config/update",
+ "create_backup": {"agent_ids": ["test.test-agent"]},
+ "retention": {"copies": 0, "days": None},
+ "schedule": "daily",
+ },
+ {
+ "backup-1": MagicMock(
+ date="2024-11-12T04:45:00+01:00",
+ with_automatic_settings=True,
+ spec=ManagerBackup,
+ ),
+ "backup-2": MagicMock(
+ date="2024-11-12T04:45:00+01:00",
+ with_automatic_settings=False,
+ spec=ManagerBackup,
+ ),
+ },
+ {},
+ {},
+ "2024-11-11T04:45:00+01:00",
+ "2024-11-12T04:45:00+01:00",
+ "2024-11-12T04:45:00+01:00",
+ 1,
+ 1,
+ 0,
+ [],
+ ),
+ ],
+)
+async def test_config_retention_copies_logic(
+ hass: HomeAssistant,
+ hass_ws_client: WebSocketGenerator,
+ freezer: FrozenDateTimeFactory,
+ hass_storage: dict[str, Any],
+ create_backup: AsyncMock,
+ delete_backup: AsyncMock,
+ get_backups: AsyncMock,
+ command: dict[str, Any],
+ backups: dict[str, Any],
+ get_backups_agent_errors: dict[str, Exception],
+ delete_backup_agent_errors: dict[str, Exception],
+ last_backup_time: str,
+ next_time: str,
+ backup_time: str,
+ backup_calls: int,
+ get_backups_calls: int,
+ delete_calls: int,
+ delete_args_list: Any,
+) -> None:
+ """Test config backup retention copies logic."""
+ client = await hass_ws_client(hass)
+ storage_data = {
+ "backups": {},
+ "config": {
+ "create_backup": {
+ "agent_ids": ["test-agent"],
+ "include_addons": ["test-addon"],
+ "include_all_addons": False,
+ "include_database": True,
+ "include_folders": ["media"],
+ "name": "test-name",
+ "password": "test-password",
+ },
+ "retention": {"copies": None, "days": None},
+ "last_attempted_automatic_backup": None,
+ "last_completed_automatic_backup": last_backup_time,
+ "schedule": {"state": "daily"},
+ },
+ }
+ hass_storage[DOMAIN] = {
+ "data": storage_data,
+ "key": DOMAIN,
+ "version": 1,
+ }
+ get_backups.return_value = (backups, get_backups_agent_errors)
+ delete_backup.return_value = delete_backup_agent_errors
+ await hass.config.async_set_time_zone("Europe/Amsterdam")
+ freezer.move_to("2024-11-11 12:00:00+01:00")
+
+ await setup_backup_integration(hass, remote_agents=["test-agent"])
+ await hass.async_block_till_done()
+
+ await client.send_json_auto_id(command)
+ result = await client.receive_json()
+
+ assert result["success"]
+
+ freezer.move_to(next_time)
+ async_fire_time_changed(hass)
+ await hass.async_block_till_done()
+ assert create_backup.call_count == backup_calls
+ assert get_backups.call_count == get_backups_calls
+ assert delete_backup.call_count == delete_calls
+ assert delete_backup.call_args_list == delete_args_list
+ async_fire_time_changed(hass, fire_all=True) # flush out storage save
+ await hass.async_block_till_done()
+ assert (
+ hass_storage[DOMAIN]["data"]["config"]["last_attempted_automatic_backup"]
+ == backup_time
+ )
+ assert (
+ hass_storage[DOMAIN]["data"]["config"]["last_completed_automatic_backup"]
+ == backup_time
+ )
+
+
+@pytest.mark.parametrize(
+ ("backup_command", "backup_time"),
+ [
+ (
+ {"type": "backup/generate_with_automatic_settings"},
+ "2024-11-11T12:00:00+01:00",
+ ),
+ (
+ {"type": "backup/generate", "agent_ids": ["test.test-agent"]},
+ None,
+ ),
+ ],
+)
+@pytest.mark.parametrize(
+ (
+ "config_command",
+ "backups",
+ "get_backups_agent_errors",
+ "delete_backup_agent_errors",
+ "backup_calls",
+ "get_backups_calls",
+ "delete_calls",
+ "delete_args_list",
+ ),
+ [
+ (
+ {
+ "type": "backup/config/update",
+ "create_backup": {"agent_ids": ["test.test-agent"]},
+ "retention": {"copies": None, "days": None},
+ "schedule": "never",
+ },
+ {
+ "backup-1": MagicMock(
+ date="2024-11-10T04:45:00+01:00",
+ with_automatic_settings=True,
+ spec=ManagerBackup,
+ ),
+ "backup-2": MagicMock(
+ date="2024-11-11T04:45:00+01:00",
+ with_automatic_settings=True,
+ spec=ManagerBackup,
+ ),
+ "backup-3": MagicMock(
+ date="2024-11-12T04:45:00+01:00",
+ with_automatic_settings=True,
+ spec=ManagerBackup,
+ ),
+ "backup-4": MagicMock(
+ date="2024-11-12T04:45:00+01:00",
+ with_automatic_settings=False,
+ spec=ManagerBackup,
+ ),
+ },
+ {},
+ {},
+ 1,
+ 1, # we get backups even if backup retention copies is None
+ 0,
+ [],
+ ),
+ (
+ {
+ "type": "backup/config/update",
+ "create_backup": {"agent_ids": ["test.test-agent"]},
+ "retention": {"copies": 3, "days": None},
+ "schedule": "never",
+ },
+ {
+ "backup-1": MagicMock(
+ date="2024-11-10T04:45:00+01:00",
+ with_automatic_settings=True,
+ spec=ManagerBackup,
+ ),
+ "backup-2": MagicMock(
+ date="2024-11-11T04:45:00+01:00",
+ with_automatic_settings=True,
+ spec=ManagerBackup,
+ ),
+ "backup-3": MagicMock(
+ date="2024-11-12T04:45:00+01:00",
+ with_automatic_settings=True,
+ spec=ManagerBackup,
+ ),
+ "backup-4": MagicMock(
+ date="2024-11-12T04:45:00+01:00",
+ with_automatic_settings=False,
+ spec=ManagerBackup,
+ ),
+ },
+ {},
+ {},
+ 1,
+ 1,
+ 0,
+ [],
+ ),
+ (
+ {
+ "type": "backup/config/update",
+ "create_backup": {"agent_ids": ["test.test-agent"]},
+ "retention": {"copies": 3, "days": None},
+ "schedule": "never",
+ },
+ {
+ "backup-1": MagicMock(
+ date="2024-11-09T04:45:00+01:00",
+ with_automatic_settings=True,
+ spec=ManagerBackup,
+ ),
+ "backup-2": MagicMock(
+ date="2024-11-10T04:45:00+01:00",
+ with_automatic_settings=True,
+ spec=ManagerBackup,
+ ),
+ "backup-3": MagicMock(
+ date="2024-11-11T04:45:00+01:00",
+ with_automatic_settings=True,
+ spec=ManagerBackup,
+ ),
+ "backup-4": MagicMock(
+ date="2024-11-12T04:45:00+01:00",
+ with_automatic_settings=True,
+ spec=ManagerBackup,
+ ),
+ "backup-5": MagicMock(
+ date="2024-11-12T04:45:00+01:00",
+ with_automatic_settings=False,
+ spec=ManagerBackup,
+ ),
+ },
+ {},
+ {},
+ 1,
+ 1,
+ 1,
+ [call("backup-1")],
+ ),
+ (
+ {
+ "type": "backup/config/update",
+ "create_backup": {"agent_ids": ["test.test-agent"]},
+ "retention": {"copies": 2, "days": None},
+ "schedule": "never",
+ },
+ {
+ "backup-1": MagicMock(
+ date="2024-11-09T04:45:00+01:00",
+ with_automatic_settings=True,
+ spec=ManagerBackup,
+ ),
+ "backup-2": MagicMock(
+ date="2024-11-10T04:45:00+01:00",
+ with_automatic_settings=True,
+ spec=ManagerBackup,
+ ),
+ "backup-3": MagicMock(
+ date="2024-11-11T04:45:00+01:00",
+ with_automatic_settings=True,
+ spec=ManagerBackup,
+ ),
+ "backup-4": MagicMock(
+ date="2024-11-12T04:45:00+01:00",
+ with_automatic_settings=True,
+ spec=ManagerBackup,
+ ),
+ "backup-5": MagicMock(
+ date="2024-11-12T04:45:00+01:00",
+ with_automatic_settings=False,
+ spec=ManagerBackup,
+ ),
+ },
+ {},
+ {},
+ 1,
+ 1,
+ 2,
+ [call("backup-1"), call("backup-2")],
+ ),
+ ],
+)
+async def test_config_retention_copies_logic_manual_backup(
+ hass: HomeAssistant,
+ hass_ws_client: WebSocketGenerator,
+ freezer: FrozenDateTimeFactory,
+ hass_storage: dict[str, Any],
+ create_backup: AsyncMock,
+ delete_backup: AsyncMock,
+ get_backups: AsyncMock,
+ config_command: dict[str, Any],
+ backup_command: dict[str, Any],
+ backups: dict[str, Any],
+ get_backups_agent_errors: dict[str, Exception],
+ delete_backup_agent_errors: dict[str, Exception],
+ backup_time: str,
+ backup_calls: int,
+ get_backups_calls: int,
+ delete_calls: int,
+ delete_args_list: Any,
+) -> None:
+ """Test config backup retention copies logic for manual backup."""
+ client = await hass_ws_client(hass)
+ storage_data = {
+ "backups": {},
+ "config": {
+ "create_backup": {
+ "agent_ids": ["test-agent"],
+ "include_addons": ["test-addon"],
+ "include_all_addons": False,
+ "include_database": True,
+ "include_folders": ["media"],
+ "name": "test-name",
+ "password": "test-password",
+ },
+ "retention": {"copies": None, "days": None},
+ "last_attempted_automatic_backup": None,
+ "last_completed_automatic_backup": None,
+ "schedule": {"state": "daily"},
+ },
+ }
+ hass_storage[DOMAIN] = {
+ "data": storage_data,
+ "key": DOMAIN,
+ "version": 1,
+ }
+ get_backups.return_value = (backups, get_backups_agent_errors)
+ delete_backup.return_value = delete_backup_agent_errors
+ await hass.config.async_set_time_zone("Europe/Amsterdam")
+ freezer.move_to("2024-11-11 12:00:00+01:00")
+
+ await setup_backup_integration(hass, remote_agents=["test-agent"])
+ await hass.async_block_till_done()
+
+ await client.send_json_auto_id(config_command)
+ result = await client.receive_json()
+ assert result["success"]
+
+ # Create a manual backup
+ await client.send_json_auto_id(backup_command)
+ result = await client.receive_json()
+ assert result["success"]
+
+ # Wait for backup creation to complete
+ await hass.async_block_till_done()
+
+ assert create_backup.call_count == backup_calls
+ assert get_backups.call_count == get_backups_calls
+ assert delete_backup.call_count == delete_calls
+ assert delete_backup.call_args_list == delete_args_list
+ async_fire_time_changed(hass, fire_all=True) # flush out storage save
+ await hass.async_block_till_done()
+ assert (
+ hass_storage[DOMAIN]["data"]["config"]["last_attempted_automatic_backup"]
+ == backup_time
+ )
+ assert (
+ hass_storage[DOMAIN]["data"]["config"]["last_completed_automatic_backup"]
+ == backup_time
+ )
+
+
+@pytest.mark.parametrize(
+ (
+ "stored_retained_days",
+ "commands",
+ "backups",
+ "get_backups_agent_errors",
+ "delete_backup_agent_errors",
+ "last_backup_time",
+ "start_time",
+ "next_time",
+ "get_backups_calls",
+ "delete_calls",
+ "delete_args_list",
+ ),
+ [
+ # No config update - cleanup backups older than 2 days
+ (
+ 2,
+ [],
+ {
+ "backup-1": MagicMock(
+ date="2024-11-10T04:45:00+01:00",
+ with_automatic_settings=True,
+ spec=ManagerBackup,
+ ),
+ "backup-2": MagicMock(
+ date="2024-11-11T04:45:00+01:00",
+ with_automatic_settings=True,
+ spec=ManagerBackup,
+ ),
+ "backup-3": MagicMock(
+ date="2024-11-10T04:45:00+01:00",
+ with_automatic_settings=False,
+ spec=ManagerBackup,
+ ),
+ },
+ {},
+ {},
+ "2024-11-11T04:45:00+01:00",
+ "2024-11-11T12:00:00+01:00",
+ "2024-11-12T12:00:00+01:00",
+ 1,
+ 1,
+ [call("backup-1")],
+ ),
+ # No config update - No cleanup
+ (
+ None,
+ [],
+ {
+ "backup-1": MagicMock(
+ date="2024-11-10T04:45:00+01:00",
+ with_automatic_settings=True,
+ spec=ManagerBackup,
+ ),
+ "backup-2": MagicMock(
+ date="2024-11-11T04:45:00+01:00",
+ with_automatic_settings=True,
+ spec=ManagerBackup,
+ ),
+ "backup-3": MagicMock(
+ date="2024-11-10T04:45:00+01:00",
+ with_automatic_settings=False,
+ spec=ManagerBackup,
+ ),
+ },
+ {},
+ {},
+ "2024-11-11T04:45:00+01:00",
+ "2024-11-11T12:00:00+01:00",
+ "2024-11-12T12:00:00+01:00",
+ 0,
+ 0,
+ [],
+ ),
+ # Unchanged config
+ (
+ 2,
+ [
+ {
+ "type": "backup/config/update",
+ "create_backup": {"agent_ids": ["test-agent"]},
+ "retention": {"copies": None, "days": 2},
+ "schedule": "never",
+ }
+ ],
+ {
+ "backup-1": MagicMock(
+ date="2024-11-10T04:45:00+01:00",
+ with_automatic_settings=True,
+ spec=ManagerBackup,
+ ),
+ "backup-2": MagicMock(
+ date="2024-11-11T04:45:00+01:00",
+ with_automatic_settings=True,
+ spec=ManagerBackup,
+ ),
+ "backup-3": MagicMock(
+ date="2024-11-10T04:45:00+01:00",
+ with_automatic_settings=False,
+ spec=ManagerBackup,
+ ),
+ },
+ {},
+ {},
+ "2024-11-11T04:45:00+01:00",
+ "2024-11-11T12:00:00+01:00",
+ "2024-11-12T12:00:00+01:00",
+ 1,
+ 1,
+ [call("backup-1")],
+ ),
+ (
+ None,
+ [
+ {
+ "type": "backup/config/update",
+ "create_backup": {"agent_ids": ["test-agent"]},
+ "retention": {"copies": None, "days": 2},
+ "schedule": "never",
+ }
+ ],
+ {
+ "backup-1": MagicMock(
+ date="2024-11-10T04:45:00+01:00",
+ with_automatic_settings=True,
+ spec=ManagerBackup,
+ ),
+ "backup-2": MagicMock(
+ date="2024-11-11T04:45:00+01:00",
+ with_automatic_settings=True,
+ spec=ManagerBackup,
+ ),
+ "backup-3": MagicMock(
+ date="2024-11-10T04:45:00+01:00",
+ with_automatic_settings=False,
+ spec=ManagerBackup,
+ ),
+ },
+ {},
+ {},
+ "2024-11-11T04:45:00+01:00",
+ "2024-11-11T12:00:00+01:00",
+ "2024-11-12T12:00:00+01:00",
+ 1,
+ 1,
+ [call("backup-1")],
+ ),
+ (
+ None,
+ [
+ {
+ "type": "backup/config/update",
+ "create_backup": {"agent_ids": ["test-agent"]},
+ "retention": {"copies": None, "days": 3},
+ "schedule": "never",
+ }
+ ],
+ {
+ "backup-1": MagicMock(
+ date="2024-11-10T04:45:00+01:00",
+ with_automatic_settings=True,
+ spec=ManagerBackup,
+ ),
+ "backup-2": MagicMock(
+ date="2024-11-11T04:45:00+01:00",
+ with_automatic_settings=True,
+ spec=ManagerBackup,
+ ),
+ "backup-3": MagicMock(
+ date="2024-11-10T04:45:00+01:00",
+ with_automatic_settings=False,
+ spec=ManagerBackup,
+ ),
+ },
+ {},
+ {},
+ "2024-11-11T04:45:00+01:00",
+ "2024-11-11T12:00:00+01:00",
+ "2024-11-12T12:00:00+01:00",
+ 1,
+ 0,
+ [],
+ ),
+ (
+ None,
+ [
+ {
+ "type": "backup/config/update",
+ "create_backup": {"agent_ids": ["test-agent"]},
+ "retention": {"copies": None, "days": 2},
+ "schedule": "never",
+ }
+ ],
+ {
+ "backup-1": MagicMock(
+ date="2024-11-09T04:45:00+01:00",
+ with_automatic_settings=True,
+ spec=ManagerBackup,
+ ),
+ "backup-2": MagicMock(
+ date="2024-11-10T04:45:00+01:00",
+ with_automatic_settings=True,
+ spec=ManagerBackup,
+ ),
+ "backup-3": MagicMock(
+ date="2024-11-11T04:45:00+01:00",
+ with_automatic_settings=True,
+ spec=ManagerBackup,
+ ),
+ "backup-4": MagicMock(
+ date="2024-11-10T04:45:00+01:00",
+ with_automatic_settings=False,
+ spec=ManagerBackup,
+ ),
+ },
+ {},
+ {},
+ "2024-11-11T04:45:00+01:00",
+ "2024-11-11T12:00:00+01:00",
+ "2024-11-12T12:00:00+01:00",
+ 1,
+ 2,
+ [call("backup-1"), call("backup-2")],
+ ),
+ (
+ None,
+ [
+ {
+ "type": "backup/config/update",
+ "create_backup": {"agent_ids": ["test-agent"]},
+ "retention": {"copies": None, "days": 2},
+ "schedule": "never",
+ }
+ ],
+ {
+ "backup-1": MagicMock(
+ date="2024-11-10T04:45:00+01:00",
+ with_automatic_settings=True,
+ spec=ManagerBackup,
+ ),
+ "backup-2": MagicMock(
+ date="2024-11-11T04:45:00+01:00",
+ with_automatic_settings=True,
+ spec=ManagerBackup,
+ ),
+ "backup-3": MagicMock(
+ date="2024-11-10T04:45:00+01:00",
+ with_automatic_settings=False,
+ spec=ManagerBackup,
+ ),
+ },
+ {"test-agent": BackupAgentError("Boom!")},
+ {},
+ "2024-11-11T04:45:00+01:00",
+ "2024-11-11T12:00:00+01:00",
+ "2024-11-12T12:00:00+01:00",
+ 1,
+ 1,
+ [call("backup-1")],
+ ),
+ (
+ None,
+ [
+ {
+ "type": "backup/config/update",
+ "create_backup": {"agent_ids": ["test-agent"]},
+ "retention": {"copies": None, "days": 2},
+ "schedule": "never",
+ }
+ ],
+ {
+ "backup-1": MagicMock(
+ date="2024-11-10T04:45:00+01:00",
+ with_automatic_settings=True,
+ spec=ManagerBackup,
+ ),
+ "backup-2": MagicMock(
+ date="2024-11-11T04:45:00+01:00",
+ with_automatic_settings=True,
+ spec=ManagerBackup,
+ ),
+ "backup-3": MagicMock(
+ date="2024-11-10T04:45:00+01:00",
+ with_automatic_settings=False,
+ spec=ManagerBackup,
+ ),
+ },
+ {},
+ {"test-agent": BackupAgentError("Boom!")},
+ "2024-11-11T04:45:00+01:00",
+ "2024-11-11T12:00:00+01:00",
+ "2024-11-12T12:00:00+01:00",
+ 1,
+ 1,
+ [call("backup-1")],
+ ),
+ (
+ None,
+ [
+ {
+ "type": "backup/config/update",
+ "create_backup": {"agent_ids": ["test-agent"]},
+ "retention": {"copies": None, "days": 0},
+ "schedule": "never",
+ }
+ ],
+ {
+ "backup-1": MagicMock(
+ date="2024-11-09T04:45:00+01:00",
+ with_automatic_settings=True,
+ spec=ManagerBackup,
+ ),
+ "backup-2": MagicMock(
+ date="2024-11-10T04:45:00+01:00",
+ with_automatic_settings=True,
+ spec=ManagerBackup,
+ ),
+ "backup-3": MagicMock(
+ date="2024-11-11T04:45:00+01:00",
+ with_automatic_settings=True,
+ spec=ManagerBackup,
+ ),
+ "backup-4": MagicMock(
+ date="2024-11-10T04:45:00+01:00",
+ with_automatic_settings=False,
+ spec=ManagerBackup,
+ ),
+ },
+ {},
+ {},
+ "2024-11-11T04:45:00+01:00",
+ "2024-11-11T12:00:00+01:00",
+ "2024-11-12T12:00:00+01:00",
+ 1,
+ 2,
+ [call("backup-1"), call("backup-2")],
+ ),
+ ],
+)
+async def test_config_retention_days_logic(
+ hass: HomeAssistant,
+ hass_ws_client: WebSocketGenerator,
+ freezer: FrozenDateTimeFactory,
+ hass_storage: dict[str, Any],
+ delete_backup: AsyncMock,
+ get_backups: AsyncMock,
+ stored_retained_days: int | None,
+ commands: list[dict[str, Any]],
+ backups: dict[str, Any],
+ get_backups_agent_errors: dict[str, Exception],
+ delete_backup_agent_errors: dict[str, Exception],
+ last_backup_time: str,
+ start_time: str,
+ next_time: str,
+ get_backups_calls: int,
+ delete_calls: int,
+ delete_args_list: list[Any],
+) -> None:
+ """Test config backup retention logic."""
+ client = await hass_ws_client(hass)
+ storage_data = {
+ "backups": {},
+ "config": {
+ "create_backup": {
+ "agent_ids": ["test-agent"],
+ "include_addons": ["test-addon"],
+ "include_all_addons": False,
+ "include_database": True,
+ "include_folders": ["media"],
+ "name": "test-name",
+ "password": "test-password",
+ },
+ "retention": {"copies": None, "days": stored_retained_days},
+ "last_attempted_automatic_backup": None,
+ "last_completed_automatic_backup": last_backup_time,
+ "schedule": {"state": "never"},
+ },
+ }
+ hass_storage[DOMAIN] = {
+ "data": storage_data,
+ "key": DOMAIN,
+ "version": 1,
+ }
+ get_backups.return_value = (backups, get_backups_agent_errors)
+ delete_backup.return_value = delete_backup_agent_errors
+ await hass.config.async_set_time_zone("Europe/Amsterdam")
+ freezer.move_to(start_time)
+
+ await setup_backup_integration(hass)
+ await hass.async_block_till_done()
+
+ for command in commands:
+ await client.send_json_auto_id(command)
+ result = await client.receive_json()
+ assert result["success"]
+
+ freezer.move_to(next_time)
+ async_fire_time_changed(hass)
+ await hass.async_block_till_done()
+ assert get_backups.call_count == get_backups_calls
+ assert delete_backup.call_count == delete_calls
+ assert delete_backup.call_args_list == delete_args_list
+ async_fire_time_changed(hass, fire_all=True) # flush out storage save
+ await hass.async_block_till_done()
+
+
+async def test_subscribe_event(
+ hass: HomeAssistant,
+ hass_ws_client: WebSocketGenerator,
+ snapshot: SnapshotAssertion,
+) -> None:
+ """Test subscribe event."""
+ await setup_backup_integration(hass, with_hassio=False)
+
+ manager = hass.data[DATA_MANAGER]
+
+ client = await hass_ws_client(hass)
+
+ await client.send_json_auto_id({"type": "backup/subscribe_events"})
+ assert await client.receive_json() == snapshot
+ assert await client.receive_json() == snapshot
+
+ manager.async_on_backup_event(
+ CreateBackupEvent(stage=None, state=CreateBackupState.IN_PROGRESS)
+ )
+ assert await client.receive_json() == snapshot
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 3769aef5cd3..27292e5a28c 100644
--- a/tests/components/bang_olufsen/const.py
+++ b/tests/components/bang_olufsen/const.py
@@ -16,6 +16,7 @@ from mozart_api.models import (
PlayQueueItemType,
RenderingState,
SceneProperties,
+ Source,
UserFlow,
VolumeLevel,
VolumeMute,
@@ -37,6 +38,7 @@ 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"
@@ -64,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,7 +85,7 @@ TEST_DATA_CREATE_ENTRY = {
}
TEST_DATA_CREATE_ENTRY_2 = {
CONF_HOST: TEST_HOST,
- CONF_MODEL: TEST_MODEL_BALANCE,
+ CONF_MODEL: TEST_MODEL_CORE,
CONF_BEOLINK_JID: TEST_JID_2,
CONF_NAME: TEST_NAME_2,
}
@@ -125,7 +130,10 @@ TEST_DATA_ZEROCONF_IPV6 = ZeroconfServiceInfo(
},
)
-TEST_AUDIO_SOURCES = [BangOlufsenSource.TIDAL.name, BangOlufsenSource.LINE_IN.name]
+TEST_SOURCE = Source(
+ name="Tidal", id="tidal", is_seekable=True, is_enabled=True, is_playable=True
+)
+TEST_AUDIO_SOURCES = [TEST_SOURCE.name, BangOlufsenSource.LINE_IN.name]
TEST_VIDEO_SOURCES = ["HDMI A"]
TEST_SOURCES = TEST_AUDIO_SOURCES + TEST_VIDEO_SOURCES
TEST_FALLBACK_SOURCES = [
diff --git a/tests/components/bang_olufsen/snapshots/test_diagnostics.ambr b/tests/components/bang_olufsen/snapshots/test_diagnostics.ambr
new file mode 100644
index 00000000000..e9540b5cec6
--- /dev/null
+++ b/tests/components/bang_olufsen/snapshots/test_diagnostics.ambr
@@ -0,0 +1,67 @@
+# serializer version: 1
+# name: test_async_get_config_entry_diagnostics
+ dict({
+ 'config_entry': dict({
+ 'data': dict({
+ 'host': '192.168.0.1',
+ 'jid': '1111.1111111.11111111@products.bang-olufsen.com',
+ 'model': 'Beosound Balance',
+ 'name': 'Beosound Balance-11111111',
+ }),
+ 'disabled_by': None,
+ 'discovery_keys': dict({
+ }),
+ 'domain': 'bang_olufsen',
+ 'minor_version': 1,
+ 'options': dict({
+ }),
+ 'pref_disable_new_entities': False,
+ 'pref_disable_polling': False,
+ 'source': 'user',
+ 'title': 'Beosound Balance-11111111',
+ 'unique_id': '11111111',
+ 'version': 1,
+ }),
+ 'media_player': dict({
+ 'attributes': dict({
+ 'beolink': dict({
+ 'listeners': dict({
+ 'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com',
+ 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com',
+ }),
+ 'peers': dict({
+ 'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com',
+ 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com',
+ }),
+ 'self': dict({
+ 'Living room Balance': '1111.1111111.11111111@products.bang-olufsen.com',
+ }),
+ }),
+ 'device_class': 'speaker',
+ 'entity_picture_local': None,
+ 'friendly_name': 'Living room Balance',
+ 'group_members': list([
+ 'media_player.beosound_balance_11111111',
+ 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com',
+ 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com',
+ ]),
+ 'media_content_type': 'music',
+ 'sound_mode': 'Test Listening Mode (123)',
+ 'sound_mode_list': list([
+ 'Test Listening Mode (123)',
+ 'Test Listening Mode (234)',
+ 'Test Listening Mode 2 (345)',
+ ]),
+ 'source_list': list([
+ 'Tidal',
+ 'Line-In',
+ 'HDMI A',
+ ]),
+ 'supported_features': 2095933,
+ }),
+ 'entity_id': 'media_player.beosound_balance_11111111',
+ 'state': 'playing',
+ }),
+ 'websocket_connected': False,
+ })
+# ---
diff --git a/tests/components/bang_olufsen/snapshots/test_media_player.ambr b/tests/components/bang_olufsen/snapshots/test_media_player.ambr
index e48dc39198b..327b7ecfacf 100644
--- a/tests/components/bang_olufsen/snapshots/test_media_player.ambr
+++ b/tests/components/bang_olufsen/snapshots/test_media_player.ambr
@@ -23,7 +23,6 @@
'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com',
'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com',
]),
- 'icon': 'mdi:speaker-wireless',
'media_content_type': ,
'repeat': ,
'shuffle': False,
@@ -72,7 +71,6 @@
'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com',
'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com',
]),
- 'icon': 'mdi:speaker-wireless',
'media_content_type': ,
'repeat': ,
'shuffle': False,
@@ -122,7 +120,6 @@
'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com',
'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com',
]),
- 'icon': 'mdi:speaker-wireless',
'media_content_type': ,
'repeat': ,
'shuffle': False,
@@ -172,7 +169,6 @@
'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com',
'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com',
]),
- 'icon': 'mdi:speaker-wireless',
'media_content_type': ,
'repeat': ,
'shuffle': False,
@@ -222,7 +218,6 @@
'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com',
'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com',
]),
- 'icon': 'mdi:speaker-wireless',
'media_content_type': ,
'repeat': ,
'shuffle': False,
@@ -248,7 +243,7 @@
'state': 'playing',
})
# ---
-# name: test_async_beolink_join
+# name: test_async_beolink_join[service_parameters0-method_parameters0]
StateSnapshot({
'attributes': ReadOnlyDict({
'beolink': dict({
@@ -272,7 +267,6 @@
'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com',
'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com',
]),
- 'icon': 'mdi:speaker-wireless',
'media_content_type': ,
'repeat': ,
'shuffle': False,
@@ -297,6 +291,240 @@
'state': 'playing',
})
# ---
+# name: test_async_beolink_join[service_parameters1-method_parameters1]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ 'beolink': dict({
+ 'listeners': dict({
+ 'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com',
+ 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com',
+ }),
+ 'peers': dict({
+ 'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com',
+ 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com',
+ }),
+ 'self': dict({
+ 'Living room Balance': '1111.1111111.11111111@products.bang-olufsen.com',
+ }),
+ }),
+ 'device_class': 'speaker',
+ 'entity_picture_local': None,
+ 'friendly_name': 'Living room Balance',
+ 'group_members': list([
+ 'media_player.beosound_balance_11111111',
+ 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com',
+ 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com',
+ ]),
+ 'media_content_type': ,
+ 'repeat': ,
+ 'shuffle': False,
+ 'sound_mode': 'Test Listening Mode (123)',
+ 'sound_mode_list': list([
+ 'Test Listening Mode (123)',
+ 'Test Listening Mode (234)',
+ 'Test Listening Mode 2 (345)',
+ ]),
+ 'source_list': list([
+ 'Tidal',
+ 'Line-In',
+ 'HDMI A',
+ ]),
+ 'supported_features': ,
+ }),
+ 'context': ,
+ 'entity_id': 'media_player.beosound_balance_11111111',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': 'playing',
+ })
+# ---
+# name: test_async_beolink_join[service_parameters2-method_parameters2]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ 'beolink': dict({
+ 'listeners': dict({
+ 'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com',
+ 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com',
+ }),
+ 'peers': dict({
+ 'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com',
+ 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com',
+ }),
+ 'self': dict({
+ 'Living room Balance': '1111.1111111.11111111@products.bang-olufsen.com',
+ }),
+ }),
+ 'device_class': 'speaker',
+ 'entity_picture_local': None,
+ 'friendly_name': 'Living room Balance',
+ 'group_members': list([
+ 'media_player.beosound_balance_11111111',
+ 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com',
+ 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com',
+ ]),
+ 'media_content_type': ,
+ 'repeat': ,
+ 'shuffle': False,
+ 'sound_mode': 'Test Listening Mode (123)',
+ 'sound_mode_list': list([
+ 'Test Listening Mode (123)',
+ 'Test Listening Mode (234)',
+ 'Test Listening Mode 2 (345)',
+ ]),
+ 'source_list': list([
+ 'Tidal',
+ 'Line-In',
+ 'HDMI A',
+ ]),
+ 'supported_features': ,
+ }),
+ 'context': ,
+ 'entity_id': 'media_player.beosound_balance_11111111',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': 'playing',
+ })
+# ---
+# name: test_async_beolink_join_invalid[service_parameters0-expected_result0]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ 'beolink': dict({
+ 'listeners': dict({
+ 'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com',
+ 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com',
+ }),
+ 'peers': dict({
+ 'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com',
+ 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com',
+ }),
+ 'self': dict({
+ 'Living room Balance': '1111.1111111.11111111@products.bang-olufsen.com',
+ }),
+ }),
+ 'device_class': 'speaker',
+ 'entity_picture_local': None,
+ 'friendly_name': 'Living room Balance',
+ 'group_members': list([
+ 'media_player.beosound_balance_11111111',
+ 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com',
+ 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com',
+ ]),
+ 'media_content_type': ,
+ 'sound_mode': 'Test Listening Mode (123)',
+ 'sound_mode_list': list([
+ 'Test Listening Mode (123)',
+ 'Test Listening Mode (234)',
+ 'Test Listening Mode 2 (345)',
+ ]),
+ 'source_list': list([
+ 'Tidal',
+ 'Line-In',
+ 'HDMI A',
+ ]),
+ 'supported_features': ,
+ }),
+ 'context': ,
+ 'entity_id': 'media_player.beosound_balance_11111111',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': 'playing',
+ })
+# ---
+# name: test_async_beolink_join_invalid[service_parameters1-expected_result1]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ 'beolink': dict({
+ 'listeners': dict({
+ 'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com',
+ 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com',
+ }),
+ 'peers': dict({
+ 'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com',
+ 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com',
+ }),
+ 'self': dict({
+ 'Living room Balance': '1111.1111111.11111111@products.bang-olufsen.com',
+ }),
+ }),
+ 'device_class': 'speaker',
+ 'entity_picture_local': None,
+ 'friendly_name': 'Living room Balance',
+ 'group_members': list([
+ 'media_player.beosound_balance_11111111',
+ 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com',
+ 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com',
+ ]),
+ 'media_content_type': ,
+ 'sound_mode': 'Test Listening Mode (123)',
+ 'sound_mode_list': list([
+ 'Test Listening Mode (123)',
+ 'Test Listening Mode (234)',
+ 'Test Listening Mode 2 (345)',
+ ]),
+ 'source_list': list([
+ 'Tidal',
+ 'Line-In',
+ 'HDMI A',
+ ]),
+ 'supported_features': ,
+ }),
+ 'context': ,
+ 'entity_id': 'media_player.beosound_balance_11111111',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': 'playing',
+ })
+# ---
+# name: test_async_beolink_join_invalid[service_parameters2-expected_result2]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ 'beolink': dict({
+ 'listeners': dict({
+ 'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com',
+ 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com',
+ }),
+ 'peers': dict({
+ 'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com',
+ 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com',
+ }),
+ 'self': dict({
+ 'Living room Balance': '1111.1111111.11111111@products.bang-olufsen.com',
+ }),
+ }),
+ 'device_class': 'speaker',
+ 'entity_picture_local': None,
+ 'friendly_name': 'Living room Balance',
+ 'group_members': list([
+ 'media_player.beosound_balance_11111111',
+ 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com',
+ 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com',
+ ]),
+ 'media_content_type': ,
+ 'sound_mode': 'Test Listening Mode (123)',
+ 'sound_mode_list': list([
+ 'Test Listening Mode (123)',
+ 'Test Listening Mode (234)',
+ 'Test Listening Mode 2 (345)',
+ ]),
+ 'source_list': list([
+ 'Tidal',
+ 'Line-In',
+ 'HDMI A',
+ ]),
+ 'supported_features': ,
+ }),
+ 'context': ,
+ 'entity_id': 'media_player.beosound_balance_11111111',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': 'playing',
+ })
+# ---
# name: test_async_beolink_unexpand
StateSnapshot({
'attributes': ReadOnlyDict({
@@ -321,7 +549,6 @@
'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com',
'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com',
]),
- 'icon': 'mdi:speaker-wireless',
'media_content_type': ,
'repeat': ,
'shuffle': False,
@@ -370,7 +597,6 @@
'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com',
'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com',
]),
- 'icon': 'mdi:speaker-wireless',
'media_content_type': ,
'repeat': ,
'shuffle': False,
@@ -420,7 +646,6 @@
'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com',
'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com',
]),
- 'icon': 'mdi:speaker-wireless',
'media_content_type': ,
'sound_mode': 'Test Listening Mode (123)',
'sound_mode_list': list([
@@ -467,7 +692,6 @@
'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com',
'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com',
]),
- 'icon': 'mdi:speaker-wireless',
'media_content_type': ,
'repeat': ,
'shuffle': False,
@@ -517,7 +741,6 @@
'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com',
'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com',
]),
- 'icon': 'mdi:speaker-wireless',
'media_content_type': ,
'sound_mode': 'Test Listening Mode (123)',
'sound_mode_list': list([
@@ -564,7 +787,6 @@
'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com',
'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com',
]),
- 'icon': 'mdi:speaker-wireless',
'media_content_type': ,
'media_position': 0,
'sound_mode': 'Test Listening Mode (123)',
@@ -573,7 +795,7 @@
'Test Listening Mode (234)',
'Test Listening Mode 2 (345)',
]),
- 'source': 'Chromecast built-in',
+ 'source': 'Line-In',
'source_list': list([
'Tidal',
'Line-In',
@@ -613,7 +835,6 @@
'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com',
'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com',
]),
- 'icon': 'mdi:speaker-wireless',
'media_content_type': ,
'sound_mode': 'Test Listening Mode (123)',
'sound_mode_list': list([
@@ -660,7 +881,6 @@
'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com',
'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com',
]),
- 'icon': 'mdi:speaker-wireless',
'media_content_type': ,
'sound_mode': 'Test Listening Mode (123)',
'sound_mode_list': list([
@@ -708,7 +928,6 @@
'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com',
'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com',
]),
- 'icon': 'mdi:speaker-wireless',
'media_content_type': ,
'sound_mode': 'Test Listening Mode (123)',
'sound_mode_list': list([
@@ -755,7 +974,6 @@
'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com',
'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com',
]),
- 'icon': 'mdi:speaker-wireless',
'media_content_type': ,
'repeat': ,
'shuffle': False,
@@ -802,7 +1020,6 @@
'media_player.beosound_balance_22222222',
'media_player.beosound_balance_11111111',
]),
- 'icon': 'mdi:speaker-wireless',
'media_content_type': ,
'sound_mode': 'Test Listening Mode (123)',
'sound_mode_list': list([
@@ -849,7 +1066,6 @@
'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com',
'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com',
]),
- 'icon': 'mdi:speaker-wireless',
'media_content_type': ,
'sound_mode': 'Test Listening Mode (123)',
'sound_mode_list': list([
diff --git a/tests/components/bang_olufsen/test_diagnostics.py b/tests/components/bang_olufsen/test_diagnostics.py
new file mode 100644
index 00000000000..7c99648ace4
--- /dev/null
+++ b/tests/components/bang_olufsen/test_diagnostics.py
@@ -0,0 +1,41 @@
+"""Test bang_olufsen config entry diagnostics."""
+
+from unittest.mock import AsyncMock
+
+from syrupy import SnapshotAssertion
+from syrupy.filters import props
+
+from homeassistant.core import HomeAssistant
+
+from tests.common import MockConfigEntry
+from tests.components.diagnostics import get_diagnostics_for_config_entry
+from tests.typing import ClientSessionGenerator
+
+
+async def test_async_get_config_entry_diagnostics(
+ hass: HomeAssistant,
+ hass_client: ClientSessionGenerator,
+ mock_config_entry: MockConfigEntry,
+ mock_mozart_client: AsyncMock,
+ snapshot: SnapshotAssertion,
+) -> None:
+ """Test config entry diagnostics."""
+ mock_config_entry.add_to_hass(hass)
+ await hass.config_entries.async_setup(mock_config_entry.entry_id)
+
+ result = await get_diagnostics_for_config_entry(
+ hass, hass_client, mock_config_entry
+ )
+
+ assert result == snapshot(
+ exclude=props(
+ "created_at",
+ "entry_id",
+ "id",
+ "last_changed",
+ "last_reported",
+ "last_updated",
+ "media_position_updated_at",
+ "modified_at",
+ )
+ )
diff --git a/tests/components/bang_olufsen/test_event.py b/tests/components/bang_olufsen/test_event.py
new file mode 100644
index 00000000000..d58e5d2219b
--- /dev/null
+++ b/tests/components/bang_olufsen/test_event.py
@@ -0,0 +1,103 @@
+"""Test the bang_olufsen event entities."""
+
+from unittest.mock import AsyncMock
+
+from inflection import underscore
+from mozart_api.models import ButtonEvent
+
+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,
+) -> 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", "preset_"
+ )
+ for button_type in DEVICE_BUTTONS
+ ]
+
+ # Check that the entities are available
+ for entity_id in entity_ids:
+ entity_registry.async_get(entity_id)
+
+
+async def test_button_event_creation_beoconnect_core(
+ hass: HomeAssistant,
+ mock_config_entry_core: MockConfigEntry,
+ mock_mozart_client: AsyncMock,
+ entity_registry: EntityRegistry,
+) -> 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)
+
+ # Add Button Event entity ids
+ entity_ids = [
+ f"event.beosound_balance_11111111_{underscore(button_type)}".replace(
+ "preset", "preset_"
+ )
+ for button_type in DEVICE_BUTTONS
+ ]
+
+ # Check that the entities are unavailable
+ for entity_id in entity_ids:
+ assert not entity_registry.async_get(entity_id)
+
+
+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 e991ab3d1bc..70b826f0b92 100644
--- a/tests/components/bang_olufsen/test_media_player.py
+++ b/tests/components/bang_olufsen/test_media_player.py
@@ -18,6 +18,7 @@ from mozart_api.models import (
import pytest
from syrupy.assertion import SnapshotAssertion
from syrupy.filters import props
+from voluptuous import Invalid, MultipleInvalid
from homeassistant.components.bang_olufsen.const import (
BANG_OLUFSEN_REPEAT_FROM_HA,
@@ -105,6 +106,7 @@ from .const import (
TEST_SEEK_POSITION_HOME_ASSISTANT_FORMAT,
TEST_SOUND_MODE_2,
TEST_SOUND_MODES,
+ TEST_SOURCE,
TEST_SOURCES,
TEST_VIDEO_SOURCES,
TEST_VOLUME,
@@ -231,7 +233,7 @@ async def test_async_update_sources_availability(
# Add a source that is available and playable
mock_mozart_client.get_available_sources.return_value = SourceArray(
- items=[BangOlufsenSource.TIDAL]
+ items=[TEST_SOURCE]
)
# Send playback_source. The source is not actually used, so its attributes don't matter
@@ -239,7 +241,7 @@ async def test_async_update_sources_availability(
assert mock_mozart_client.get_available_sources.call_count == 2
assert (states := hass.states.get(TEST_MEDIA_PLAYER_ENTITY_ID))
- assert states.attributes[ATTR_INPUT_SOURCE_LIST] == [BangOlufsenSource.TIDAL.name]
+ assert states.attributes[ATTR_INPUT_SOURCE_LIST] == [TEST_SOURCE.name]
async def test_async_update_playback_metadata(
@@ -357,19 +359,17 @@ async def test_async_update_playback_state(
@pytest.mark.parametrize(
- ("reported_source", "real_source", "content_type", "progress", "metadata"),
+ ("source", "content_type", "progress", "metadata"),
[
- # Normal source, music mediatype expected, no progress expected
+ # Normal source, music mediatype expected
(
- BangOlufsenSource.TIDAL,
- BangOlufsenSource.TIDAL,
+ TEST_SOURCE,
MediaType.MUSIC,
TEST_PLAYBACK_PROGRESS.progress,
PlaybackContentMetadata(),
),
- # URI source, url media type expected, no progress expected
+ # URI source, url media type expected
(
- BangOlufsenSource.URI_STREAMER,
BangOlufsenSource.URI_STREAMER,
MediaType.URL,
TEST_PLAYBACK_PROGRESS.progress,
@@ -378,44 +378,17 @@ async def test_async_update_playback_state(
# Line-In source,media type expected, progress 0 expected
(
BangOlufsenSource.LINE_IN,
- BangOlufsenSource.CHROMECAST,
MediaType.MUSIC,
0,
PlaybackContentMetadata(),
),
- # Chromecast as source, but metadata says Line-In.
- # Progress is not set to 0 as the source is Chromecast first
- (
- BangOlufsenSource.CHROMECAST,
- BangOlufsenSource.LINE_IN,
- MediaType.MUSIC,
- TEST_PLAYBACK_PROGRESS.progress,
- PlaybackContentMetadata(title=BangOlufsenSource.LINE_IN.name),
- ),
- # Chromecast as source, but metadata says Bluetooth
- (
- BangOlufsenSource.CHROMECAST,
- BangOlufsenSource.BLUETOOTH,
- MediaType.MUSIC,
- TEST_PLAYBACK_PROGRESS.progress,
- PlaybackContentMetadata(title=BangOlufsenSource.BLUETOOTH.name),
- ),
- # Chromecast as source, but metadata says Bluetooth in another way
- (
- BangOlufsenSource.CHROMECAST,
- BangOlufsenSource.BLUETOOTH,
- MediaType.MUSIC,
- TEST_PLAYBACK_PROGRESS.progress,
- PlaybackContentMetadata(art=[]),
- ),
],
)
async def test_async_update_source_change(
hass: HomeAssistant,
mock_mozart_client: AsyncMock,
mock_config_entry: MockConfigEntry,
- reported_source: Source,
- real_source: Source,
+ source: Source,
content_type: MediaType,
progress: int,
metadata: PlaybackContentMetadata,
@@ -444,10 +417,10 @@ async def test_async_update_source_change(
# Simulate metadata
playback_metadata_callback(metadata)
- source_change_callback(reported_source)
+ source_change_callback(source)
assert (states := hass.states.get(TEST_MEDIA_PLAYER_ENTITY_ID))
- assert states.attributes[ATTR_INPUT_SOURCE] == real_source.name
+ assert states.attributes[ATTR_INPUT_SOURCE] == source.name
assert states.attributes[ATTR_MEDIA_CONTENT_TYPE] == content_type
assert states.attributes[ATTR_MEDIA_POSITION] == progress
@@ -555,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."""
@@ -567,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(
@@ -774,7 +747,7 @@ async def test_async_media_next_track(
("source", "expected_result", "seek_called_times"),
[
# Seekable source, seek expected
- (BangOlufsenSource.DEEZER, does_not_raise(), 1),
+ (TEST_SOURCE, does_not_raise(), 1),
# Non seekable source, seek shouldn't work
(BangOlufsenSource.LINE_IN, pytest.raises(HomeAssistantError), 0),
# Malformed source, seek shouldn't work
@@ -862,7 +835,7 @@ async def test_async_clear_playlist(
# Invalid source
("Test source", pytest.raises(ServiceValidationError), 0, 0),
# Valid audio source
- (BangOlufsenSource.TIDAL.name, does_not_raise(), 1, 0),
+ (TEST_SOURCE.name, does_not_raise(), 1, 0),
# Valid video source
(TEST_VIDEO_SOURCES[0], does_not_raise(), 0, 1),
],
@@ -1413,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,
@@ -1428,11 +1401,11 @@ 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(BangOlufsenSource.TIDAL)
+ source_change_callback(TEST_SOURCE)
await hass.services.async_call(
MEDIA_PLAYER_DOMAIN,
@@ -1468,7 +1441,7 @@ async def test_async_join_players(
),
# Invalid media_player entity
(
- BangOlufsenSource.TIDAL,
+ TEST_SOURCE,
[TEST_MEDIA_PLAYER_ENTITY_ID_3],
pytest.raises(ServiceValidationError),
"invalid_grouping_entity",
@@ -1480,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,
@@ -1495,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)
@@ -1551,13 +1524,38 @@ async def test_async_unjoin_player(
assert states == snapshot(exclude=props("media_position_updated_at"))
+@pytest.mark.parametrize(
+ (
+ "service_parameters",
+ "method_parameters",
+ ),
+ [
+ # Defined JID
+ (
+ {"beolink_jid": TEST_JID_2},
+ {"jid": TEST_JID_2},
+ ),
+ # Defined JID and source
+ (
+ {"beolink_jid": TEST_JID_2, "source_id": TEST_SOURCE.id},
+ {"jid": TEST_JID_2, "source": TEST_SOURCE.id},
+ ),
+ # Defined JID and Beolink Converter NL/ML source
+ (
+ {"beolink_jid": TEST_JID_2, "source_id": "cd"},
+ {"jid": TEST_JID_2, "source": "CD"},
+ ),
+ ],
+)
async def test_async_beolink_join(
hass: HomeAssistant,
snapshot: SnapshotAssertion,
mock_mozart_client: AsyncMock,
mock_config_entry: MockConfigEntry,
+ service_parameters: dict[str, str],
+ method_parameters: dict[str, str],
) -> None:
- """Test async_beolink_join with defined JID."""
+ """Test async_beolink_join with defined JID and JID and source."""
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
@@ -1565,14 +1563,61 @@ async def test_async_beolink_join(
await hass.services.async_call(
DOMAIN,
"beolink_join",
- {
- ATTR_ENTITY_ID: TEST_MEDIA_PLAYER_ENTITY_ID,
- "beolink_jid": TEST_JID_2,
- },
+ {ATTR_ENTITY_ID: TEST_MEDIA_PLAYER_ENTITY_ID, **service_parameters},
blocking=True,
)
- mock_mozart_client.join_beolink_peer.assert_called_once_with(jid=TEST_JID_2)
+ mock_mozart_client.join_beolink_peer.assert_called_once_with(**method_parameters)
+
+ assert (states := hass.states.get(TEST_MEDIA_PLAYER_ENTITY_ID))
+ assert states == snapshot(exclude=props("media_position_updated_at"))
+
+
+@pytest.mark.parametrize(
+ (
+ "service_parameters",
+ "expected_result",
+ ),
+ [
+ # Defined invalid JID
+ (
+ {"beolink_jid": "not_a_jid"},
+ pytest.raises(Invalid),
+ ),
+ # Defined invalid source
+ (
+ {"source_id": "invalid_source"},
+ pytest.raises(MultipleInvalid),
+ ),
+ # Defined invalid JID and invalid source
+ (
+ {"beolink_jid": "not_a_jid", "source_id": "invalid_source"},
+ pytest.raises(MultipleInvalid),
+ ),
+ ],
+)
+async def test_async_beolink_join_invalid(
+ hass: HomeAssistant,
+ snapshot: SnapshotAssertion,
+ mock_mozart_client: AsyncMock,
+ mock_config_entry: MockConfigEntry,
+ service_parameters: dict[str, str],
+ expected_result: AbstractContextManager,
+) -> None:
+ """Test invalid async_beolink_join calls with defined JID or source ID."""
+
+ mock_config_entry.add_to_hass(hass)
+ await hass.config_entries.async_setup(mock_config_entry.entry_id)
+
+ with expected_result:
+ await hass.services.async_call(
+ DOMAIN,
+ "beolink_join",
+ {ATTR_ENTITY_ID: TEST_MEDIA_PLAYER_ENTITY_ID, **service_parameters},
+ blocking=True,
+ )
+
+ mock_mozart_client.join_beolink_peer.assert_not_called()
assert (states := hass.states.get(TEST_MEDIA_PLAYER_ENTITY_ID))
assert states == snapshot(exclude=props("media_position_updated_at"))
@@ -1637,7 +1682,7 @@ async def test_async_beolink_expand(
)
# Set the source to a beolink expandable source
- source_change_callback(BangOlufsenSource.TIDAL)
+ source_change_callback(TEST_SOURCE)
await hass.services.async_call(
DOMAIN,
diff --git a/tests/components/bang_olufsen/test_websocket.py b/tests/components/bang_olufsen/test_websocket.py
index b17859a4f4e..ecf5b2d011e 100644
--- a/tests/components/bang_olufsen/test_websocket.py
+++ b/tests/components/bang_olufsen/test_websocket.py
@@ -135,7 +135,6 @@ async def test_on_all_notifications_raw(
},
"eventType": "WebSocketEventVolume",
}
- raw_notification_full = raw_notification
# Get device ID for the modified notification that is sent as an event and in the log
assert mock_config_entry.unique_id
@@ -144,12 +143,11 @@ async def test_on_all_notifications_raw(
identifiers={(DOMAIN, mock_config_entry.unique_id)}
)
)
- raw_notification_full.update(
- {
- "device_id": device.id,
- "serial_number": mock_config_entry.unique_id,
- }
- )
+ raw_notification_full = {
+ "device_id": device.id,
+ "serial_number": int(mock_config_entry.unique_id),
+ **raw_notification,
+ }
caplog.set_level(logging.DEBUG)
diff --git a/tests/components/binary_sensor/test_init.py b/tests/components/binary_sensor/test_init.py
index ea0ad05a0db..26b8d919d72 100644
--- a/tests/components/binary_sensor/test_init.py
+++ b/tests/components/binary_sensor/test_init.py
@@ -17,8 +17,6 @@ from tests.common import (
MockConfigEntry,
MockModule,
MockPlatform,
- help_test_all,
- import_and_test_deprecated_constant_enum,
mock_config_flow,
mock_integration,
mock_platform,
@@ -198,22 +196,3 @@ async def test_entity_category_config_raises_error(
"Entity binary_sensor.test2 cannot be added as the entity category is set to config"
in caplog.text
)
-
-
-def test_all() -> None:
- """Test module.__all__ is correctly set."""
- help_test_all(binary_sensor)
-
-
-@pytest.mark.parametrize(
- "device_class",
- list(binary_sensor.BinarySensorDeviceClass),
-)
-def test_deprecated_constant_device_class(
- caplog: pytest.LogCaptureFixture,
- device_class: binary_sensor.BinarySensorDeviceClass,
-) -> None:
- """Test deprecated binary sensor device classes."""
- import_and_test_deprecated_constant_enum(
- caplog, binary_sensor, device_class, "DEVICE_CLASS_", "2025.1"
- )
diff --git a/tests/components/blink/test_config_flow.py b/tests/components/blink/test_config_flow.py
index c89ab65ea1d..ec1a8b95e0d 100644
--- a/tests/components/blink/test_config_flow.py
+++ b/tests/components/blink/test_config_flow.py
@@ -55,6 +55,35 @@ async def test_form(hass: HomeAssistant) -> None:
}
assert len(mock_setup_entry.mock_calls) == 1
+ # Now check for duplicates
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+ assert result["type"] is FlowResultType.FORM
+ assert result["errors"] == {}
+
+ with (
+ patch("homeassistant.components.blink.config_flow.Auth.startup"),
+ patch(
+ "homeassistant.components.blink.config_flow.Auth.check_key_required",
+ return_value=False,
+ ),
+ patch(
+ "homeassistant.components.blink.async_setup_entry",
+ return_value=True,
+ ) as mock_setup_entry,
+ ):
+ result2 = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {"username": "blink@example.com", "password": "example"},
+ )
+ await hass.async_block_till_done()
+
+ assert result2["type"] is FlowResultType.ABORT
+ assert result2["reason"] == "already_configured"
+
+ assert len(mock_setup_entry.mock_calls) == 0
+
async def test_form_2fa(hass: HomeAssistant) -> None:
"""Test we get the 2fa form."""
diff --git a/tests/components/bluesound/conftest.py b/tests/components/bluesound/conftest.py
index b4ee61dee57..717c9f61850 100644
--- a/tests/components/bluesound/conftest.py
+++ b/tests/components/bluesound/conftest.py
@@ -81,11 +81,11 @@ class PlayerMockData:
volume_db=0.5,
volume=50,
group=None,
- master=None,
- slaves=None,
+ leader=None,
+ followers=None,
zone=None,
- zone_master=None,
- zone_slave=None,
+ zone_leader=None,
+ zone_follower=None,
mute_volume_db=None,
mute_volume=None,
)
diff --git a/tests/components/bluesound/test_config_flow.py b/tests/components/bluesound/test_config_flow.py
index 63744cdf0ff..a1d67c120db 100644
--- a/tests/components/bluesound/test_config_flow.py
+++ b/tests/components/bluesound/test_config_flow.py
@@ -6,7 +6,7 @@ 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
@@ -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 0bf615de3da..a43696a0a7f 100644
--- a/tests/components/bluesound/test_media_player.py
+++ b/tests/components/bluesound/test_media_player.py
@@ -11,7 +11,7 @@ from syrupy.filters import props
from homeassistant.components.bluesound import DOMAIN as BLUESOUND_DOMAIN
from homeassistant.components.bluesound.const import ATTR_MASTER
-from homeassistant.components.bluesound.services import (
+from homeassistant.components.bluesound.media_player import (
SERVICE_CLEAR_TIMER,
SERVICE_JOIN,
SERVICE_SET_TIMER,
@@ -259,7 +259,7 @@ async def test_join(
blocking=True,
)
- player_mocks.player_data_secondary.player.add_slave.assert_called_once_with(
+ player_mocks.player_data_secondary.player.add_follower.assert_called_once_with(
"1.1.1.1", 11000
)
@@ -273,7 +273,7 @@ async def test_unjoin(
"""Test the unjoin action."""
updated_sync_status = dataclasses.replace(
player_mocks.player_data.sync_status_long_polling_mock.get(),
- master=PairedPlayer("2.2.2.2", 11000),
+ leader=PairedPlayer("2.2.2.2", 11000),
)
player_mocks.player_data.sync_status_long_polling_mock.set(updated_sync_status)
@@ -287,7 +287,7 @@ async def test_unjoin(
blocking=True,
)
- player_mocks.player_data_secondary.player.remove_slave.assert_called_once_with(
+ player_mocks.player_data_secondary.player.remove_follower.assert_called_once_with(
"1.1.1.1", 11000
)
@@ -297,7 +297,7 @@ async def test_attr_master(
setup_config_entry: None,
player_mocks: PlayerMocks,
) -> None:
- """Test the media player master."""
+ """Test the media player leader."""
attr_master = hass.states.get("media_player.player_name1111").attributes[
ATTR_MASTER
]
@@ -305,7 +305,7 @@ async def test_attr_master(
updated_sync_status = dataclasses.replace(
player_mocks.player_data.sync_status_long_polling_mock.get(),
- slaves=[PairedPlayer("2.2.2.2", 11000)],
+ followers=[PairedPlayer("2.2.2.2", 11000)],
)
player_mocks.player_data.sync_status_long_polling_mock.set(updated_sync_status)
@@ -325,17 +325,17 @@ async def test_attr_bluesound_group(
setup_config_entry_secondary: None,
player_mocks: PlayerMocks,
) -> None:
- """Test the media player grouping."""
+ """Test the media player grouping for leader."""
attr_bluesound_group = hass.states.get(
"media_player.player_name1111"
).attributes.get("bluesound_group")
assert attr_bluesound_group is None
- updated_status = dataclasses.replace(
- player_mocks.player_data.status_long_polling_mock.get(),
- group_name="player-name1111+player-name2222",
+ updated_sync_status = dataclasses.replace(
+ player_mocks.player_data.sync_status_long_polling_mock.get(),
+ followers=[PairedPlayer("2.2.2.2", 11000)],
)
- player_mocks.player_data.status_long_polling_mock.set(updated_status)
+ player_mocks.player_data.sync_status_long_polling_mock.set(updated_sync_status)
# give the long polling loop a chance to update the state; this could be any async call
await hass.async_block_till_done()
@@ -347,6 +347,45 @@ async def test_attr_bluesound_group(
assert attr_bluesound_group == ["player-name1111", "player-name2222"]
+async def test_attr_bluesound_group_for_follower(
+ hass: HomeAssistant,
+ setup_config_entry: None,
+ setup_config_entry_secondary: None,
+ player_mocks: PlayerMocks,
+) -> None:
+ """Test the media player grouping for follower."""
+ attr_bluesound_group = hass.states.get(
+ "media_player.player_name2222"
+ ).attributes.get("bluesound_group")
+ assert attr_bluesound_group is None
+
+ updated_sync_status = dataclasses.replace(
+ player_mocks.player_data.sync_status_long_polling_mock.get(),
+ followers=[PairedPlayer("2.2.2.2", 11000)],
+ )
+ player_mocks.player_data.sync_status_long_polling_mock.set(updated_sync_status)
+
+ # give the long polling loop a chance to update the state; this could be any async call
+ await hass.async_block_till_done()
+
+ updated_sync_status = dataclasses.replace(
+ player_mocks.player_data_secondary.sync_status_long_polling_mock.get(),
+ leader=PairedPlayer("1.1.1.1", 11000),
+ )
+ player_mocks.player_data_secondary.sync_status_long_polling_mock.set(
+ updated_sync_status
+ )
+
+ # give the long polling loop a chance to update the state; this could be any async call
+ await hass.async_block_till_done()
+
+ attr_bluesound_group = hass.states.get(
+ "media_player.player_name2222"
+ ).attributes.get("bluesound_group")
+
+ assert attr_bluesound_group == ["player-name1111", "player-name2222"]
+
+
async def test_volume_up_from_6_to_7(
hass: HomeAssistant,
setup_config_entry: None,
diff --git a/tests/components/bmw_connected_drive/__init__.py b/tests/components/bmw_connected_drive/__init__.py
index 4d280a1d0e5..c437e1d3669 100644
--- a/tests/components/bmw_connected_drive/__init__.py
+++ b/tests/components/bmw_connected_drive/__init__.py
@@ -9,6 +9,7 @@ import respx
from homeassistant import config_entries
from homeassistant.components.bmw_connected_drive.const import (
+ CONF_CAPTCHA_TOKEN,
CONF_GCID,
CONF_READ_ONLY,
CONF_REFRESH_TOKEN,
@@ -24,8 +25,12 @@ FIXTURE_USER_INPUT = {
CONF_PASSWORD: "p4ssw0rd",
CONF_REGION: "rest_of_world",
}
-FIXTURE_REFRESH_TOKEN = "SOME_REFRESH_TOKEN"
-FIXTURE_GCID = "SOME_GCID"
+FIXTURE_CAPTCHA_INPUT = {
+ CONF_CAPTCHA_TOKEN: "captcha_token",
+}
+FIXTURE_USER_INPUT_W_CAPTCHA = FIXTURE_USER_INPUT | FIXTURE_CAPTCHA_INPUT
+FIXTURE_REFRESH_TOKEN = "another_token_string"
+FIXTURE_GCID = "DUMMY"
FIXTURE_CONFIG_ENTRY = {
"entry_id": "1",
@@ -43,6 +48,11 @@ FIXTURE_CONFIG_ENTRY = {
"unique_id": f"{FIXTURE_USER_INPUT[CONF_REGION]}-{FIXTURE_USER_INPUT[CONF_USERNAME]}",
}
+REMOTE_SERVICE_EXC_REASON = "HTTPStatusError: 502 Bad Gateway"
+REMOTE_SERVICE_EXC_TRANSLATION = (
+ "Error executing remote service on vehicle. HTTPStatusError: 502 Bad Gateway"
+)
+
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/snapshots/test_diagnostics.ambr b/tests/components/bmw_connected_drive/snapshots/test_diagnostics.ambr
index 81ef1220069..b87da22a332 100644
--- a/tests/components/bmw_connected_drive/snapshots/test_diagnostics.ambr
+++ b/tests/components/bmw_connected_drive/snapshots/test_diagnostics.ambr
@@ -4833,7 +4833,7 @@
}),
]),
'info': dict({
- 'gcid': 'SOME_GCID',
+ 'gcid': 'DUMMY',
'password': '**REDACTED**',
'refresh_token': '**REDACTED**',
'region': 'rest_of_world',
@@ -7202,7 +7202,7 @@
}),
]),
'info': dict({
- 'gcid': 'SOME_GCID',
+ 'gcid': 'DUMMY',
'password': '**REDACTED**',
'refresh_token': '**REDACTED**',
'region': 'rest_of_world',
@@ -8925,7 +8925,7 @@
}),
]),
'info': dict({
- 'gcid': 'SOME_GCID',
+ 'gcid': 'DUMMY',
'password': '**REDACTED**',
'refresh_token': '**REDACTED**',
'region': 'rest_of_world',
diff --git a/tests/components/bmw_connected_drive/test_button.py b/tests/components/bmw_connected_drive/test_button.py
index 88c7990cde9..356cfcb439e 100644
--- a/tests/components/bmw_connected_drive/test_button.py
+++ b/tests/components/bmw_connected_drive/test_button.py
@@ -13,7 +13,11 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er
-from . import check_remote_service_call, setup_mocked_integration
+from . import (
+ REMOTE_SERVICE_EXC_TRANSLATION,
+ check_remote_service_call,
+ setup_mocked_integration,
+)
from tests.common import snapshot_platform
@@ -81,11 +85,13 @@ async def test_service_call_fail(
monkeypatch.setattr(
RemoteServices,
"trigger_remote_service",
- AsyncMock(side_effect=MyBMWRemoteServiceError),
+ AsyncMock(
+ side_effect=MyBMWRemoteServiceError("HTTPStatusError: 502 Bad Gateway")
+ ),
)
# Test
- with pytest.raises(HomeAssistantError):
+ with pytest.raises(HomeAssistantError, match=REMOTE_SERVICE_EXC_TRANSLATION):
await hass.services.async_call(
"button",
"press",
diff --git a/tests/components/bmw_connected_drive/test_config_flow.py b/tests/components/bmw_connected_drive/test_config_flow.py
index f57f1a304ac..9c124261392 100644
--- a/tests/components/bmw_connected_drive/test_config_flow.py
+++ b/tests/components/bmw_connected_drive/test_config_flow.py
@@ -4,29 +4,28 @@ from copy import deepcopy
from unittest.mock import patch
from bimmer_connected.api.authentication import MyBMWAuthentication
-from bimmer_connected.models import (
- MyBMWAPIError,
- MyBMWAuthError,
- MyBMWCaptchaMissingError,
-)
+from bimmer_connected.models import MyBMWAPIError, MyBMWAuthError
from httpx import RequestError
import pytest
from homeassistant import config_entries
from homeassistant.components.bmw_connected_drive.config_flow import DOMAIN
from homeassistant.components.bmw_connected_drive.const import (
+ CONF_CAPTCHA_TOKEN,
CONF_READ_ONLY,
CONF_REFRESH_TOKEN,
)
-from homeassistant.const import CONF_PASSWORD, CONF_REGION, CONF_USERNAME
+from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from . import (
+ FIXTURE_CAPTCHA_INPUT,
FIXTURE_CONFIG_ENTRY,
FIXTURE_GCID,
FIXTURE_REFRESH_TOKEN,
FIXTURE_USER_INPUT,
+ FIXTURE_USER_INPUT_W_CAPTCHA,
)
from tests.common import MockConfigEntry
@@ -61,7 +60,7 @@ async def test_authentication_error(hass: HomeAssistant) -> None:
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_USER},
- data=FIXTURE_USER_INPUT,
+ data=deepcopy(FIXTURE_USER_INPUT_W_CAPTCHA),
)
assert result["type"] is FlowResultType.FORM
@@ -79,7 +78,7 @@ async def test_connection_error(hass: HomeAssistant) -> None:
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_USER},
- data=FIXTURE_USER_INPUT,
+ data=deepcopy(FIXTURE_USER_INPUT_W_CAPTCHA),
)
assert result["type"] is FlowResultType.FORM
@@ -97,7 +96,7 @@ async def test_api_error(hass: HomeAssistant) -> None:
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_USER},
- data=deepcopy(FIXTURE_USER_INPUT),
+ data=deepcopy(FIXTURE_USER_INPUT_W_CAPTCHA),
)
assert result["type"] is FlowResultType.FORM
@@ -105,6 +104,28 @@ async def test_api_error(hass: HomeAssistant) -> None:
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 (
@@ -118,14 +139,22 @@ async def test_full_user_flow_implementation(hass: HomeAssistant) -> None:
return_value=True,
) as mock_setup_entry,
):
- result2 = await hass.config_entries.flow.async_init(
+ result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_USER},
data=deepcopy(FIXTURE_USER_INPUT),
)
- assert result2["type"] is FlowResultType.CREATE_ENTRY
- assert result2["title"] == FIXTURE_COMPLETE_ENTRY[CONF_USERNAME]
- assert result2["data"] == FIXTURE_COMPLETE_ENTRY
+
+ 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 len(mock_setup_entry.mock_calls) == 1
@@ -195,64 +224,28 @@ async def test_reauth(hass: HomeAssistant) -> None:
result = await config_entry.start_reauth_flow(hass)
assert result["type"] is FlowResultType.FORM
- assert result["step_id"] == "user"
- assert result["errors"] == {}
+ assert result["step_id"] == "change_password"
+ assert set(result["data_schema"].schema) == {CONF_PASSWORD}
- suggested_values = {
- key: key.description.get("suggested_value")
- for key in result["data_schema"].schema
- }
- assert suggested_values[CONF_USERNAME] == FIXTURE_USER_INPUT[CONF_USERNAME]
- assert suggested_values[CONF_PASSWORD] == wrong_password
- assert suggested_values[CONF_REGION] == FIXTURE_USER_INPUT[CONF_REGION]
-
- result2 = await hass.config_entries.flow.async_configure(
- result["flow_id"], FIXTURE_USER_INPUT
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], {CONF_PASSWORD: FIXTURE_USER_INPUT[CONF_PASSWORD]}
)
await hass.async_block_till_done()
- assert result2["type"] is FlowResultType.ABORT
- assert result2["reason"] == "reauth_successful"
+ 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.ABORT
+ assert result["reason"] == "reauth_successful"
assert config_entry.data == FIXTURE_COMPLETE_ENTRY
assert len(mock_setup_entry.mock_calls) == 2
-async def test_reauth_unique_id_abort(hass: HomeAssistant) -> None:
- """Test aborting the reauth form if unique_id changes."""
- with patch(
- "bimmer_connected.api.authentication.MyBMWAuthentication.login",
- side_effect=login_sideeffect,
- autospec=True,
- ):
- wrong_password = "wrong"
-
- config_entry_with_wrong_password = deepcopy(FIXTURE_CONFIG_ENTRY)
- config_entry_with_wrong_password["data"][CONF_PASSWORD] = wrong_password
-
- config_entry = MockConfigEntry(**config_entry_with_wrong_password)
- config_entry.add_to_hass(hass)
-
- await hass.config_entries.async_setup(config_entry.entry_id)
- await hass.async_block_till_done()
-
- assert config_entry.data == config_entry_with_wrong_password["data"]
-
- result = await config_entry.start_reauth_flow(hass)
- assert result["type"] is FlowResultType.FORM
- assert result["step_id"] == "user"
- assert result["errors"] == {}
-
- result2 = await hass.config_entries.flow.async_configure(
- result["flow_id"], {**FIXTURE_USER_INPUT, CONF_REGION: "north_america"}
- )
- await hass.async_block_till_done()
-
- assert result2["type"] is FlowResultType.ABORT
- assert result2["reason"] == "account_mismatch"
- assert config_entry.data == config_entry_with_wrong_password["data"]
-
-
async def test_reconfigure(hass: HomeAssistant) -> None:
"""Test the reconfiguration form."""
with patch(
@@ -268,79 +261,21 @@ async def test_reconfigure(hass: HomeAssistant) -> None:
result = await config_entry.start_reconfigure_flow(hass)
assert result["type"] is FlowResultType.FORM
- assert result["step_id"] == "user"
- assert result["errors"] == {}
+ assert result["step_id"] == "change_password"
+ assert set(result["data_schema"].schema) == {CONF_PASSWORD}
- suggested_values = {
- key: key.description.get("suggested_value")
- for key in result["data_schema"].schema
- }
- assert suggested_values[CONF_USERNAME] == FIXTURE_USER_INPUT[CONF_USERNAME]
- assert suggested_values[CONF_PASSWORD] == FIXTURE_USER_INPUT[CONF_PASSWORD]
- assert suggested_values[CONF_REGION] == FIXTURE_USER_INPUT[CONF_REGION]
-
- result2 = await hass.config_entries.flow.async_configure(
- result["flow_id"], FIXTURE_USER_INPUT
- )
- await hass.async_block_till_done()
-
- assert result2["type"] is FlowResultType.ABORT
- assert result2["reason"] == "reconfigure_successful"
- assert config_entry.data == FIXTURE_COMPLETE_ENTRY
-
-
-async def test_reconfigure_unique_id_abort(hass: HomeAssistant) -> None:
- """Test aborting the reconfiguration form if unique_id changes."""
- with patch(
- "bimmer_connected.api.authentication.MyBMWAuthentication.login",
- side_effect=login_sideeffect,
- autospec=True,
- ):
- 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()
-
- result = await config_entry.start_reconfigure_flow(hass)
- assert result["type"] is FlowResultType.FORM
- assert result["step_id"] == "user"
- assert result["errors"] == {}
-
- result2 = await hass.config_entries.flow.async_configure(
- result["flow_id"],
- {**FIXTURE_USER_INPUT, CONF_USERNAME: "somebody@email.com"},
- )
- await hass.async_block_till_done()
-
- assert result2["type"] is FlowResultType.ABORT
- assert result2["reason"] == "account_mismatch"
- assert config_entry.data == FIXTURE_COMPLETE_ENTRY
-
-
-@pytest.mark.usefixtures("bmw_fixture")
-async def test_captcha_flow_not_set(hass: HomeAssistant) -> None:
- """Test the external flow with captcha failing once and succeeding the second time."""
-
- TEST_REGION = "north_america"
-
- # Start flow and open form
- # Start flow and open form
- 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"
-
- # Add login data
- with patch(
- "bimmer_connected.api.authentication.MyBMWAuthentication._login_row_na",
- side_effect=MyBMWCaptchaMissingError(
- "Missing hCaptcha token for North America login"
- ),
- ):
result = await hass.config_entries.flow.async_configure(
- result["flow_id"],
- user_input={**FIXTURE_USER_INPUT, CONF_REGION: TEST_REGION},
+ result["flow_id"], {CONF_PASSWORD: FIXTURE_USER_INPUT[CONF_PASSWORD]}
)
- assert result["errors"]["base"] == "missing_captcha"
+ await hass.async_block_till_done()
+
+ 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.ABORT
+ assert result["reason"] == "reconfigure_successful"
+ assert config_entry.data == FIXTURE_COMPLETE_ENTRY
diff --git a/tests/components/bmw_connected_drive/test_coordinator.py b/tests/components/bmw_connected_drive/test_coordinator.py
index 774a85eb6da..beb3d74d572 100644
--- a/tests/components/bmw_connected_drive/test_coordinator.py
+++ b/tests/components/bmw_connected_drive/test_coordinator.py
@@ -33,7 +33,7 @@ async def test_update_success(hass: HomeAssistant) -> None:
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
- assert config_entry.runtime_data.coordinator.last_update_success is True
+ assert config_entry.runtime_data.last_update_success is True
@pytest.mark.usefixtures("bmw_fixture")
@@ -48,7 +48,7 @@ async def test_update_failed(
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
- coordinator = config_entry.runtime_data.coordinator
+ coordinator = config_entry.runtime_data
assert coordinator.last_update_success is True
@@ -77,7 +77,7 @@ async def test_update_reauth(
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
- coordinator = config_entry.runtime_data.coordinator
+ coordinator = config_entry.runtime_data
assert coordinator.last_update_success is True
@@ -146,7 +146,7 @@ async def test_captcha_reauth(
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
- coordinator = config_entry.runtime_data.coordinator
+ coordinator = config_entry.runtime_data
assert coordinator.last_update_success is True
diff --git a/tests/components/bmw_connected_drive/test_init.py b/tests/components/bmw_connected_drive/test_init.py
index e523b2b3d02..8507cacc376 100644
--- a/tests/components/bmw_connected_drive/test_init.py
+++ b/tests/components/bmw_connected_drive/test_init.py
@@ -10,7 +10,7 @@ from homeassistant.components.bmw_connected_drive.const import (
CONF_READ_ONLY,
DOMAIN as BMW_DOMAIN,
)
-from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
+from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr, entity_registry as er
@@ -18,6 +18,9 @@ from . import FIXTURE_CONFIG_ENTRY
from tests.common import MockConfigEntry
+BINARY_SENSOR_DOMAIN = Platform.BINARY_SENSOR.value
+SENSOR_DOMAIN = Platform.SENSOR.value
+
VIN = "WBYYYYYYYYYYYYYYY"
VEHICLE_NAME = "i3 (+ REX)"
VEHICLE_NAME_SLUG = "i3_rex"
@@ -109,6 +112,28 @@ async def test_migrate_options_from_data(hass: HomeAssistant) -> None:
f"{VIN}-mileage",
f"{VIN}-mileage",
),
+ (
+ {
+ "domain": SENSOR_DOMAIN,
+ "platform": BMW_DOMAIN,
+ "unique_id": f"{VIN}-charging_status",
+ "suggested_object_id": f"{VEHICLE_NAME} Charging Status",
+ "disabled_by": None,
+ },
+ f"{VIN}-charging_status",
+ f"{VIN}-fuel_and_battery.charging_status",
+ ),
+ (
+ {
+ "domain": BINARY_SENSOR_DOMAIN,
+ "platform": BMW_DOMAIN,
+ "unique_id": f"{VIN}-charging_status",
+ "suggested_object_id": f"{VEHICLE_NAME} Charging Status",
+ "disabled_by": None,
+ },
+ f"{VIN}-charging_status",
+ f"{VIN}-charging_status",
+ ),
],
)
async def test_migrate_unique_ids(
diff --git a/tests/components/bmw_connected_drive/test_lock.py b/tests/components/bmw_connected_drive/test_lock.py
index 2fa694d426b..088534c79f5 100644
--- a/tests/components/bmw_connected_drive/test_lock.py
+++ b/tests/components/bmw_connected_drive/test_lock.py
@@ -16,7 +16,12 @@ from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er
from homeassistant.util import dt as dt_util
-from . import check_remote_service_call, setup_mocked_integration
+from . import (
+ REMOTE_SERVICE_EXC_REASON,
+ REMOTE_SERVICE_EXC_TRANSLATION,
+ check_remote_service_call,
+ setup_mocked_integration,
+)
from tests.common import snapshot_platform
from tests.components.recorder.common import async_wait_recording_done
@@ -118,11 +123,11 @@ async def test_service_call_fail(
monkeypatch.setattr(
RemoteServices,
"trigger_remote_service",
- AsyncMock(side_effect=MyBMWRemoteServiceError),
+ AsyncMock(side_effect=MyBMWRemoteServiceError(REMOTE_SERVICE_EXC_REASON)),
)
# Test
- with pytest.raises(HomeAssistantError):
+ with pytest.raises(HomeAssistantError, match=REMOTE_SERVICE_EXC_TRANSLATION):
await hass.services.async_call(
"lock",
service,
diff --git a/tests/components/bmw_connected_drive/test_notify.py b/tests/components/bmw_connected_drive/test_notify.py
index 4113f618be0..1bade3be011 100644
--- a/tests/components/bmw_connected_drive/test_notify.py
+++ b/tests/components/bmw_connected_drive/test_notify.py
@@ -11,7 +11,11 @@ import respx
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
-from . import check_remote_service_call, setup_mocked_integration
+from . import (
+ REMOTE_SERVICE_EXC_TRANSLATION,
+ check_remote_service_call,
+ setup_mocked_integration,
+)
async def test_legacy_notify_service_simple(
@@ -68,21 +72,21 @@ async def test_legacy_notify_service_simple(
{
"latitude": POI_DATA.get("lat"),
},
- "Invalid data for point of interest: required key not provided @ data['longitude']",
+ r"Invalid data for point of interest: required key not provided @ data\['longitude'\]",
),
(
{
"latitude": POI_DATA.get("lat"),
"longitude": "text",
},
- "Invalid data for point of interest: invalid longitude for dictionary value @ data['longitude']",
+ r"Invalid data for point of interest: invalid longitude for dictionary value @ data\['longitude'\]",
),
(
{
"latitude": POI_DATA.get("lat"),
"longitude": 9999,
},
- "Invalid data for point of interest: invalid longitude for dictionary value @ data['longitude']",
+ r"Invalid data for point of interest: invalid longitude for dictionary value @ data\['longitude'\]",
),
],
)
@@ -96,7 +100,7 @@ async def test_service_call_invalid_input(
# Setup component
assert await setup_mocked_integration(hass)
- with pytest.raises(ServiceValidationError) as exc:
+ with pytest.raises(ServiceValidationError, match=exc_translation):
await hass.services.async_call(
"notify",
"bmw_connected_drive_ix_xdrive50",
@@ -106,7 +110,6 @@ async def test_service_call_invalid_input(
},
blocking=True,
)
- assert str(exc.value) == exc_translation
@pytest.mark.usefixtures("bmw_fixture")
@@ -132,11 +135,11 @@ async def test_service_call_fail(
monkeypatch.setattr(
RemoteServices,
"trigger_remote_service",
- AsyncMock(side_effect=raised),
+ AsyncMock(side_effect=raised("HTTPStatusError: 502 Bad Gateway")),
)
# Test
- with pytest.raises(expected):
+ with pytest.raises(expected, match=REMOTE_SERVICE_EXC_TRANSLATION):
await hass.services.async_call(
"notify",
"bmw_connected_drive_ix_xdrive50",
diff --git a/tests/components/bmw_connected_drive/test_number.py b/tests/components/bmw_connected_drive/test_number.py
index f2a50ce4df6..733f4fe3113 100644
--- a/tests/components/bmw_connected_drive/test_number.py
+++ b/tests/components/bmw_connected_drive/test_number.py
@@ -13,7 +13,12 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er
-from . import check_remote_service_call, setup_mocked_integration
+from . import (
+ REMOTE_SERVICE_EXC_REASON,
+ REMOTE_SERVICE_EXC_TRANSLATION,
+ check_remote_service_call,
+ setup_mocked_integration,
+)
from tests.common import snapshot_platform
@@ -89,7 +94,10 @@ async def test_service_call_invalid_input(
old_value = hass.states.get(entity_id).state
# Test
- with pytest.raises(ValueError):
+ with pytest.raises(
+ ValueError,
+ match="Target SoC must be an integer between 20 and 100 that is a multiple of 5.",
+ ):
await hass.services.async_call(
"number",
"set_value",
@@ -102,17 +110,32 @@ async def test_service_call_invalid_input(
@pytest.mark.usefixtures("bmw_fixture")
@pytest.mark.parametrize(
- ("raised", "expected"),
+ ("raised", "expected", "exc_translation"),
[
- (MyBMWRemoteServiceError, HomeAssistantError),
- (MyBMWAPIError, HomeAssistantError),
- (ValueError, ValueError),
+ (
+ MyBMWRemoteServiceError(REMOTE_SERVICE_EXC_REASON),
+ HomeAssistantError,
+ REMOTE_SERVICE_EXC_TRANSLATION,
+ ),
+ (
+ MyBMWAPIError(REMOTE_SERVICE_EXC_REASON),
+ HomeAssistantError,
+ REMOTE_SERVICE_EXC_TRANSLATION,
+ ),
+ (
+ ValueError(
+ "Target SoC must be an integer between 20 and 100 that is a multiple of 5."
+ ),
+ ValueError,
+ "Target SoC must be an integer between 20 and 100 that is a multiple of 5.",
+ ),
],
)
async def test_service_call_fail(
hass: HomeAssistant,
raised: Exception,
expected: Exception,
+ exc_translation: str,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Test exception handling."""
@@ -130,7 +153,7 @@ async def test_service_call_fail(
)
# Test
- with pytest.raises(expected):
+ with pytest.raises(expected, match=exc_translation):
await hass.services.async_call(
"number",
"set_value",
diff --git a/tests/components/bmw_connected_drive/test_select.py b/tests/components/bmw_connected_drive/test_select.py
index a270f38ee01..53c39f572f2 100644
--- a/tests/components/bmw_connected_drive/test_select.py
+++ b/tests/components/bmw_connected_drive/test_select.py
@@ -16,7 +16,12 @@ from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.translation import async_get_translations
-from . import check_remote_service_call, setup_mocked_integration
+from . import (
+ REMOTE_SERVICE_EXC_REASON,
+ REMOTE_SERVICE_EXC_TRANSLATION,
+ check_remote_service_call,
+ setup_mocked_integration,
+)
from tests.common import snapshot_platform
@@ -105,7 +110,10 @@ async def test_service_call_invalid_input(
old_value = hass.states.get(entity_id).state
# Test
- with pytest.raises(ServiceValidationError):
+ with pytest.raises(
+ ServiceValidationError,
+ match=f"Option {value} is not valid for entity {entity_id}",
+ ):
await hass.services.async_call(
"select",
"select_option",
@@ -118,17 +126,32 @@ async def test_service_call_invalid_input(
@pytest.mark.usefixtures("bmw_fixture")
@pytest.mark.parametrize(
- ("raised", "expected"),
+ ("raised", "expected", "exc_translation"),
[
- (MyBMWRemoteServiceError, HomeAssistantError),
- (MyBMWAPIError, HomeAssistantError),
- (ServiceValidationError, ServiceValidationError),
+ (
+ MyBMWRemoteServiceError(REMOTE_SERVICE_EXC_REASON),
+ HomeAssistantError,
+ REMOTE_SERVICE_EXC_TRANSLATION,
+ ),
+ (
+ MyBMWAPIError(REMOTE_SERVICE_EXC_REASON),
+ 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(
hass: HomeAssistant,
raised: Exception,
expected: Exception,
+ exc_translation: str,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Test exception handling."""
@@ -146,7 +169,7 @@ async def test_service_call_fail(
)
# Test
- with pytest.raises(expected):
+ with pytest.raises(expected, match=exc_translation):
await hass.services.async_call(
"select",
"select_option",
diff --git a/tests/components/bmw_connected_drive/test_switch.py b/tests/components/bmw_connected_drive/test_switch.py
index 58bddbfc937..c28b651abaf 100644
--- a/tests/components/bmw_connected_drive/test_switch.py
+++ b/tests/components/bmw_connected_drive/test_switch.py
@@ -13,7 +13,12 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er
-from . import check_remote_service_call, setup_mocked_integration
+from . import (
+ REMOTE_SERVICE_EXC_REASON,
+ REMOTE_SERVICE_EXC_TRANSLATION,
+ check_remote_service_call,
+ setup_mocked_integration,
+)
from tests.common import snapshot_platform
@@ -75,17 +80,25 @@ async def test_service_call_success(
@pytest.mark.usefixtures("bmw_fixture")
@pytest.mark.parametrize(
- ("raised", "expected"),
+ ("raised", "expected", "exc_translation"),
[
- (MyBMWRemoteServiceError, HomeAssistantError),
- (MyBMWAPIError, HomeAssistantError),
- (ValueError, ValueError),
+ (
+ MyBMWRemoteServiceError(REMOTE_SERVICE_EXC_REASON),
+ HomeAssistantError,
+ REMOTE_SERVICE_EXC_TRANSLATION,
+ ),
+ (
+ MyBMWAPIError(REMOTE_SERVICE_EXC_REASON),
+ HomeAssistantError,
+ REMOTE_SERVICE_EXC_TRANSLATION,
+ ),
],
)
async def test_service_call_fail(
hass: HomeAssistant,
raised: Exception,
expected: Exception,
+ exc_translation: str,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Test exception handling."""
@@ -107,7 +120,7 @@ async def test_service_call_fail(
assert hass.states.get(entity_id).state == old_value
# Test
- with pytest.raises(expected):
+ with pytest.raises(expected, match=exc_translation):
await hass.services.async_call(
"switch",
"turn_on",
@@ -122,7 +135,7 @@ async def test_service_call_fail(
assert hass.states.get(entity_id).state == old_value
# Test
- with pytest.raises(expected):
+ with pytest.raises(expected, match=exc_translation):
await hass.services.async_call(
"switch",
"turn_off",
diff --git a/tests/components/bring/test_config_flow.py b/tests/components/bring/test_config_flow.py
index 8d215a5d3ee..93e86051a75 100644
--- a/tests/components/bring/test_config_flow.py
+++ b/tests/components/bring/test_config_flow.py
@@ -188,3 +188,29 @@ async def test_flow_reauth_error_and_recover(
assert result["reason"] == "reauth_successful"
assert len(hass.config_entries.async_entries()) == 1
+
+
+async def test_flow_reauth_unique_id_mismatch(
+ hass: HomeAssistant,
+ bring_config_entry: MockConfigEntry,
+ mock_bring_client: AsyncMock,
+) -> None:
+ """Test we abort reauth if unique id mismatch."""
+
+ mock_bring_client.uuid = "11111111-11111111-11111111-11111111"
+
+ bring_config_entry.add_to_hass(hass)
+
+ result = await bring_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: "new-password"},
+ )
+
+ await hass.async_block_till_done()
+
+ assert result["type"] is FlowResultType.ABORT
+ assert result["reason"] == "unique_id_mismatch"
diff --git a/tests/components/broadlink/test_sensors.py b/tests/components/broadlink/test_sensor.py
similarity index 100%
rename from tests/components/broadlink/test_sensors.py
rename to tests/components/broadlink/test_sensor.py
diff --git a/tests/components/brother/snapshots/test_sensor.ambr b/tests/components/brother/snapshots/test_sensor.ambr
index a27c5addd61..4de85859461 100644
--- a/tests/components/brother/snapshots/test_sensor.ambr
+++ b/tests/components/brother/snapshots/test_sensor.ambr
@@ -31,7 +31,7 @@
'supported_features': 0,
'translation_key': 'bw_pages',
'unique_id': '0123456789_bw_counter',
- 'unit_of_measurement': 'p',
+ 'unit_of_measurement': 'pages',
})
# ---
# name: test_sensors[sensor.hl_l2340dw_b_w_pages-state]
@@ -39,7 +39,7 @@
'attributes': ReadOnlyDict({
'friendly_name': 'HL-L2340DW B/W pages',
'state_class': ,
- 'unit_of_measurement': 'p',
+ 'unit_of_measurement': 'pages',
}),
'context': ,
'entity_id': 'sensor.hl_l2340dw_b_w_pages',
@@ -131,7 +131,7 @@
'supported_features': 0,
'translation_key': 'black_drum_page_counter',
'unique_id': '0123456789_black_drum_counter',
- 'unit_of_measurement': 'p',
+ 'unit_of_measurement': 'pages',
})
# ---
# name: test_sensors[sensor.hl_l2340dw_black_drum_page_counter-state]
@@ -139,7 +139,7 @@
'attributes': ReadOnlyDict({
'friendly_name': 'HL-L2340DW Black drum page counter',
'state_class': ,
- 'unit_of_measurement': 'p',
+ 'unit_of_measurement': 'pages',
}),
'context': ,
'entity_id': 'sensor.hl_l2340dw_black_drum_page_counter',
@@ -231,7 +231,7 @@
'supported_features': 0,
'translation_key': 'black_drum_remaining_pages',
'unique_id': '0123456789_black_drum_remaining_pages',
- 'unit_of_measurement': 'p',
+ 'unit_of_measurement': 'pages',
})
# ---
# name: test_sensors[sensor.hl_l2340dw_black_drum_remaining_pages-state]
@@ -239,7 +239,7 @@
'attributes': ReadOnlyDict({
'friendly_name': 'HL-L2340DW Black drum remaining pages',
'state_class': ,
- 'unit_of_measurement': 'p',
+ 'unit_of_measurement': 'pages',
}),
'context': ,
'entity_id': 'sensor.hl_l2340dw_black_drum_remaining_pages',
@@ -331,7 +331,7 @@
'supported_features': 0,
'translation_key': 'color_pages',
'unique_id': '0123456789_color_counter',
- 'unit_of_measurement': 'p',
+ 'unit_of_measurement': 'pages',
})
# ---
# name: test_sensors[sensor.hl_l2340dw_color_pages-state]
@@ -339,7 +339,7 @@
'attributes': ReadOnlyDict({
'friendly_name': 'HL-L2340DW Color pages',
'state_class': ,
- 'unit_of_measurement': 'p',
+ 'unit_of_measurement': 'pages',
}),
'context': ,
'entity_id': 'sensor.hl_l2340dw_color_pages',
@@ -381,7 +381,7 @@
'supported_features': 0,
'translation_key': 'cyan_drum_page_counter',
'unique_id': '0123456789_cyan_drum_counter',
- 'unit_of_measurement': 'p',
+ 'unit_of_measurement': 'pages',
})
# ---
# name: test_sensors[sensor.hl_l2340dw_cyan_drum_page_counter-state]
@@ -389,7 +389,7 @@
'attributes': ReadOnlyDict({
'friendly_name': 'HL-L2340DW Cyan drum page counter',
'state_class': ,
- 'unit_of_measurement': 'p',
+ 'unit_of_measurement': 'pages',
}),
'context': ,
'entity_id': 'sensor.hl_l2340dw_cyan_drum_page_counter',
@@ -481,7 +481,7 @@
'supported_features': 0,
'translation_key': 'cyan_drum_remaining_pages',
'unique_id': '0123456789_cyan_drum_remaining_pages',
- 'unit_of_measurement': 'p',
+ 'unit_of_measurement': 'pages',
})
# ---
# name: test_sensors[sensor.hl_l2340dw_cyan_drum_remaining_pages-state]
@@ -489,7 +489,7 @@
'attributes': ReadOnlyDict({
'friendly_name': 'HL-L2340DW Cyan drum remaining pages',
'state_class': ,
- 'unit_of_measurement': 'p',
+ 'unit_of_measurement': 'pages',
}),
'context': ,
'entity_id': 'sensor.hl_l2340dw_cyan_drum_remaining_pages',
@@ -581,7 +581,7 @@
'supported_features': 0,
'translation_key': 'drum_page_counter',
'unique_id': '0123456789_drum_counter',
- 'unit_of_measurement': 'p',
+ 'unit_of_measurement': 'pages',
})
# ---
# name: test_sensors[sensor.hl_l2340dw_drum_page_counter-state]
@@ -589,7 +589,7 @@
'attributes': ReadOnlyDict({
'friendly_name': 'HL-L2340DW Drum page counter',
'state_class': ,
- 'unit_of_measurement': 'p',
+ 'unit_of_measurement': 'pages',
}),
'context': ,
'entity_id': 'sensor.hl_l2340dw_drum_page_counter',
@@ -681,7 +681,7 @@
'supported_features': 0,
'translation_key': 'drum_remaining_pages',
'unique_id': '0123456789_drum_remaining_pages',
- 'unit_of_measurement': 'p',
+ 'unit_of_measurement': 'pages',
})
# ---
# name: test_sensors[sensor.hl_l2340dw_drum_remaining_pages-state]
@@ -689,7 +689,7 @@
'attributes': ReadOnlyDict({
'friendly_name': 'HL-L2340DW Drum remaining pages',
'state_class': ,
- 'unit_of_measurement': 'p',
+ 'unit_of_measurement': 'pages',
}),
'context': ,
'entity_id': 'sensor.hl_l2340dw_drum_remaining_pages',
@@ -731,7 +731,7 @@
'supported_features': 0,
'translation_key': 'duplex_unit_page_counter',
'unique_id': '0123456789_duplex_unit_pages_counter',
- 'unit_of_measurement': 'p',
+ 'unit_of_measurement': 'pages',
})
# ---
# name: test_sensors[sensor.hl_l2340dw_duplex_unit_page_counter-state]
@@ -739,7 +739,7 @@
'attributes': ReadOnlyDict({
'friendly_name': 'HL-L2340DW Duplex unit page counter',
'state_class': ,
- 'unit_of_measurement': 'p',
+ 'unit_of_measurement': 'pages',
}),
'context': ,
'entity_id': 'sensor.hl_l2340dw_duplex_unit_page_counter',
@@ -878,7 +878,7 @@
'supported_features': 0,
'translation_key': 'magenta_drum_page_counter',
'unique_id': '0123456789_magenta_drum_counter',
- 'unit_of_measurement': 'p',
+ 'unit_of_measurement': 'pages',
})
# ---
# name: test_sensors[sensor.hl_l2340dw_magenta_drum_page_counter-state]
@@ -886,7 +886,7 @@
'attributes': ReadOnlyDict({
'friendly_name': 'HL-L2340DW Magenta drum page counter',
'state_class': ,
- 'unit_of_measurement': 'p',
+ 'unit_of_measurement': 'pages',
}),
'context': ,
'entity_id': 'sensor.hl_l2340dw_magenta_drum_page_counter',
@@ -978,7 +978,7 @@
'supported_features': 0,
'translation_key': 'magenta_drum_remaining_pages',
'unique_id': '0123456789_magenta_drum_remaining_pages',
- 'unit_of_measurement': 'p',
+ 'unit_of_measurement': 'pages',
})
# ---
# name: test_sensors[sensor.hl_l2340dw_magenta_drum_remaining_pages-state]
@@ -986,7 +986,7 @@
'attributes': ReadOnlyDict({
'friendly_name': 'HL-L2340DW Magenta drum remaining pages',
'state_class': ,
- 'unit_of_measurement': 'p',
+ 'unit_of_measurement': 'pages',
}),
'context': ,
'entity_id': 'sensor.hl_l2340dw_magenta_drum_remaining_pages',
@@ -1078,7 +1078,7 @@
'supported_features': 0,
'translation_key': 'page_counter',
'unique_id': '0123456789_page_counter',
- 'unit_of_measurement': 'p',
+ 'unit_of_measurement': 'pages',
})
# ---
# name: test_sensors[sensor.hl_l2340dw_page_counter-state]
@@ -1086,7 +1086,7 @@
'attributes': ReadOnlyDict({
'friendly_name': 'HL-L2340DW Page counter',
'state_class': ,
- 'unit_of_measurement': 'p',
+ 'unit_of_measurement': 'pages',
}),
'context': ,
'entity_id': 'sensor.hl_l2340dw_page_counter',
@@ -1224,7 +1224,7 @@
'supported_features': 0,
'translation_key': 'yellow_drum_page_counter',
'unique_id': '0123456789_yellow_drum_counter',
- 'unit_of_measurement': 'p',
+ 'unit_of_measurement': 'pages',
})
# ---
# name: test_sensors[sensor.hl_l2340dw_yellow_drum_page_counter-state]
@@ -1232,7 +1232,7 @@
'attributes': ReadOnlyDict({
'friendly_name': 'HL-L2340DW Yellow drum page counter',
'state_class': ,
- 'unit_of_measurement': 'p',
+ 'unit_of_measurement': 'pages',
}),
'context': ,
'entity_id': 'sensor.hl_l2340dw_yellow_drum_page_counter',
@@ -1324,7 +1324,7 @@
'supported_features': 0,
'translation_key': 'yellow_drum_remaining_pages',
'unique_id': '0123456789_yellow_drum_remaining_pages',
- 'unit_of_measurement': 'p',
+ 'unit_of_measurement': 'pages',
})
# ---
# name: test_sensors[sensor.hl_l2340dw_yellow_drum_remaining_pages-state]
@@ -1332,7 +1332,7 @@
'attributes': ReadOnlyDict({
'friendly_name': 'HL-L2340DW Yellow drum remaining pages',
'state_class': ,
- 'unit_of_measurement': 'p',
+ 'unit_of_measurement': 'pages',
}),
'context': ,
'entity_id': 'sensor.hl_l2340dw_yellow_drum_remaining_pages',
diff --git a/tests/components/bsblan/conftest.py b/tests/components/bsblan/conftest.py
index e46cdd75f2d..7d2db2f8b46 100644
--- a/tests/components/bsblan/conftest.py
+++ b/tests/components/bsblan/conftest.py
@@ -3,7 +3,7 @@
from collections.abc import Generator
from unittest.mock import AsyncMock, MagicMock, patch
-from bsblan import Device, Info, Sensor, State, StaticState
+from bsblan import Device, HotWaterState, Info, Sensor, State, StaticState
import pytest
from homeassistant.components.bsblan.const import CONF_PASSKEY, DOMAIN
@@ -58,6 +58,11 @@ def mock_bsblan() -> Generator[MagicMock]:
bsblan.sensor.return_value = Sensor.from_json(
load_fixture("sensor.json", DOMAIN)
)
+ bsblan.hot_water_state.return_value = HotWaterState.from_json(
+ load_fixture("dhw_state.json", DOMAIN)
+ )
+ # mock get_temperature_unit property
+ bsblan.get_temperature_unit = "°C"
yield bsblan
diff --git a/tests/components/bsblan/fixtures/dhw_state.json b/tests/components/bsblan/fixtures/dhw_state.json
new file mode 100644
index 00000000000..41b8c7beda5
--- /dev/null
+++ b/tests/components/bsblan/fixtures/dhw_state.json
@@ -0,0 +1,110 @@
+{
+ "operating_mode": {
+ "name": "DHW operating mode",
+ "error": 0,
+ "value": "On",
+ "desc": "On",
+ "dataType": 1,
+ "readonly": 0,
+ "unit": ""
+ },
+ "nominal_setpoint": {
+ "name": "DHW nominal setpoint",
+ "error": 0,
+ "value": "50.0",
+ "desc": "",
+ "dataType": 0,
+ "readonly": 0,
+ "unit": "°C"
+ },
+ "nominal_setpoint_max": {
+ "name": "DHW nominal setpoint maximum",
+ "error": 0,
+ "value": "65.0",
+ "desc": "",
+ "dataType": 0,
+ "readonly": 0,
+ "unit": "°C"
+ },
+ "reduced_setpoint": {
+ "name": "DHW reduced setpoint",
+ "error": 0,
+ "value": "40.0",
+ "desc": "",
+ "dataType": 0,
+ "readonly": 0,
+ "unit": "°C"
+ },
+ "release": {
+ "name": "DHW release programme",
+ "error": 0,
+ "value": "1",
+ "desc": "Released",
+ "dataType": 1,
+ "readonly": 0,
+ "unit": ""
+ },
+ "legionella_function": {
+ "name": "Legionella function fixed weekday",
+ "error": 0,
+ "value": "0",
+ "desc": "Off",
+ "dataType": 1,
+ "readonly": 0,
+ "unit": ""
+ },
+ "legionella_setpoint": {
+ "name": "Legionella function setpoint",
+ "error": 0,
+ "value": "60.0",
+ "desc": "",
+ "dataType": 0,
+ "readonly": 0,
+ "unit": "°C"
+ },
+ "legionella_periodicity": {
+ "name": "Legionella function periodicity",
+ "error": 0,
+ "value": "7",
+ "desc": "Weekly",
+ "dataType": 0,
+ "readonly": 0,
+ "unit": "days"
+ },
+ "legionella_function_day": {
+ "name": "Legionella function day",
+ "error": 0,
+ "value": "6",
+ "desc": "Saturday",
+ "dataType": 1,
+ "readonly": 0,
+ "unit": ""
+ },
+ "legionella_function_time": {
+ "name": "Legionella function time",
+ "error": 0,
+ "value": "12:00",
+ "desc": "",
+ "dataType": 2,
+ "readonly": 0,
+ "unit": ""
+ },
+ "dhw_actual_value_top_temperature": {
+ "name": "DHW temperature actual value",
+ "error": 0,
+ "value": "48.5",
+ "desc": "",
+ "dataType": 0,
+ "readonly": 1,
+ "unit": "°C"
+ },
+ "state_dhw_pump": {
+ "name": "State DHW circulation pump",
+ "error": 0,
+ "value": "0",
+ "desc": "Off",
+ "dataType": 1,
+ "readonly": 1,
+ "unit": ""
+ }
+}
diff --git a/tests/components/bsblan/snapshots/test_climate.ambr b/tests/components/bsblan/snapshots/test_climate.ambr
index 4eb70fe2658..16828fea752 100644
--- a/tests/components/bsblan/snapshots/test_climate.ambr
+++ b/tests/components/bsblan/snapshots/test_climate.ambr
@@ -1,5 +1,5 @@
# serializer version: 1
-# name: test_celsius_fahrenheit[static.json][climate.bsb_lan-entry]
+# name: test_celsius_fahrenheit[climate.bsb_lan-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
@@ -44,7 +44,7 @@
'unit_of_measurement': None,
})
# ---
-# name: test_celsius_fahrenheit[static.json][climate.bsb_lan-state]
+# name: test_celsius_fahrenheit[climate.bsb_lan-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'current_temperature': 18.6,
@@ -72,79 +72,6 @@
'state': 'heat',
})
# ---
-# name: test_celsius_fahrenheit[static_F.json][climate.bsb_lan-entry]
- EntityRegistryEntrySnapshot({
- 'aliases': set({
- }),
- 'area_id': None,
- 'capabilities': dict({
- 'hvac_modes': list([
- ,
- ,
- ,
- ]),
- 'max_temp': -6.7,
- 'min_temp': -13.3,
- 'preset_modes': list([
- 'eco',
- 'none',
- ]),
- }),
- 'config_entry_id': ,
- 'device_class': None,
- 'device_id': ,
- 'disabled_by': None,
- 'domain': 'climate',
- 'entity_category': None,
- 'entity_id': 'climate.bsb_lan',
- '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': 'bsblan',
- 'previous_unique_id': None,
- 'supported_features': ,
- 'translation_key': None,
- 'unique_id': '00:80:41:19:69:90-climate',
- 'unit_of_measurement': None,
- })
-# ---
-# name: test_celsius_fahrenheit[static_F.json][climate.bsb_lan-state]
- StateSnapshot({
- 'attributes': ReadOnlyDict({
- 'current_temperature': -7.4,
- 'friendly_name': 'BSB-LAN',
- 'hvac_modes': list([
- ,
- ,
- ,
- ]),
- 'max_temp': -6.7,
- 'min_temp': -13.3,
- 'preset_mode': 'none',
- 'preset_modes': list([
- 'eco',
- 'none',
- ]),
- 'supported_features': ,
- 'temperature': -7.5,
- }),
- 'context': ,
- 'entity_id': 'climate.bsb_lan',
- 'last_changed': ,
- 'last_reported': ,
- 'last_updated': ,
- 'state': 'heat',
- })
-# ---
# name: test_climate_entity_properties[climate.bsb_lan-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
diff --git a/tests/components/bsblan/snapshots/test_water_heater.ambr b/tests/components/bsblan/snapshots/test_water_heater.ambr
new file mode 100644
index 00000000000..c1a13b764c0
--- /dev/null
+++ b/tests/components/bsblan/snapshots/test_water_heater.ambr
@@ -0,0 +1,68 @@
+# serializer version: 1
+# name: test_water_heater_states[dhw_state.json][water_heater.bsb_lan-entry]
+ EntityRegistryEntrySnapshot({
+ 'aliases': set({
+ }),
+ 'area_id': None,
+ 'capabilities': dict({
+ 'max_temp': 65.0,
+ 'min_temp': 40.0,
+ 'operation_list': list([
+ 'eco',
+ 'off',
+ 'on',
+ ]),
+ }),
+ 'config_entry_id': ,
+ 'device_class': None,
+ 'device_id': ,
+ 'disabled_by': None,
+ 'domain': 'water_heater',
+ 'entity_category': None,
+ 'entity_id': 'water_heater.bsb_lan',
+ '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': 'bsblan',
+ 'previous_unique_id': None,
+ 'supported_features': ,
+ 'translation_key': None,
+ 'unique_id': '00:80:41:19:69:90',
+ 'unit_of_measurement': None,
+ })
+# ---
+# name: test_water_heater_states[dhw_state.json][water_heater.bsb_lan-state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ 'current_temperature': 48.5,
+ 'friendly_name': 'BSB-LAN',
+ 'max_temp': 65.0,
+ 'min_temp': 40.0,
+ 'operation_list': list([
+ 'eco',
+ 'off',
+ 'on',
+ ]),
+ 'operation_mode': 'on',
+ 'supported_features': ,
+ 'target_temp_high': None,
+ 'target_temp_low': None,
+ 'temperature': 50.0,
+ }),
+ 'context': ,
+ 'entity_id': 'water_heater.bsb_lan',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': 'on',
+ })
+# ---
diff --git a/tests/components/bsblan/test_climate.py b/tests/components/bsblan/test_climate.py
index c519c3043da..7ee12c5fa1a 100644
--- a/tests/components/bsblan/test_climate.py
+++ b/tests/components/bsblan/test_climate.py
@@ -3,12 +3,11 @@
from datetime import timedelta
from unittest.mock import AsyncMock, MagicMock
-from bsblan import BSBLANError, StaticState
+from bsblan import BSBLANError
from freezegun.api import FrozenDateTimeFactory
import pytest
from syrupy.assertion import SnapshotAssertion
-from homeassistant.components.bsblan.const import DOMAIN
from homeassistant.components.climate import (
ATTR_HVAC_MODE,
ATTR_PRESET_MODE,
@@ -27,37 +26,19 @@ import homeassistant.helpers.entity_registry as er
from . import setup_with_selected_platforms
-from tests.common import (
- MockConfigEntry,
- async_fire_time_changed,
- load_json_object_fixture,
- snapshot_platform,
-)
+from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
ENTITY_ID = "climate.bsb_lan"
-@pytest.mark.parametrize(
- ("static_file"),
- [
- ("static.json"),
- ("static_F.json"),
- ],
-)
async def test_celsius_fahrenheit(
hass: HomeAssistant,
mock_bsblan: AsyncMock,
mock_config_entry: MockConfigEntry,
snapshot: SnapshotAssertion,
entity_registry: er.EntityRegistry,
- static_file: str,
) -> None:
"""Test Celsius and Fahrenheit temperature units."""
-
- static_data = load_json_object_fixture(static_file, DOMAIN)
-
- mock_bsblan.static_values.return_value = StaticState.from_dict(static_data)
-
await setup_with_selected_platforms(hass, mock_config_entry, [Platform.CLIMATE])
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
@@ -75,21 +56,9 @@ async def test_climate_entity_properties(
await setup_with_selected_platforms(hass, mock_config_entry, [Platform.CLIMATE])
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
- # Test when current_temperature is "---"
- mock_current_temp = MagicMock()
- mock_current_temp.value = "---"
- mock_bsblan.state.return_value.current_temperature = mock_current_temp
-
- freezer.tick(timedelta(minutes=1))
- async_fire_time_changed(hass)
- await hass.async_block_till_done()
-
- state = hass.states.get(ENTITY_ID)
- assert state.attributes["current_temperature"] is None
-
# Test target_temperature
mock_target_temp = MagicMock()
- mock_target_temp.value = "23.5"
+ mock_target_temp.value = 23.5
mock_bsblan.state.return_value.target_temperature = mock_target_temp
freezer.tick(timedelta(minutes=1))
diff --git a/tests/components/bsblan/test_sensor.py b/tests/components/bsblan/test_sensor.py
index dc22574168d..c95671a1a6b 100644
--- a/tests/components/bsblan/test_sensor.py
+++ b/tests/components/bsblan/test_sensor.py
@@ -1,19 +1,17 @@
"""Tests for the BSB-Lan sensor platform."""
-from datetime import timedelta
-from unittest.mock import AsyncMock, MagicMock
+from unittest.mock import AsyncMock
from freezegun.api import FrozenDateTimeFactory
-import pytest
from syrupy.assertion import SnapshotAssertion
-from homeassistant.const import STATE_UNKNOWN, Platform
+from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
import homeassistant.helpers.entity_registry as er
from . import setup_with_selected_platforms
-from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
+from tests.common import MockConfigEntry, snapshot_platform
ENTITY_CURRENT_TEMP = "sensor.bsb_lan_current_temperature"
ENTITY_OUTSIDE_TEMP = "sensor.bsb_lan_outside_temperature"
@@ -30,37 +28,3 @@ async def test_sensor_entity_properties(
"""Test the sensor entity properties."""
await setup_with_selected_platforms(hass, mock_config_entry, [Platform.SENSOR])
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
-
-
-@pytest.mark.parametrize(
- ("value", "expected_state"),
- [
- (18.6, "18.6"),
- (None, STATE_UNKNOWN),
- ("---", STATE_UNKNOWN),
- ],
-)
-async def test_current_temperature_scenarios(
- hass: HomeAssistant,
- mock_bsblan: AsyncMock,
- mock_config_entry: MockConfigEntry,
- freezer: FrozenDateTimeFactory,
- value,
- expected_state,
-) -> None:
- """Test various scenarios for current temperature sensor."""
- await setup_with_selected_platforms(hass, mock_config_entry, [Platform.SENSOR])
-
- # Set up the mock value
- mock_current_temp = MagicMock()
- mock_current_temp.value = value
- mock_bsblan.sensor.return_value.current_temperature = mock_current_temp
-
- # Trigger an update
- freezer.tick(timedelta(minutes=1))
- async_fire_time_changed(hass)
- await hass.async_block_till_done()
-
- # Check the state
- state = hass.states.get(ENTITY_CURRENT_TEMP)
- assert state.state == expected_state
diff --git a/tests/components/bsblan/test_water_heater.py b/tests/components/bsblan/test_water_heater.py
new file mode 100644
index 00000000000..ed920774aa5
--- /dev/null
+++ b/tests/components/bsblan/test_water_heater.py
@@ -0,0 +1,210 @@
+"""Tests for the BSB-Lan water heater platform."""
+
+from datetime import timedelta
+from unittest.mock import AsyncMock, MagicMock
+
+from bsblan import BSBLANError
+from freezegun.api import FrozenDateTimeFactory
+import pytest
+from syrupy.assertion import SnapshotAssertion
+
+from homeassistant.components.water_heater import (
+ ATTR_OPERATION_MODE,
+ DOMAIN as WATER_HEATER_DOMAIN,
+ SERVICE_SET_OPERATION_MODE,
+ SERVICE_SET_TEMPERATURE,
+ STATE_ECO,
+ STATE_OFF,
+ STATE_ON,
+)
+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 . import setup_with_selected_platforms
+
+from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
+
+ENTITY_ID = "water_heater.bsb_lan"
+
+
+@pytest.mark.parametrize(
+ ("dhw_file"),
+ [
+ ("dhw_state.json"),
+ ],
+)
+async def test_water_heater_states(
+ hass: HomeAssistant,
+ mock_bsblan: AsyncMock,
+ mock_config_entry: MockConfigEntry,
+ snapshot: SnapshotAssertion,
+ entity_registry: er.EntityRegistry,
+ dhw_file: str,
+) -> None:
+ """Test water heater states with different configurations."""
+ await setup_with_selected_platforms(
+ hass, mock_config_entry, [Platform.WATER_HEATER]
+ )
+ await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
+
+
+async def test_water_heater_entity_properties(
+ hass: HomeAssistant,
+ mock_bsblan: AsyncMock,
+ mock_config_entry: MockConfigEntry,
+ freezer: FrozenDateTimeFactory,
+) -> None:
+ """Test the water heater entity properties."""
+ await setup_with_selected_platforms(
+ hass, mock_config_entry, [Platform.WATER_HEATER]
+ )
+
+ state = hass.states.get(ENTITY_ID)
+ assert state is not None
+
+ # Test when nominal setpoint is "10"
+ mock_setpoint = MagicMock()
+ mock_setpoint.value = 10
+ mock_bsblan.hot_water_state.return_value.nominal_setpoint = mock_setpoint
+
+ freezer.tick(timedelta(minutes=1))
+ async_fire_time_changed(hass)
+ await hass.async_block_till_done()
+
+ state = hass.states.get(ENTITY_ID)
+ assert state.attributes.get("temperature") == 10
+
+
+@pytest.mark.parametrize(
+ ("mode", "bsblan_mode"),
+ [
+ (STATE_ECO, "Eco"),
+ (STATE_OFF, "Off"),
+ (STATE_ON, "On"),
+ ],
+)
+async def test_set_operation_mode(
+ hass: HomeAssistant,
+ mock_bsblan: AsyncMock,
+ mock_config_entry: MockConfigEntry,
+ mode: str,
+ bsblan_mode: str,
+) -> None:
+ """Test setting operation mode."""
+ await setup_with_selected_platforms(
+ hass, mock_config_entry, [Platform.WATER_HEATER]
+ )
+
+ await hass.services.async_call(
+ domain=WATER_HEATER_DOMAIN,
+ service=SERVICE_SET_OPERATION_MODE,
+ service_data={
+ ATTR_ENTITY_ID: ENTITY_ID,
+ ATTR_OPERATION_MODE: mode,
+ },
+ blocking=True,
+ )
+
+ mock_bsblan.set_hot_water.assert_called_once_with(operating_mode=bsblan_mode)
+
+
+async def test_set_invalid_operation_mode(
+ hass: HomeAssistant,
+ mock_bsblan: AsyncMock,
+ mock_config_entry: MockConfigEntry,
+) -> None:
+ """Test setting invalid operation mode."""
+ await setup_with_selected_platforms(
+ hass, mock_config_entry, [Platform.WATER_HEATER]
+ )
+
+ with pytest.raises(
+ HomeAssistantError,
+ match=r"Operation mode invalid_mode is not valid for water_heater\.bsb_lan\. Valid operation modes are: eco, off, on",
+ ):
+ await hass.services.async_call(
+ domain=WATER_HEATER_DOMAIN,
+ service=SERVICE_SET_OPERATION_MODE,
+ service_data={
+ ATTR_ENTITY_ID: ENTITY_ID,
+ ATTR_OPERATION_MODE: "invalid_mode",
+ },
+ blocking=True,
+ )
+
+
+async def test_set_temperature(
+ hass: HomeAssistant,
+ mock_bsblan: AsyncMock,
+ mock_config_entry: MockConfigEntry,
+) -> None:
+ """Test setting temperature."""
+ await setup_with_selected_platforms(
+ hass, mock_config_entry, [Platform.WATER_HEATER]
+ )
+
+ await hass.services.async_call(
+ domain=WATER_HEATER_DOMAIN,
+ service=SERVICE_SET_TEMPERATURE,
+ service_data={
+ ATTR_ENTITY_ID: ENTITY_ID,
+ ATTR_TEMPERATURE: 50,
+ },
+ blocking=True,
+ )
+
+ mock_bsblan.set_hot_water.assert_called_once_with(nominal_setpoint=50)
+
+
+async def test_set_temperature_failure(
+ hass: HomeAssistant,
+ mock_bsblan: AsyncMock,
+ mock_config_entry: MockConfigEntry,
+) -> None:
+ """Test setting temperature with API failure."""
+ await setup_with_selected_platforms(
+ hass, mock_config_entry, [Platform.WATER_HEATER]
+ )
+
+ mock_bsblan.set_hot_water.side_effect = BSBLANError("Test error")
+
+ with pytest.raises(
+ HomeAssistantError, match="An error occurred while setting the temperature"
+ ):
+ await hass.services.async_call(
+ domain=WATER_HEATER_DOMAIN,
+ service=SERVICE_SET_TEMPERATURE,
+ service_data={
+ ATTR_ENTITY_ID: ENTITY_ID,
+ ATTR_TEMPERATURE: 50,
+ },
+ blocking=True,
+ )
+
+
+async def test_operation_mode_error(
+ hass: HomeAssistant,
+ mock_bsblan: AsyncMock,
+ mock_config_entry: MockConfigEntry,
+) -> None:
+ """Test operation mode setting with API failure."""
+ await setup_with_selected_platforms(
+ hass, mock_config_entry, [Platform.WATER_HEATER]
+ )
+
+ mock_bsblan.set_hot_water.side_effect = BSBLANError("Test error")
+
+ with pytest.raises(
+ HomeAssistantError, match="An error occurred while setting the operation mode"
+ ):
+ await hass.services.async_call(
+ domain=WATER_HEATER_DOMAIN,
+ service=SERVICE_SET_OPERATION_MODE,
+ service_data={
+ ATTR_ENTITY_ID: ENTITY_ID,
+ ATTR_OPERATION_MODE: STATE_ECO,
+ },
+ blocking=True,
+ )
diff --git a/tests/components/calendar/test_init.py b/tests/components/calendar/test_init.py
index 4ad5e11b8e4..36b102b933a 100644
--- a/tests/components/calendar/test_init.py
+++ b/tests/components/calendar/test_init.py
@@ -14,7 +14,8 @@ import voluptuous as vol
from homeassistant.components.calendar import DOMAIN, SERVICE_GET_EVENTS
from homeassistant.core import HomeAssistant
-from homeassistant.exceptions import HomeAssistantError
+from homeassistant.exceptions import HomeAssistantError, ServiceNotSupported
+from homeassistant.setup import async_setup_component
import homeassistant.util.dt as dt_util
from .conftest import MockCalendarEntity, MockConfigEntry
@@ -214,8 +215,12 @@ async def test_unsupported_websocket(
async def test_unsupported_create_event_service(hass: HomeAssistant) -> None:
"""Test unsupported service call."""
-
- with pytest.raises(HomeAssistantError, match="does not support this service"):
+ await async_setup_component(hass, "homeassistant", {})
+ with pytest.raises(
+ ServiceNotSupported,
+ match="Entity calendar.calendar_1 does not "
+ "support action calendar.create_event",
+ ):
await hass.services.async_call(
DOMAIN,
"create_event",
diff --git a/tests/components/cambridge_audio/__init__.py b/tests/components/cambridge_audio/__init__.py
index f6b5f48d39d..4e11a728f41 100644
--- a/tests/components/cambridge_audio/__init__.py
+++ b/tests/components/cambridge_audio/__init__.py
@@ -1,5 +1,9 @@
"""Tests for the Cambridge Audio integration."""
+from unittest.mock import AsyncMock
+
+from aiostreammagic.models import CallbackType
+
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
@@ -11,3 +15,11 @@ async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry)
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
+
+
+async def mock_state_update(
+ client: AsyncMock, callback_type: CallbackType = CallbackType.STATE
+) -> None:
+ """Trigger a callback in the media player."""
+ for callback in client.register_state_update_callbacks.call_args_list:
+ await callback[0][0](client, callback_type)
diff --git a/tests/components/cambridge_audio/fixtures/get_presets_list.json b/tests/components/cambridge_audio/fixtures/get_presets_list.json
index 87d49e9fd30..6443b7dfbcf 100644
--- a/tests/components/cambridge_audio/fixtures/get_presets_list.json
+++ b/tests/components/cambridge_audio/fixtures/get_presets_list.json
@@ -28,7 +28,7 @@
"name": "Unknown Preset Type",
"type": "Unknown",
"class": "stream.unknown",
- "state": "OK"
+ "state": "UNAVAILABLE"
}
]
}
diff --git a/tests/components/cambridge_audio/snapshots/test_diagnostics.ambr b/tests/components/cambridge_audio/snapshots/test_diagnostics.ambr
index 1ba9c4093f6..8de3ccea746 100644
--- a/tests/components/cambridge_audio/snapshots/test_diagnostics.ambr
+++ b/tests/components/cambridge_audio/snapshots/test_diagnostics.ambr
@@ -78,7 +78,7 @@
'name': 'Unknown Preset Type',
'preset_class': 'stream.unknown',
'preset_id': 3,
- 'state': 'OK',
+ 'state': 'UNAVAILABLE',
'type': 'Unknown',
}),
]),
diff --git a/tests/components/cambridge_audio/snapshots/test_media_browser.ambr b/tests/components/cambridge_audio/snapshots/test_media_browser.ambr
new file mode 100644
index 00000000000..180d5ed1bb0
--- /dev/null
+++ b/tests/components/cambridge_audio/snapshots/test_media_browser.ambr
@@ -0,0 +1,39 @@
+# serializer version: 1
+# name: test_browse_media_root
+ list([
+ dict({
+ 'can_expand': True,
+ 'can_play': False,
+ 'children_media_class': None,
+ 'media_class': 'directory',
+ 'media_content_id': '',
+ 'media_content_type': 'presets',
+ 'thumbnail': 'https://brands.home-assistant.io/_/cambridge_audio/logo.png',
+ 'title': 'Presets',
+ }),
+ ])
+# ---
+# name: test_browse_presets
+ list([
+ dict({
+ 'can_expand': False,
+ 'can_play': True,
+ 'children_media_class': None,
+ 'media_class': 'music',
+ 'media_content_id': '1',
+ 'media_content_type': 'preset',
+ 'thumbnail': 'https://static.airable.io/43/68/432868.png',
+ 'title': 'Chicago House Radio',
+ }),
+ dict({
+ 'can_expand': False,
+ 'can_play': True,
+ 'children_media_class': None,
+ 'media_class': 'music',
+ 'media_content_id': '2',
+ 'media_content_type': 'preset',
+ 'thumbnail': 'https://i.scdn.co/image/ab67616d0000b27325a5a1ed28871e8e53e62d59',
+ 'title': 'Spotify: Good & Evil',
+ }),
+ ])
+# ---
diff --git a/tests/components/cambridge_audio/test_config_flow.py b/tests/components/cambridge_audio/test_config_flow.py
index 9a2d077b8f8..8d01db6e015 100644
--- a/tests/components/cambridge_audio/test_config_flow.py
+++ b/tests/components/cambridge_audio/test_config_flow.py
@@ -7,7 +7,7 @@ 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
+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
@@ -192,3 +192,55 @@ async def test_zeroconf_duplicate(
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
+
+
+async def _start_reconfigure_flow(
+ hass: HomeAssistant, mock_config_entry: MockConfigEntry
+) -> ConfigFlowResult:
+ """Initialize a reconfigure flow."""
+ mock_config_entry.add_to_hass(hass)
+
+ reconfigure_result = await mock_config_entry.start_reconfigure_flow(hass)
+
+ assert reconfigure_result["type"] is FlowResultType.FORM
+ assert reconfigure_result["step_id"] == "reconfigure"
+
+ return await hass.config_entries.flow.async_configure(
+ reconfigure_result["flow_id"],
+ {CONF_HOST: "192.168.20.219"},
+ )
+
+
+async def test_reconfigure_flow(
+ hass: HomeAssistant,
+ mock_stream_magic_client: AsyncMock,
+ mock_setup_entry: AsyncMock,
+ mock_config_entry: MockConfigEntry,
+) -> None:
+ """Test reconfigure flow."""
+
+ result = await _start_reconfigure_flow(hass, mock_config_entry)
+
+ assert result["type"] is FlowResultType.ABORT
+ assert result["reason"] == "reconfigure_successful"
+
+ entry = hass.config_entries.async_get_entry(mock_config_entry.entry_id)
+ assert entry
+ assert entry.data == {
+ CONF_HOST: "192.168.20.219",
+ }
+
+
+async def test_reconfigure_unique_id_mismatch(
+ hass: HomeAssistant,
+ mock_stream_magic_client: AsyncMock,
+ mock_setup_entry: AsyncMock,
+ mock_config_entry: MockConfigEntry,
+) -> None:
+ """Ensure reconfigure flow aborts when the bride changes."""
+ mock_stream_magic_client.info.unit_id = "different_udn"
+
+ result = await _start_reconfigure_flow(hass, mock_config_entry)
+
+ assert result["type"] is FlowResultType.ABORT
+ assert result["reason"] == "wrong_device"
diff --git a/tests/components/cambridge_audio/test_init.py b/tests/components/cambridge_audio/test_init.py
index 4a8c1b668e2..a058f7c8b6c 100644
--- a/tests/components/cambridge_audio/test_init.py
+++ b/tests/components/cambridge_audio/test_init.py
@@ -1,8 +1,10 @@
"""Tests for the Cambridge Audio integration."""
-from unittest.mock import AsyncMock
+from unittest.mock import AsyncMock, Mock
from aiostreammagic import StreamMagicError
+from aiostreammagic.models import CallbackType
+import pytest
from syrupy import SnapshotAssertion
from homeassistant.components.cambridge_audio.const import DOMAIN
@@ -10,7 +12,7 @@ from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
-from . import setup_integration
+from . import mock_state_update, setup_integration
from tests.common import MockConfigEntry
@@ -43,3 +45,23 @@ async def test_device_info(
)
assert device_entry is not None
assert device_entry == snapshot
+
+
+async def test_disconnect_reconnect_log(
+ hass: HomeAssistant,
+ snapshot: SnapshotAssertion,
+ mock_stream_magic_client: AsyncMock,
+ mock_config_entry: MockConfigEntry,
+ device_registry: dr.DeviceRegistry,
+ caplog: pytest.LogCaptureFixture,
+) -> None:
+ """Test device registry integration."""
+ await setup_integration(hass, mock_config_entry)
+
+ mock_stream_magic_client.is_connected = Mock(return_value=False)
+ await mock_state_update(mock_stream_magic_client, CallbackType.CONNECTION)
+ assert "Disconnected from device at 192.168.20.218" in caplog.text
+
+ mock_stream_magic_client.is_connected = Mock(return_value=True)
+ await mock_state_update(mock_stream_magic_client, CallbackType.CONNECTION)
+ assert "Reconnected to device at 192.168.20.218" in caplog.text
diff --git a/tests/components/cambridge_audio/test_media_browser.py b/tests/components/cambridge_audio/test_media_browser.py
new file mode 100644
index 00000000000..da72cfab534
--- /dev/null
+++ b/tests/components/cambridge_audio/test_media_browser.py
@@ -0,0 +1,61 @@
+"""Tests for the Cambridge Audio media browser."""
+
+from unittest.mock import AsyncMock
+
+from syrupy import SnapshotAssertion
+
+from homeassistant.core import HomeAssistant
+
+from . import setup_integration
+from .const import ENTITY_ID
+
+from tests.common import MockConfigEntry
+from tests.typing import WebSocketGenerator
+
+
+async def test_browse_media_root(
+ hass: HomeAssistant,
+ mock_stream_magic_client: AsyncMock,
+ mock_config_entry: MockConfigEntry,
+ hass_ws_client: WebSocketGenerator,
+ snapshot: SnapshotAssertion,
+) -> None:
+ """Test the root browse page."""
+ await setup_integration(hass, mock_config_entry)
+
+ client = await hass_ws_client()
+ await client.send_json(
+ {
+ "id": 1,
+ "type": "media_player/browse_media",
+ "entity_id": ENTITY_ID,
+ }
+ )
+ response = await client.receive_json()
+ assert response["success"]
+ assert response["result"]["children"] == snapshot
+
+
+async def test_browse_presets(
+ hass: HomeAssistant,
+ mock_stream_magic_client: AsyncMock,
+ mock_config_entry: MockConfigEntry,
+ hass_ws_client: WebSocketGenerator,
+ snapshot: SnapshotAssertion,
+) -> None:
+ """Test the presets browse page."""
+ await setup_integration(hass, mock_config_entry)
+
+ client = await hass_ws_client()
+ await client.send_json(
+ {
+ "id": 1,
+ "type": "media_player/browse_media",
+ "entity_id": ENTITY_ID,
+ "media_content_type": "presets",
+ "media_content_id": "",
+ }
+ )
+ response = await client.receive_json()
+ assert response["success"]
+ assert response["result"]["children"] == snapshot
diff --git a/tests/components/cambridge_audio/test_media_player.py b/tests/components/cambridge_audio/test_media_player.py
index b857e61c235..bb2ccd1aec4 100644
--- a/tests/components/cambridge_audio/test_media_player.py
+++ b/tests/components/cambridge_audio/test_media_player.py
@@ -7,7 +7,6 @@ from aiostreammagic import (
ShuffleMode,
TransportControl,
)
-from aiostreammagic.models import CallbackType
import pytest
from homeassistant.components.media_player import (
@@ -49,18 +48,12 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
-from . import setup_integration
+from . import mock_state_update, setup_integration
from .const import ENTITY_ID
from tests.common import MockConfigEntry
-async def mock_state_update(client: AsyncMock) -> None:
- """Trigger a callback in the media player."""
- for callback in client.register_state_update_callbacks.call_args_list:
- await callback[0][0](client, CallbackType.STATE)
-
-
async def test_entity_supported_features(
hass: HomeAssistant,
mock_stream_magic_client: AsyncMock,
diff --git a/tests/components/camera/common.py b/tests/components/camera/common.py
index 569756c2640..19ac2cc168b 100644
--- a/tests/components/camera/common.py
+++ b/tests/components/camera/common.py
@@ -6,7 +6,7 @@ components. Instead call the service directly.
from unittest.mock import Mock
-from webrtc_models import RTCIceCandidate
+from webrtc_models import RTCIceCandidateInit
from homeassistant.components.camera import (
Camera,
@@ -66,7 +66,7 @@ class SomeTestProvider(CameraWebRTCProvider):
send_message(WebRTCAnswer(answer="answer"))
async def async_on_webrtc_candidate(
- self, session_id: str, candidate: RTCIceCandidate
+ self, session_id: str, candidate: RTCIceCandidateInit
) -> None:
"""Handle the WebRTC candidate."""
diff --git a/tests/components/camera/conftest.py b/tests/components/camera/conftest.py
index f0c418711c7..b529ee3e9b9 100644
--- a/tests/components/camera/conftest.py
+++ b/tests/components/camera/conftest.py
@@ -4,7 +4,7 @@ from collections.abc import AsyncGenerator, Generator
from unittest.mock import AsyncMock, Mock, PropertyMock, patch
import pytest
-from webrtc_models import RTCIceCandidate
+from webrtc_models import RTCIceCandidateInit
from homeassistant.components import camera
from homeassistant.components.camera.const import StreamType
@@ -62,32 +62,17 @@ async def mock_camera_fixture(hass: HomeAssistant) -> AsyncGenerator[None]:
def mock_camera_hls_fixture(mock_camera: None) -> Generator[None]:
"""Initialize a demo camera platform with HLS."""
with patch(
- "homeassistant.components.camera.Camera.frontend_stream_type",
- new_callable=PropertyMock(return_value=StreamType.HLS),
- ):
- yield
-
-
-@pytest.fixture
-async def mock_camera_webrtc_frontendtype_only(
- hass: HomeAssistant,
-) -> AsyncGenerator[None]:
- """Initialize a demo camera platform with WebRTC."""
- assert await async_setup_component(
- hass, "camera", {camera.DOMAIN: {"platform": "demo"}}
- )
- await hass.async_block_till_done()
-
- with patch(
- "homeassistant.components.camera.Camera.frontend_stream_type",
- new_callable=PropertyMock(return_value=StreamType.WEB_RTC),
+ "homeassistant.components.camera.Camera.camera_capabilities",
+ new_callable=PropertyMock(
+ return_value=camera.CameraCapabilities({StreamType.HLS})
+ ),
):
yield
@pytest.fixture
async def mock_camera_webrtc(
- mock_camera_webrtc_frontendtype_only: None,
+ mock_camera: None,
) -> AsyncGenerator[None]:
"""Initialize a demo camera platform with WebRTC."""
@@ -96,9 +81,17 @@ async def mock_camera_webrtc(
) -> None:
send_message(WebRTCAnswer(WEBRTC_ANSWER))
- with patch(
- "homeassistant.components.camera.Camera.async_handle_async_webrtc_offer",
- side_effect=async_handle_async_webrtc_offer,
+ with (
+ patch(
+ "homeassistant.components.camera.Camera.async_handle_async_webrtc_offer",
+ side_effect=async_handle_async_webrtc_offer,
+ ),
+ patch(
+ "homeassistant.components.camera.Camera.camera_capabilities",
+ new_callable=PropertyMock(
+ return_value=camera.CameraCapabilities({StreamType.WEB_RTC})
+ ),
+ ),
):
yield
@@ -168,7 +161,6 @@ async def mock_test_webrtc_cameras(hass: HomeAssistant) -> None:
_attr_supported_features: camera.CameraEntityFeature = (
camera.CameraEntityFeature.STREAM
)
- _attr_frontend_stream_type: camera.StreamType = camera.StreamType.WEB_RTC
async def stream_source(self) -> str | None:
return STREAM_SOURCE
@@ -192,7 +184,7 @@ async def mock_test_webrtc_cameras(hass: HomeAssistant) -> None:
send_message(WebRTCAnswer(WEBRTC_ANSWER))
async def async_on_webrtc_candidate(
- self, session_id: str, candidate: RTCIceCandidate
+ self, session_id: str, candidate: RTCIceCandidateInit
) -> None:
"""Handle a WebRTC candidate."""
# Do nothing
diff --git a/tests/components/camera/test_init.py b/tests/components/camera/test_init.py
index 32024694b7e..32520fcad23 100644
--- a/tests/components/camera/test_init.py
+++ b/tests/components/camera/test_init.py
@@ -7,7 +7,7 @@ from unittest.mock import ANY, AsyncMock, Mock, PropertyMock, mock_open, patch
import pytest
from syrupy.assertion import SnapshotAssertion
-from webrtc_models import RTCIceCandidate
+from webrtc_models import RTCIceCandidateInit
from homeassistant.components import camera
from homeassistant.components.camera import (
@@ -27,6 +27,7 @@ from homeassistant.components.camera.helper import get_camera_from_entity_id
from homeassistant.components.websocket_api import TYPE_RESULT
from homeassistant.const import (
ATTR_ENTITY_ID,
+ CONF_PLATFORM,
EVENT_HOMEASSISTANT_STARTED,
STATE_UNAVAILABLE,
)
@@ -801,32 +802,13 @@ async def test_use_stream_for_stills(
@pytest.mark.parametrize(
"module",
- [camera, camera.const],
+ [camera],
)
def test_all(module: ModuleType) -> None:
"""Test module.__all__ is correctly set."""
help_test_all(module)
-@pytest.mark.parametrize(
- "enum",
- list(camera.const.StreamType),
-)
-@pytest.mark.parametrize(
- "module",
- [camera, camera.const],
-)
-def test_deprecated_stream_type_constants(
- caplog: pytest.LogCaptureFixture,
- enum: camera.const.StreamType,
- module: ModuleType,
-) -> None:
- """Test deprecated stream type constants."""
- import_and_test_deprecated_constant_enum(
- caplog, module, enum, "STREAM_TYPE_", "2025.1"
- )
-
-
@pytest.mark.parametrize(
"enum",
list(camera.const.CameraState),
@@ -844,20 +826,6 @@ def test_deprecated_state_constants(
import_and_test_deprecated_constant_enum(caplog, module, enum, "STATE_", "2025.10")
-@pytest.mark.parametrize(
- "entity_feature",
- list(camera.CameraEntityFeature),
-)
-def test_deprecated_support_constants(
- caplog: pytest.LogCaptureFixture,
- entity_feature: camera.CameraEntityFeature,
-) -> None:
- """Test deprecated support constants."""
- import_and_test_deprecated_constant_enum(
- caplog, camera, entity_feature, "SUPPORT_", "2025.1"
- )
-
-
def test_deprecated_supported_features_ints(caplog: pytest.LogCaptureFixture) -> None:
"""Test deprecated supported features ints."""
@@ -954,7 +922,7 @@ async def _test_capabilities(
send_message(WebRTCAnswer("answer"))
async def async_on_webrtc_candidate(
- self, session_id: str, candidate: RTCIceCandidate
+ self, session_id: str, candidate: RTCIceCandidateInit
) -> None:
"""Handle the WebRTC candidate."""
@@ -1054,3 +1022,27 @@ async def test_camera_capabilities_changing_native_support(
await hass.async_block_till_done()
await _test_capabilities(hass, hass_ws_client, cam.entity_id, set(), set())
+
+
+@pytest.mark.usefixtures("enable_custom_integrations")
+async def test_deprecated_frontend_stream_type_logs(
+ hass: HomeAssistant,
+ caplog: pytest.LogCaptureFixture,
+) -> None:
+ """Test using (_attr_)frontend_stream_type will log."""
+ assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}})
+ await hass.async_block_till_done()
+
+ for entity_id in (
+ "camera.property_frontend_stream_type",
+ "camera.attr_frontend_stream_type",
+ ):
+ camera_obj = get_camera_from_entity_id(hass, entity_id)
+ assert camera_obj.frontend_stream_type == StreamType.WEB_RTC
+
+ assert (
+ "Detected that custom integration 'test' is overwriting the 'frontend_stream_type' property in the PropertyFrontendStreamTypeCamera class, which is deprecated and will be removed in Home Assistant 2025.6,"
+ ) in caplog.text
+ assert (
+ "Detected that custom integration 'test' is setting the '_attr_frontend_stream_type' attribute in the AttrFrontendStreamTypeCamera class, which is deprecated and will be removed in Home Assistant 2025.6,"
+ ) in caplog.text
diff --git a/tests/components/camera/test_media_source.py b/tests/components/camera/test_media_source.py
index 85f876d4e81..bd92010d242 100644
--- a/tests/components/camera/test_media_source.py
+++ b/tests/components/camera/test_media_source.py
@@ -5,6 +5,7 @@ from unittest.mock import PropertyMock, patch
import pytest
from homeassistant.components import media_source
+from homeassistant.components.camera import CameraCapabilities
from homeassistant.components.camera.const import StreamType
from homeassistant.components.stream import FORMAT_CONTENT_TYPE
from homeassistant.core import HomeAssistant
@@ -91,7 +92,7 @@ async def test_browsing_webrtc(hass: HomeAssistant) -> None:
assert item.children[0].media_content_type == FORMAT_CONTENT_TYPE["hls"]
-@pytest.mark.usefixtures("mock_camera_hls")
+@pytest.mark.usefixtures("mock_camera")
async def test_resolving(hass: HomeAssistant) -> None:
"""Test resolving."""
# Adding stream enables HLS camera
@@ -109,7 +110,7 @@ async def test_resolving(hass: HomeAssistant) -> None:
assert item.mime_type == FORMAT_CONTENT_TYPE["hls"]
-@pytest.mark.usefixtures("mock_camera_hls")
+@pytest.mark.usefixtures("mock_camera")
async def test_resolving_errors(hass: HomeAssistant) -> None:
"""Test resolving."""
@@ -130,8 +131,10 @@ async def test_resolving_errors(hass: HomeAssistant) -> None:
with (
pytest.raises(media_source.Unresolvable) as exc_info,
patch(
- "homeassistant.components.camera.Camera.frontend_stream_type",
- new_callable=PropertyMock(return_value=StreamType.WEB_RTC),
+ "homeassistant.components.camera.Camera.camera_capabilities",
+ new_callable=PropertyMock(
+ return_value=CameraCapabilities({StreamType.WEB_RTC})
+ ),
),
):
await media_source.async_resolve_media(
diff --git a/tests/components/camera/test_webrtc.py b/tests/components/camera/test_webrtc.py
index ba5cf35c52f..a7c6d889409 100644
--- a/tests/components/camera/test_webrtc.py
+++ b/tests/components/camera/test_webrtc.py
@@ -6,7 +6,7 @@ from typing import Any
from unittest.mock import AsyncMock, Mock, patch
import pytest
-from webrtc_models import RTCIceCandidate, RTCIceServer
+from webrtc_models import RTCIceCandidate, RTCIceCandidateInit, RTCIceServer
from homeassistant.components.camera import (
DATA_ICE_SERVERS,
@@ -65,7 +65,6 @@ class MockCamera(Camera):
_attr_name = "Test"
_attr_supported_features: CameraEntityFeature = CameraEntityFeature.STREAM
- _attr_frontend_stream_type: StreamType = StreamType.WEB_RTC
def __init__(self) -> None:
"""Initialize the mock entity."""
@@ -139,42 +138,46 @@ async def init_test_integration(
return test_camera
-@pytest.mark.usefixtures("mock_camera", "mock_stream", "mock_stream_source")
+@pytest.mark.usefixtures("mock_camera", "mock_stream_source")
async def test_async_register_webrtc_provider(
hass: HomeAssistant,
) -> None:
"""Test registering a WebRTC provider."""
- await async_setup_component(hass, "camera", {})
-
camera = get_camera_from_entity_id(hass, "camera.demo_camera")
- assert camera.frontend_stream_type is StreamType.HLS
+ assert camera.camera_capabilities.frontend_stream_types == {StreamType.HLS}
provider = SomeTestProvider()
unregister = async_register_webrtc_provider(hass, provider)
await hass.async_block_till_done()
- assert camera.frontend_stream_type is StreamType.WEB_RTC
+ assert camera.camera_capabilities.frontend_stream_types == {
+ StreamType.HLS,
+ StreamType.WEB_RTC,
+ }
# Mark stream as unsupported
provider._is_supported = False
# Manually refresh the provider
await camera.async_refresh_providers()
- assert camera.frontend_stream_type is StreamType.HLS
+ assert camera.camera_capabilities.frontend_stream_types == {StreamType.HLS}
# Mark stream as supported
provider._is_supported = True
# Manually refresh the provider
await camera.async_refresh_providers()
- assert camera.frontend_stream_type is StreamType.WEB_RTC
+ assert camera.camera_capabilities.frontend_stream_types == {
+ StreamType.HLS,
+ StreamType.WEB_RTC,
+ }
unregister()
await hass.async_block_till_done()
- assert camera.frontend_stream_type is StreamType.HLS
+ assert camera.camera_capabilities.frontend_stream_types == {StreamType.HLS}
-@pytest.mark.usefixtures("mock_camera", "mock_stream", "mock_stream_source")
+@pytest.mark.usefixtures("mock_camera", "mock_stream_source")
async def test_async_register_webrtc_provider_twice(
hass: HomeAssistant,
register_test_provider: SomeTestProvider,
@@ -192,13 +195,11 @@ async def test_async_register_webrtc_provider_camera_not_loaded(
async_register_webrtc_provider(hass, SomeTestProvider())
-@pytest.mark.usefixtures("mock_camera", "mock_stream", "mock_stream_source")
+@pytest.mark.usefixtures("mock_test_webrtc_cameras")
async def test_async_register_ice_server(
hass: HomeAssistant,
) -> None:
"""Test registering an ICE server."""
- await async_setup_component(hass, "camera", {})
-
# Clear any existing ICE servers
hass.data[DATA_ICE_SERVERS].clear()
@@ -216,7 +217,7 @@ async def test_async_register_ice_server(
unregister = async_register_ice_servers(hass, get_ice_servers)
assert not called
- camera = get_camera_from_entity_id(hass, "camera.demo_camera")
+ camera = get_camera_from_entity_id(hass, "camera.async")
config = camera.async_get_webrtc_client_configuration()
assert config.configuration.ice_servers == [
@@ -277,7 +278,7 @@ async def test_async_register_ice_server(
assert config.configuration.ice_servers == []
-@pytest.mark.usefixtures("mock_camera_webrtc")
+@pytest.mark.usefixtures("mock_test_webrtc_cameras")
async def test_ws_get_client_config(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None:
@@ -286,7 +287,7 @@ async def test_ws_get_client_config(
client = await hass_ws_client(hass)
await client.send_json_auto_id(
- {"type": "camera/webrtc/get_client_config", "entity_id": "camera.demo_camera"}
+ {"type": "camera/webrtc/get_client_config", "entity_id": "camera.async"}
)
msg = await client.receive_json()
@@ -320,7 +321,7 @@ async def test_ws_get_client_config(
async_register_ice_servers(hass, get_ice_server)
await client.send_json_auto_id(
- {"type": "camera/webrtc/get_client_config", "entity_id": "camera.demo_camera"}
+ {"type": "camera/webrtc/get_client_config", "entity_id": "camera.async"}
)
msg = await client.receive_json()
@@ -370,7 +371,7 @@ async def test_ws_get_client_config_sync_offer(
}
-@pytest.mark.usefixtures("mock_camera_webrtc")
+@pytest.mark.usefixtures("mock_test_webrtc_cameras")
async def test_ws_get_client_config_custom_config(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None:
@@ -384,7 +385,7 @@ async def test_ws_get_client_config_custom_config(
client = await hass_ws_client(hass)
await client.send_json_auto_id(
- {"type": "camera/webrtc/get_client_config", "entity_id": "camera.demo_camera"}
+ {"type": "camera/webrtc/get_client_config", "entity_id": "camera.async"}
)
msg = await client.receive_json()
@@ -397,7 +398,7 @@ async def test_ws_get_client_config_custom_config(
}
-@pytest.mark.usefixtures("mock_camera_hls")
+@pytest.mark.usefixtures("mock_camera")
async def test_ws_get_client_config_no_rtc_camera(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None:
@@ -415,7 +416,7 @@ async def test_ws_get_client_config_no_rtc_camera(
assert not msg["success"]
assert msg["error"] == {
"code": "webrtc_get_client_config_failed",
- "message": "Camera does not support WebRTC, frontend_stream_type=hls",
+ "message": "Camera does not support WebRTC, frontend_stream_types={}",
}
@@ -427,15 +428,21 @@ async def provide_webrtc_answer(stream_source: str, offer: str, stream_id: str)
@pytest.fixture(name="mock_rtsp_to_webrtc")
-def mock_rtsp_to_webrtc_fixture(hass: HomeAssistant) -> Generator[Mock]:
+def mock_rtsp_to_webrtc_fixture(
+ hass: HomeAssistant, caplog: pytest.LogCaptureFixture
+) -> Generator[Mock]:
"""Fixture that registers a mock rtsp to webrtc provider."""
mock_provider = Mock(side_effect=provide_webrtc_answer)
unsub = async_register_rtsp_to_web_rtc_provider(hass, "mock_domain", mock_provider)
+ assert (
+ "async_register_rtsp_to_web_rtc_provider is a deprecated function which will"
+ " be removed in HA Core 2025.6. Use async_register_webrtc_provider instead"
+ ) in caplog.text
yield mock_provider
unsub()
-@pytest.mark.usefixtures("mock_camera_webrtc")
+@pytest.mark.usefixtures("mock_test_webrtc_cameras")
async def test_websocket_webrtc_offer(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None:
@@ -444,7 +451,7 @@ async def test_websocket_webrtc_offer(
await client.send_json_auto_id(
{
"type": "camera/webrtc/offer",
- "entity_id": "camera.demo_camera",
+ "entity_id": "camera.async",
"offer": WEBRTC_OFFER,
}
)
@@ -479,12 +486,34 @@ async def test_websocket_webrtc_offer(
assert msg["success"]
+@pytest.mark.filterwarnings(
+ "ignore:Using RTCIceCandidate is deprecated. Use RTCIceCandidateInit instead"
+)
+@pytest.mark.usefixtures("mock_stream_source", "mock_camera")
+async def test_websocket_webrtc_offer_webrtc_provider_deprecated(
+ hass: HomeAssistant,
+ hass_ws_client: WebSocketGenerator,
+ register_test_provider: SomeTestProvider,
+) -> None:
+ """Test initiating a WebRTC stream with a webrtc provider with the deprecated class."""
+ await _test_websocket_webrtc_offer_webrtc_provider(
+ hass,
+ hass_ws_client,
+ register_test_provider,
+ WebRTCCandidate(RTCIceCandidate("candidate")),
+ {"type": "candidate", "candidate": {"candidate": "candidate"}},
+ )
+
+
@pytest.mark.parametrize(
("message", "expected_frontend_message"),
[
(
- WebRTCCandidate(RTCIceCandidate("candidate")),
- {"type": "candidate", "candidate": "candidate"},
+ WebRTCCandidate(RTCIceCandidateInit("candidate")),
+ {
+ "type": "candidate",
+ "candidate": {"candidate": "candidate", "sdpMLineIndex": 0},
+ },
),
(
WebRTCError("webrtc_offer_failed", "error"),
@@ -501,6 +530,23 @@ async def test_websocket_webrtc_offer_webrtc_provider(
register_test_provider: SomeTestProvider,
message: WebRTCMessage,
expected_frontend_message: dict[str, Any],
+) -> None:
+ """Test initiating a WebRTC stream with a webrtc provider."""
+ await _test_websocket_webrtc_offer_webrtc_provider(
+ hass,
+ hass_ws_client,
+ register_test_provider,
+ message,
+ expected_frontend_message,
+ )
+
+
+async def _test_websocket_webrtc_offer_webrtc_provider(
+ hass: HomeAssistant,
+ hass_ws_client: WebSocketGenerator,
+ register_test_provider: SomeTestProvider,
+ message: WebRTCMessage,
+ expected_frontend_message: dict[str, Any],
) -> None:
"""Test initiating a WebRTC stream with a webrtc provider."""
client = await hass_ws_client(hass)
@@ -555,11 +601,11 @@ async def test_websocket_webrtc_offer_webrtc_provider(
mock_async_close_session.assert_called_once_with(session_id)
-@pytest.mark.usefixtures("mock_camera_webrtc")
async def test_websocket_webrtc_offer_invalid_entity(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None:
"""Test WebRTC with a camera entity that does not exist."""
+ await async_setup_component(hass, "camera", {})
client = await hass_ws_client(hass)
await client.send_json_auto_id(
{
@@ -578,7 +624,7 @@ async def test_websocket_webrtc_offer_invalid_entity(
}
-@pytest.mark.usefixtures("mock_camera_webrtc")
+@pytest.mark.usefixtures("mock_test_webrtc_cameras")
async def test_websocket_webrtc_offer_missing_offer(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None:
@@ -605,7 +651,6 @@ async def test_websocket_webrtc_offer_missing_offer(
(TimeoutError(), "Timeout handling WebRTC offer"),
],
)
-@pytest.mark.usefixtures("mock_camera_webrtc_frontendtype_only")
async def test_websocket_webrtc_offer_failure(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
@@ -647,24 +692,33 @@ async def test_websocket_webrtc_offer_failure(
}
+@pytest.mark.usefixtures("mock_test_webrtc_cameras")
async def test_websocket_webrtc_offer_sync(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
- init_test_integration: MockCamera,
+ caplog: pytest.LogCaptureFixture,
) -> None:
"""Test sync WebRTC stream offer."""
client = await hass_ws_client(hass)
- init_test_integration.set_sync_answer(WEBRTC_ANSWER)
await client.send_json_auto_id(
{
"type": "camera/webrtc/offer",
- "entity_id": "camera.test",
+ "entity_id": "camera.sync",
"offer": WEBRTC_OFFER,
}
)
response = await client.receive_json()
+ assert (
+ "tests.components.camera.conftest",
+ logging.WARNING,
+ (
+ "async_handle_web_rtc_offer was called from camera, this is a deprecated "
+ "function which will be removed in HA Core 2025.6. Use "
+ "async_handle_async_webrtc_offer instead"
+ ),
+ ) in caplog.record_tuples
assert response["type"] == TYPE_RESULT
assert response["success"]
subscription_id = response["id"]
@@ -746,7 +800,7 @@ async def test_websocket_webrtc_offer_invalid_stream_type(
assert not response["success"]
assert response["error"] == {
"code": "webrtc_offer_failed",
- "message": "Camera does not support WebRTC, frontend_stream_type=hls",
+ "message": "Camera does not support WebRTC, frontend_stream_types={}",
}
@@ -799,45 +853,6 @@ async def mock_hls_stream_source_fixture() -> AsyncGenerator[AsyncMock]:
yield mock_hls_stream_source
-@pytest.mark.usefixtures(
- "mock_camera",
- "mock_hls_stream_source", # Not an RTSP stream source
- "mock_camera_webrtc_frontendtype_only",
-)
-async def test_unsupported_rtsp_to_webrtc_stream_type(
- hass: HomeAssistant, hass_ws_client: WebSocketGenerator
-) -> None:
- """Test rtsp-to-webrtc is not registered for non-RTSP streams."""
- client = await hass_ws_client(hass)
- await client.send_json_auto_id(
- {
- "type": "camera/webrtc/offer",
- "entity_id": "camera.demo_camera",
- "offer": WEBRTC_OFFER,
- }
- )
- response = await client.receive_json()
- assert response["type"] == TYPE_RESULT
- assert response["success"]
- subscription_id = response["id"]
-
- # Session id
- response = await client.receive_json()
- assert response["id"] == subscription_id
- assert response["type"] == "event"
- assert response["event"]["type"] == "session"
-
- # Answer
- response = await client.receive_json()
- assert response["id"] == subscription_id
- assert response["type"] == "event"
- assert response["event"] == {
- "type": "error",
- "code": "webrtc_offer_failed",
- "message": "Camera does not support WebRTC",
- }
-
-
@pytest.mark.usefixtures("mock_camera", "mock_stream_source")
async def test_rtsp_to_webrtc_provider_unregistered(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
@@ -893,7 +908,7 @@ async def test_rtsp_to_webrtc_provider_unregistered(
assert not response["success"]
assert response["error"] == {
"code": "webrtc_offer_failed",
- "message": "Camera does not support WebRTC, frontend_stream_type=hls",
+ "message": "Camera does not support WebRTC, frontend_stream_types={}",
}
assert not mock_provider.called
@@ -949,34 +964,103 @@ async def test_rtsp_to_webrtc_offer_not_accepted(
unsub()
-@pytest.mark.usefixtures("mock_camera_webrtc")
+@pytest.mark.parametrize(
+ ("frontend_candidate", "expected_candidate"),
+ [
+ (
+ {"candidate": "candidate", "sdpMLineIndex": 0},
+ RTCIceCandidateInit("candidate"),
+ ),
+ (
+ {"candidate": "candidate", "sdpMLineIndex": 1},
+ RTCIceCandidateInit("candidate", sdp_m_line_index=1),
+ ),
+ (
+ {"candidate": "candidate", "sdpMid": "1"},
+ RTCIceCandidateInit("candidate", sdp_mid="1"),
+ ),
+ ],
+ ids=["candidate", "candidate-mline-index", "candidate-mid"],
+)
+@pytest.mark.usefixtures("mock_test_webrtc_cameras")
async def test_ws_webrtc_candidate(
- hass: HomeAssistant, hass_ws_client: WebSocketGenerator
+ hass: HomeAssistant,
+ hass_ws_client: WebSocketGenerator,
+ frontend_candidate: dict[str, Any],
+ expected_candidate: RTCIceCandidateInit,
) -> None:
"""Test ws webrtc candidate command."""
client = await hass_ws_client(hass)
session_id = "session_id"
- candidate = "candidate"
- with patch(
- "homeassistant.components.camera.Camera.async_on_webrtc_candidate"
+ with patch.object(
+ get_camera_from_entity_id(hass, "camera.async"), "async_on_webrtc_candidate"
) as mock_on_webrtc_candidate:
await client.send_json_auto_id(
{
"type": "camera/webrtc/candidate",
- "entity_id": "camera.demo_camera",
+ "entity_id": "camera.async",
"session_id": session_id,
- "candidate": candidate,
+ "candidate": frontend_candidate,
}
)
response = await client.receive_json()
assert response["type"] == TYPE_RESULT
assert response["success"]
- mock_on_webrtc_candidate.assert_called_once_with(
- session_id, RTCIceCandidate(candidate)
+ mock_on_webrtc_candidate.assert_called_once_with(session_id, expected_candidate)
+
+
+@pytest.mark.parametrize(
+ ("message", "expected_error_msg"),
+ [
+ (
+ {"sdpMLineIndex": 0},
+ (
+ 'Field "candidate" of type str is missing in RTCIceCandidateInit instance'
+ " for dictionary value @ data['candidate']. Got {'sdpMLineIndex': 0}"
+ ),
+ ),
+ (
+ {"candidate": "candidate", "sdpMLineIndex": -1},
+ (
+ "sdpMLineIndex must be greater than or equal to 0 for dictionary value @ "
+ "data['candidate']. Got {'candidate': 'candidate', 'sdpMLineIndex': -1}"
+ ),
+ ),
+ ],
+ ids=[
+ "candidate missing",
+ "spd_mline_index smaller than 0",
+ ],
+)
+@pytest.mark.usefixtures("mock_test_webrtc_cameras")
+async def test_ws_webrtc_candidate_invalid_candidate_message(
+ hass: HomeAssistant,
+ hass_ws_client: WebSocketGenerator,
+ message: dict,
+ expected_error_msg: str,
+) -> None:
+ """Test ws WebRTC candidate command for a camera with a different stream_type."""
+ client = await hass_ws_client(hass)
+ with patch("homeassistant.components.camera.Camera.async_on_webrtc_candidate"):
+ await client.send_json_auto_id(
+ {
+ "type": "camera/webrtc/candidate",
+ "entity_id": "camera.async",
+ "session_id": "session_id",
+ "candidate": message,
+ }
)
+ response = await client.receive_json()
+
+ assert response["type"] == TYPE_RESULT
+ assert not response["success"]
+ assert response["error"] == {
+ "code": "invalid_format",
+ "message": expected_error_msg,
+ }
-@pytest.mark.usefixtures("mock_camera_webrtc")
+@pytest.mark.usefixtures("mock_test_webrtc_cameras")
async def test_ws_webrtc_candidate_not_supported(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None:
@@ -985,9 +1069,9 @@ async def test_ws_webrtc_candidate_not_supported(
await client.send_json_auto_id(
{
"type": "camera/webrtc/candidate",
- "entity_id": "camera.demo_camera",
+ "entity_id": "camera.sync",
"session_id": "session_id",
- "candidate": "candidate",
+ "candidate": {"candidate": "candidate"},
}
)
response = await client.receive_json()
@@ -1017,29 +1101,29 @@ async def test_ws_webrtc_candidate_webrtc_provider(
"type": "camera/webrtc/candidate",
"entity_id": "camera.demo_camera",
"session_id": session_id,
- "candidate": candidate,
+ "candidate": {"candidate": candidate, "sdpMLineIndex": 1},
}
)
response = await client.receive_json()
assert response["type"] == TYPE_RESULT
assert response["success"]
mock_on_webrtc_candidate.assert_called_once_with(
- session_id, RTCIceCandidate(candidate)
+ session_id, RTCIceCandidateInit(candidate, sdp_m_line_index=1)
)
-@pytest.mark.usefixtures("mock_camera_webrtc")
async def test_ws_webrtc_candidate_invalid_entity(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None:
"""Test ws WebRTC candidate command with a camera entity that does not exist."""
+ await async_setup_component(hass, "camera", {})
client = await hass_ws_client(hass)
await client.send_json_auto_id(
{
"type": "camera/webrtc/candidate",
"entity_id": "camera.does_not_exist",
"session_id": "session_id",
- "candidate": "candidate",
+ "candidate": {"candidate": "candidate"},
}
)
response = await client.receive_json()
@@ -1052,7 +1136,7 @@ async def test_ws_webrtc_candidate_invalid_entity(
}
-@pytest.mark.usefixtures("mock_camera_webrtc")
+@pytest.mark.usefixtures("mock_test_webrtc_cameras")
async def test_ws_webrtc_canidate_missing_candidate(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None:
@@ -1061,7 +1145,7 @@ async def test_ws_webrtc_canidate_missing_candidate(
await client.send_json_auto_id(
{
"type": "camera/webrtc/candidate",
- "entity_id": "camera.demo_camera",
+ "entity_id": "camera.async",
"session_id": "session_id",
}
)
@@ -1083,7 +1167,7 @@ async def test_ws_webrtc_candidate_invalid_stream_type(
"type": "camera/webrtc/candidate",
"entity_id": "camera.demo_camera",
"session_id": "session_id",
- "candidate": "candidate",
+ "candidate": {"candidate": "candidate"},
}
)
response = await client.receive_json()
@@ -1092,7 +1176,7 @@ async def test_ws_webrtc_candidate_invalid_stream_type(
assert not response["success"]
assert response["error"] == {
"code": "webrtc_candidate_failed",
- "message": "Camera does not support WebRTC, frontend_stream_type=hls",
+ "message": "Camera does not support WebRTC, frontend_stream_types={}",
}
@@ -1126,7 +1210,7 @@ async def test_webrtc_provider_optional_interface(hass: HomeAssistant) -> None:
send_message(WebRTCAnswer(answer="answer"))
async def async_on_webrtc_candidate(
- self, session_id: str, candidate: RTCIceCandidate
+ self, session_id: str, candidate: RTCIceCandidateInit
) -> None:
"""Handle the WebRTC candidate."""
@@ -1136,7 +1220,9 @@ async def test_webrtc_provider_optional_interface(hass: HomeAssistant) -> None:
await provider.async_handle_async_webrtc_offer(
Mock(), "offer_sdp", "session_id", Mock()
)
- await provider.async_on_webrtc_candidate("session_id", RTCIceCandidate("candidate"))
+ await provider.async_on_webrtc_candidate(
+ "session_id", RTCIceCandidateInit("candidate")
+ )
provider.async_close_session("session_id")
diff --git a/tests/components/cert_expiry/test_config_flow.py b/tests/components/cert_expiry/test_config_flow.py
index 3fd696f5953..907071d8b1f 100644
--- a/tests/components/cert_expiry/test_config_flow.py
+++ b/tests/components/cert_expiry/test_config_flow.py
@@ -7,13 +7,12 @@ from unittest.mock import patch
import pytest
from homeassistant import config_entries
-from homeassistant.components.cert_expiry.const import DEFAULT_PORT, DOMAIN
-from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT
+from homeassistant.components.cert_expiry.const import DOMAIN
+from homeassistant.const import CONF_HOST, CONF_PORT
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from .const import HOST, PORT
-from .helpers import future_timestamp
from tests.common import MockConfigEntry
@@ -64,122 +63,6 @@ async def test_user_with_bad_cert(hass: HomeAssistant) -> None:
assert result["result"].unique_id == f"{HOST}:{PORT}"
-async def test_import_host_only(hass: HomeAssistant) -> None:
- """Test import with host only."""
- with (
- patch(
- "homeassistant.components.cert_expiry.config_flow.get_cert_expiry_timestamp"
- ),
- patch(
- "homeassistant.components.cert_expiry.coordinator.get_cert_expiry_timestamp",
- return_value=future_timestamp(1),
- ),
- ):
- result = await hass.config_entries.flow.async_init(
- DOMAIN,
- context={"source": config_entries.SOURCE_IMPORT},
- data={CONF_HOST: HOST},
- )
- await hass.async_block_till_done()
-
- assert result["type"] is FlowResultType.CREATE_ENTRY
- assert result["title"] == HOST
- assert result["data"][CONF_HOST] == HOST
- assert result["data"][CONF_PORT] == DEFAULT_PORT
- assert result["result"].unique_id == f"{HOST}:{DEFAULT_PORT}"
-
-
-async def test_import_host_and_port(hass: HomeAssistant) -> None:
- """Test import with host and port."""
- with (
- patch(
- "homeassistant.components.cert_expiry.config_flow.get_cert_expiry_timestamp"
- ),
- patch(
- "homeassistant.components.cert_expiry.coordinator.get_cert_expiry_timestamp",
- return_value=future_timestamp(1),
- ),
- ):
- result = await hass.config_entries.flow.async_init(
- DOMAIN,
- context={"source": config_entries.SOURCE_IMPORT},
- data={CONF_HOST: HOST, CONF_PORT: PORT},
- )
- await hass.async_block_till_done()
-
- assert result["type"] is FlowResultType.CREATE_ENTRY
- assert result["title"] == HOST
- assert result["data"][CONF_HOST] == HOST
- assert result["data"][CONF_PORT] == PORT
- assert result["result"].unique_id == f"{HOST}:{PORT}"
-
-
-async def test_import_non_default_port(hass: HomeAssistant) -> None:
- """Test import with host and non-default port."""
- with (
- patch(
- "homeassistant.components.cert_expiry.config_flow.get_cert_expiry_timestamp"
- ),
- patch(
- "homeassistant.components.cert_expiry.coordinator.get_cert_expiry_timestamp",
- return_value=future_timestamp(1),
- ),
- ):
- result = await hass.config_entries.flow.async_init(
- DOMAIN,
- context={"source": config_entries.SOURCE_IMPORT},
- data={CONF_HOST: HOST, CONF_PORT: 888},
- )
- await hass.async_block_till_done()
-
- assert result["type"] is FlowResultType.CREATE_ENTRY
- assert result["title"] == f"{HOST}:888"
- assert result["data"][CONF_HOST] == HOST
- assert result["data"][CONF_PORT] == 888
- assert result["result"].unique_id == f"{HOST}:888"
-
-
-async def test_import_with_name(hass: HomeAssistant) -> None:
- """Test import with name (deprecated)."""
- with (
- patch(
- "homeassistant.components.cert_expiry.config_flow.get_cert_expiry_timestamp"
- ),
- patch(
- "homeassistant.components.cert_expiry.coordinator.get_cert_expiry_timestamp",
- return_value=future_timestamp(1),
- ),
- ):
- result = await hass.config_entries.flow.async_init(
- DOMAIN,
- context={"source": config_entries.SOURCE_IMPORT},
- data={CONF_NAME: "legacy", CONF_HOST: HOST, CONF_PORT: PORT},
- )
- await hass.async_block_till_done()
-
- assert result["type"] is FlowResultType.CREATE_ENTRY
- assert result["title"] == HOST
- assert result["data"][CONF_HOST] == HOST
- assert result["data"][CONF_PORT] == PORT
- assert result["result"].unique_id == f"{HOST}:{PORT}"
-
-
-async def test_bad_import(hass: HomeAssistant) -> None:
- """Test import step."""
- with patch(
- "homeassistant.components.cert_expiry.helper.async_get_cert",
- side_effect=ConnectionRefusedError(),
- ):
- result = await hass.config_entries.flow.async_init(
- DOMAIN,
- context={"source": config_entries.SOURCE_IMPORT},
- data={CONF_HOST: HOST},
- )
-
- assert result["type"] is FlowResultType.ABORT
- assert result["reason"] == "import_failed"
-
-
async def test_abort_if_already_setup(hass: HomeAssistant) -> None:
"""Test we abort if the cert is already setup."""
MockConfigEntry(
@@ -188,14 +71,6 @@ async def test_abort_if_already_setup(hass: HomeAssistant) -> None:
unique_id=f"{HOST}:{PORT}",
).add_to_hass(hass)
- result = await hass.config_entries.flow.async_init(
- DOMAIN,
- context={"source": config_entries.SOURCE_IMPORT},
- data={CONF_HOST: HOST, CONF_PORT: PORT},
- )
- assert result["type"] is FlowResultType.ABORT
- assert result["reason"] == "already_configured"
-
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_USER},
diff --git a/tests/components/cert_expiry/test_init.py b/tests/components/cert_expiry/test_init.py
index e2c333cc6f3..5ba63ad1af1 100644
--- a/tests/components/cert_expiry/test_init.py
+++ b/tests/components/cert_expiry/test_init.py
@@ -1,59 +1,24 @@
"""Tests for Cert Expiry setup."""
-from datetime import timedelta
from unittest.mock import patch
from freezegun import freeze_time
from homeassistant.components.cert_expiry.const import DOMAIN
-from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import (
CONF_HOST,
CONF_PORT,
- EVENT_HOMEASSISTANT_START,
EVENT_HOMEASSISTANT_STARTED,
STATE_UNAVAILABLE,
)
from homeassistant.core import CoreState, HomeAssistant
from homeassistant.setup import async_setup_component
-import homeassistant.util.dt as dt_util
from .const import HOST, PORT
from .helpers import future_timestamp, static_datetime
-from tests.common import MockConfigEntry, async_fire_time_changed
-
-
-async def test_setup_with_config(hass: HomeAssistant) -> None:
- """Test setup component with config."""
- assert hass.state is CoreState.running
-
- config = {
- SENSOR_DOMAIN: [
- {"platform": DOMAIN, CONF_HOST: HOST, CONF_PORT: PORT},
- {"platform": DOMAIN, CONF_HOST: HOST, CONF_PORT: 888},
- ],
- }
-
- with (
- patch(
- "homeassistant.components.cert_expiry.config_flow.get_cert_expiry_timestamp"
- ),
- patch(
- "homeassistant.components.cert_expiry.coordinator.get_cert_expiry_timestamp",
- return_value=future_timestamp(1),
- ),
- ):
- assert await async_setup_component(hass, SENSOR_DOMAIN, config) is True
- await hass.async_block_till_done()
- hass.bus.async_fire(EVENT_HOMEASSISTANT_START)
- await hass.async_block_till_done()
- next_update = dt_util.utcnow() + timedelta(seconds=20)
- async_fire_time_changed(hass, next_update)
- await hass.async_block_till_done(wait_background_tasks=True)
-
- assert len(hass.config_entries.async_entries(DOMAIN)) == 2
+from tests.common import MockConfigEntry
async def test_update_unique_id(hass: HomeAssistant) -> None:
diff --git a/tests/components/cert_expiry/test_sensors.py b/tests/components/cert_expiry/test_sensor.py
similarity index 100%
rename from tests/components/cert_expiry/test_sensors.py
rename to tests/components/cert_expiry/test_sensor.py
diff --git a/tests/components/climate/test_device_trigger.py b/tests/components/climate/test_device_trigger.py
index a492d9805b5..4b5a578ecc4 100644
--- a/tests/components/climate/test_device_trigger.py
+++ b/tests/components/climate/test_device_trigger.py
@@ -48,7 +48,7 @@ async def test_get_triggers(
)
hass.states.async_set(
entity_entry.entity_id,
- const.HVAC_MODE_COOL,
+ HVACMode.COOL,
{
const.ATTR_HVAC_ACTION: HVACAction.IDLE,
const.ATTR_CURRENT_HUMIDITY: 23,
diff --git a/tests/components/climate/test_init.py b/tests/components/climate/test_init.py
index aa162e0b683..45570c63008 100644
--- a/tests/components/climate/test_init.py
+++ b/tests/components/climate/test_init.py
@@ -3,14 +3,12 @@
from __future__ import annotations
from enum import Enum
-from types import ModuleType
from typing import Any
-from unittest.mock import MagicMock, Mock, patch
+from unittest.mock import MagicMock, Mock
import pytest
import voluptuous as vol
-from homeassistant.components import climate
from homeassistant.components.climate import (
DOMAIN,
SET_TEMPERATURE_SCHEMA,
@@ -24,6 +22,7 @@ from homeassistant.components.climate.const import (
ATTR_MAX_TEMP,
ATTR_MIN_TEMP,
ATTR_PRESET_MODE,
+ ATTR_SWING_HORIZONTAL_MODE,
ATTR_SWING_MODE,
ATTR_TARGET_TEMP_HIGH,
ATTR_TARGET_TEMP_LOW,
@@ -31,18 +30,15 @@ from homeassistant.components.climate.const import (
SERVICE_SET_HUMIDITY,
SERVICE_SET_HVAC_MODE,
SERVICE_SET_PRESET_MODE,
+ SERVICE_SET_SWING_HORIZONTAL_MODE,
SERVICE_SET_SWING_MODE,
SERVICE_SET_TEMPERATURE,
+ SWING_HORIZONTAL_OFF,
+ SWING_HORIZONTAL_ON,
ClimateEntityFeature,
)
from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import (
- ATTR_TEMPERATURE,
- PRECISION_WHOLE,
- SERVICE_TURN_OFF,
- SERVICE_TURN_ON,
- UnitOfTemperature,
-)
+from homeassistant.const import ATTR_TEMPERATURE, PRECISION_WHOLE, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import issue_registry as ir
@@ -54,9 +50,6 @@ from tests.common import (
MockModule,
MockPlatform,
async_mock_service,
- help_test_all,
- import_and_test_deprecated_constant,
- import_and_test_deprecated_constant_enum,
mock_integration,
mock_platform,
setup_test_component_platform,
@@ -104,6 +97,7 @@ class MockClimateEntity(MockEntity, ClimateEntity):
ClimateEntityFeature.FAN_MODE
| ClimateEntityFeature.PRESET_MODE
| ClimateEntityFeature.SWING_MODE
+ | ClimateEntityFeature.SWING_HORIZONTAL_MODE
)
_attr_preset_mode = "home"
_attr_preset_modes = ["home", "away"]
@@ -111,6 +105,8 @@ class MockClimateEntity(MockEntity, ClimateEntity):
_attr_fan_modes = ["auto", "off"]
_attr_swing_mode = "auto"
_attr_swing_modes = ["auto", "off"]
+ _attr_swing_horizontal_mode = "on"
+ _attr_swing_horizontal_modes = [SWING_HORIZONTAL_ON, SWING_HORIZONTAL_OFF]
_attr_temperature_unit = UnitOfTemperature.CELSIUS
_attr_target_temperature = 20
_attr_target_temperature_high = 25
@@ -144,6 +140,10 @@ class MockClimateEntity(MockEntity, ClimateEntity):
"""Set swing mode."""
self._attr_swing_mode = swing_mode
+ def set_swing_horizontal_mode(self, swing_horizontal_mode: str) -> None:
+ """Set horizontal swing mode."""
+ self._attr_swing_horizontal_mode = swing_horizontal_mode
+
def set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set new target hvac mode."""
self._attr_hvac_mode = hvac_mode
@@ -194,67 +194,14 @@ def _create_tuples(enum: type[Enum], constant_prefix: str) -> list[tuple[Enum, s
(enum_field, constant_prefix)
for enum_field in enum
if enum_field
- not in [ClimateEntityFeature.TURN_ON, ClimateEntityFeature.TURN_OFF]
+ not in [
+ ClimateEntityFeature.TURN_ON,
+ ClimateEntityFeature.TURN_OFF,
+ ClimateEntityFeature.SWING_HORIZONTAL_MODE,
+ ]
]
-@pytest.mark.parametrize(
- "module",
- [climate, climate.const],
-)
-def test_all(module: ModuleType) -> None:
- """Test module.__all__ is correctly set."""
- help_test_all(module)
-
-
-@pytest.mark.parametrize(
- ("enum", "constant_prefix"),
- _create_tuples(climate.ClimateEntityFeature, "SUPPORT_")
- + _create_tuples(climate.HVACMode, "HVAC_MODE_"),
-)
-@pytest.mark.parametrize(
- "module",
- [climate, climate.const],
-)
-def test_deprecated_constants(
- caplog: pytest.LogCaptureFixture,
- enum: Enum,
- constant_prefix: str,
- module: ModuleType,
-) -> None:
- """Test deprecated constants."""
- import_and_test_deprecated_constant_enum(
- caplog, module, enum, constant_prefix, "2025.1"
- )
-
-
-@pytest.mark.parametrize(
- ("enum", "constant_postfix"),
- [
- (climate.HVACAction.OFF, "OFF"),
- (climate.HVACAction.HEATING, "HEAT"),
- (climate.HVACAction.COOLING, "COOL"),
- (climate.HVACAction.DRYING, "DRY"),
- (climate.HVACAction.IDLE, "IDLE"),
- (climate.HVACAction.FAN, "FAN"),
- ],
-)
-def test_deprecated_current_constants(
- caplog: pytest.LogCaptureFixture,
- enum: climate.HVACAction,
- constant_postfix: str,
-) -> None:
- """Test deprecated current constants."""
- import_and_test_deprecated_constant(
- caplog,
- climate.const,
- "CURRENT_HVAC_" + constant_postfix,
- f"{enum.__class__.__name__}.{enum.name}",
- enum,
- "2025.1",
- )
-
-
async def test_temperature_features_is_valid(
hass: HomeAssistant,
register_test_integration: MockConfigEntry,
@@ -339,6 +286,7 @@ async def test_mode_validation(
assert state.attributes.get(ATTR_PRESET_MODE) == "home"
assert state.attributes.get(ATTR_FAN_MODE) == "auto"
assert state.attributes.get(ATTR_SWING_MODE) == "auto"
+ assert state.attributes.get(ATTR_SWING_HORIZONTAL_MODE) == "on"
await hass.services.async_call(
DOMAIN,
@@ -358,6 +306,15 @@ async def test_mode_validation(
},
blocking=True,
)
+ await hass.services.async_call(
+ DOMAIN,
+ SERVICE_SET_SWING_HORIZONTAL_MODE,
+ {
+ "entity_id": "climate.test",
+ "swing_horizontal_mode": "off",
+ },
+ blocking=True,
+ )
await hass.services.async_call(
DOMAIN,
SERVICE_SET_FAN_MODE,
@@ -371,6 +328,7 @@ async def test_mode_validation(
assert state.attributes.get(ATTR_PRESET_MODE) == "away"
assert state.attributes.get(ATTR_FAN_MODE) == "off"
assert state.attributes.get(ATTR_SWING_MODE) == "off"
+ assert state.attributes.get(ATTR_SWING_HORIZONTAL_MODE) == "off"
await hass.services.async_call(
DOMAIN,
@@ -427,6 +385,25 @@ async def test_mode_validation(
)
assert exc.value.translation_key == "not_valid_swing_mode"
+ with pytest.raises(
+ ServiceValidationError,
+ match="Horizontal swing mode invalid is not valid. Valid horizontal swing modes are: on, off",
+ ) as exc:
+ await hass.services.async_call(
+ DOMAIN,
+ SERVICE_SET_SWING_HORIZONTAL_MODE,
+ {
+ "entity_id": "climate.test",
+ "swing_horizontal_mode": "invalid",
+ },
+ blocking=True,
+ )
+ assert (
+ str(exc.value)
+ == "Horizontal swing mode invalid is not valid. Valid horizontal swing modes are: on, off"
+ )
+ assert exc.value.translation_key == "not_valid_horizontal_swing_mode"
+
with pytest.raises(
ServiceValidationError,
match="Fan mode invalid is not valid. Valid fan modes are: auto, off",
@@ -447,289 +424,6 @@ async def test_mode_validation(
assert exc.value.translation_key == "not_valid_fan_mode"
-@pytest.mark.parametrize(
- "supported_features_at_int",
- [
- ClimateEntityFeature.TARGET_TEMPERATURE.value,
- ClimateEntityFeature.TARGET_TEMPERATURE.value
- | ClimateEntityFeature.TURN_ON.value
- | ClimateEntityFeature.TURN_OFF.value,
- ],
-)
-def test_deprecated_supported_features_ints(
- caplog: pytest.LogCaptureFixture, supported_features_at_int: int
-) -> None:
- """Test deprecated supported features ints."""
-
- class MockClimateEntity(ClimateEntity):
- @property
- def supported_features(self) -> int:
- """Return supported features."""
- return supported_features_at_int
-
- entity = MockClimateEntity()
- assert entity.supported_features is ClimateEntityFeature(supported_features_at_int)
- assert "MockClimateEntity" in caplog.text
- assert "is using deprecated supported features values" in caplog.text
- assert "Instead it should use" in caplog.text
- assert "ClimateEntityFeature.TARGET_TEMPERATURE" in caplog.text
- caplog.clear()
- assert entity.supported_features is ClimateEntityFeature(supported_features_at_int)
- assert "is using deprecated supported features values" not in caplog.text
-
-
-async def test_warning_not_implemented_turn_on_off_feature(
- hass: HomeAssistant,
- caplog: pytest.LogCaptureFixture,
- register_test_integration: MockConfigEntry,
-) -> None:
- """Test adding feature flag and warn if missing when methods are set."""
-
- called = []
-
- class MockClimateEntityTest(MockClimateEntity):
- """Mock Climate device."""
-
- def turn_on(self) -> None:
- """Turn on."""
- called.append("turn_on")
-
- def turn_off(self) -> None:
- """Turn off."""
- called.append("turn_off")
-
- climate_entity = MockClimateEntityTest(name="test", entity_id="climate.test")
-
- with patch.object(
- MockClimateEntityTest, "__module__", "tests.custom_components.climate.test_init"
- ):
- setup_test_component_platform(
- hass, DOMAIN, entities=[climate_entity], from_config_entry=True
- )
- await hass.config_entries.async_setup(register_test_integration.entry_id)
- await hass.async_block_till_done()
-
- state = hass.states.get("climate.test")
- assert state is not None
-
- assert (
- "Entity climate.test (.MockClimateEntityTest'>)"
- " does not set ClimateEntityFeature.TURN_OFF but implements the turn_off method."
- " Please report it to the author of the 'test' custom integration"
- in caplog.text
- )
- assert (
- "Entity climate.test (.MockClimateEntityTest'>)"
- " does not set ClimateEntityFeature.TURN_ON but implements the turn_on method."
- " Please report it to the author of the 'test' custom integration"
- in caplog.text
- )
-
- await hass.services.async_call(
- DOMAIN,
- SERVICE_TURN_ON,
- {
- "entity_id": "climate.test",
- },
- blocking=True,
- )
- await hass.services.async_call(
- DOMAIN,
- SERVICE_TURN_OFF,
- {
- "entity_id": "climate.test",
- },
- blocking=True,
- )
-
- assert len(called) == 2
- assert "turn_on" in called
- assert "turn_off" in called
-
-
-async def test_implicit_warning_not_implemented_turn_on_off_feature(
- hass: HomeAssistant,
- caplog: pytest.LogCaptureFixture,
- register_test_integration: MockConfigEntry,
-) -> None:
- """Test adding feature flag and warn if missing when methods are not set.
-
- (implicit by hvac mode)
- """
-
- class MockClimateEntityTest(MockEntity, ClimateEntity):
- """Mock Climate device."""
-
- _attr_temperature_unit = UnitOfTemperature.CELSIUS
-
- @property
- def hvac_mode(self) -> HVACMode:
- """Return hvac operation ie. heat, cool mode.
-
- Need to be one of HVACMode.*.
- """
- return HVACMode.HEAT
-
- @property
- def hvac_modes(self) -> list[HVACMode]:
- """Return the list of available hvac operation modes.
-
- Need to be a subset of HVAC_MODES.
- """
- return [HVACMode.OFF, HVACMode.HEAT]
-
- climate_entity = MockClimateEntityTest(name="test", entity_id="climate.test")
-
- with patch.object(
- MockClimateEntityTest, "__module__", "tests.custom_components.climate.test_init"
- ):
- setup_test_component_platform(
- hass, DOMAIN, entities=[climate_entity], from_config_entry=True
- )
- await hass.config_entries.async_setup(register_test_integration.entry_id)
- await hass.async_block_till_done()
-
- state = hass.states.get("climate.test")
- assert state is not None
-
- assert (
- "Entity climate.test (.MockClimateEntityTest'>)"
- " implements HVACMode(s): off, heat and therefore implicitly supports the turn_on/turn_off"
- " methods without setting the proper ClimateEntityFeature. Please report it to the author"
- " of the 'test' custom integration" in caplog.text
- )
-
-
-async def test_no_warning_implemented_turn_on_off_feature(
- hass: HomeAssistant,
- caplog: pytest.LogCaptureFixture,
- register_test_integration: MockConfigEntry,
-) -> None:
- """Test no warning when feature flags are set."""
-
- class MockClimateEntityTest(MockClimateEntity):
- """Mock Climate device."""
-
- _attr_supported_features = (
- ClimateEntityFeature.FAN_MODE
- | ClimateEntityFeature.PRESET_MODE
- | ClimateEntityFeature.SWING_MODE
- | ClimateEntityFeature.TURN_OFF
- | ClimateEntityFeature.TURN_ON
- )
-
- climate_entity = MockClimateEntityTest(name="test", entity_id="climate.test")
-
- with patch.object(
- MockClimateEntityTest, "__module__", "tests.custom_components.climate.test_init"
- ):
- setup_test_component_platform(
- hass, DOMAIN, entities=[climate_entity], from_config_entry=True
- )
- await hass.config_entries.async_setup(register_test_integration.entry_id)
- await hass.async_block_till_done()
-
- state = hass.states.get("climate.test")
- assert state is not None
-
- assert (
- "does not set ClimateEntityFeature.TURN_OFF but implements the turn_off method."
- not in caplog.text
- )
- assert (
- "does not set ClimateEntityFeature.TURN_ON but implements the turn_on method."
- not in caplog.text
- )
- assert (
- " implements HVACMode(s): off, heat and therefore implicitly supports the off, heat methods"
- not in caplog.text
- )
-
-
-async def test_no_warning_integration_has_migrated(
- hass: HomeAssistant,
- caplog: pytest.LogCaptureFixture,
- register_test_integration: MockConfigEntry,
-) -> None:
- """Test no warning when integration migrated using `_enable_turn_on_off_backwards_compatibility`."""
-
- class MockClimateEntityTest(MockClimateEntity):
- """Mock Climate device."""
-
- _enable_turn_on_off_backwards_compatibility = False
- _attr_supported_features = (
- ClimateEntityFeature.FAN_MODE
- | ClimateEntityFeature.PRESET_MODE
- | ClimateEntityFeature.SWING_MODE
- )
-
- climate_entity = MockClimateEntityTest(name="test", entity_id="climate.test")
-
- with patch.object(
- MockClimateEntityTest, "__module__", "tests.custom_components.climate.test_init"
- ):
- setup_test_component_platform(
- hass, DOMAIN, entities=[climate_entity], from_config_entry=True
- )
- await hass.config_entries.async_setup(register_test_integration.entry_id)
- await hass.async_block_till_done()
-
- state = hass.states.get("climate.test")
- assert state is not None
-
- assert (
- "does not set ClimateEntityFeature.TURN_OFF but implements the turn_off method."
- not in caplog.text
- )
- assert (
- "does not set ClimateEntityFeature.TURN_ON but implements the turn_on method."
- not in caplog.text
- )
- assert (
- " implements HVACMode(s): off, heat and therefore implicitly supports the off, heat methods"
- not in caplog.text
- )
-
-
-async def test_no_warning_integration_implement_feature_flags(
- hass: HomeAssistant,
- caplog: pytest.LogCaptureFixture,
- register_test_integration: MockConfigEntry,
-) -> None:
- """Test no warning when integration uses the correct feature flags."""
-
- class MockClimateEntityTest(MockClimateEntity):
- """Mock Climate device."""
-
- _attr_supported_features = (
- ClimateEntityFeature.FAN_MODE
- | ClimateEntityFeature.PRESET_MODE
- | ClimateEntityFeature.SWING_MODE
- | ClimateEntityFeature.TURN_OFF
- | ClimateEntityFeature.TURN_ON
- )
-
- climate_entity = MockClimateEntityTest(name="test", entity_id="climate.test")
-
- with patch.object(
- MockClimateEntityTest, "__module__", "tests.custom_components.climate.test_init"
- ):
- setup_test_component_platform(
- hass, DOMAIN, entities=[climate_entity], from_config_entry=True
- )
- await hass.config_entries.async_setup(register_test_integration.entry_id)
- await hass.async_block_till_done()
-
- state = hass.states.get("climate.test")
- assert state is not None
-
- assert "does not set ClimateEntityFeature" not in caplog.text
- assert "implements HVACMode(s):" not in caplog.text
-
-
async def test_turn_on_off_toggle(hass: HomeAssistant) -> None:
"""Test turn_on/turn_off/toggle methods."""
@@ -768,7 +462,6 @@ async def test_sync_toggle(hass: HomeAssistant) -> None:
class MockClimateEntityTest(MockClimateEntity):
"""Mock Climate device."""
- _enable_turn_on_off_backwards_compatibility = False
_attr_supported_features = (
ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON
)
diff --git a/tests/components/climate/test_reproduce_state.py b/tests/components/climate/test_reproduce_state.py
index 0632ebcc9e4..3bc91467f14 100644
--- a/tests/components/climate/test_reproduce_state.py
+++ b/tests/components/climate/test_reproduce_state.py
@@ -6,6 +6,7 @@ from homeassistant.components.climate import (
ATTR_FAN_MODE,
ATTR_HUMIDITY,
ATTR_PRESET_MODE,
+ ATTR_SWING_HORIZONTAL_MODE,
ATTR_SWING_MODE,
ATTR_TARGET_TEMP_HIGH,
ATTR_TARGET_TEMP_LOW,
@@ -14,6 +15,7 @@ from homeassistant.components.climate import (
SERVICE_SET_HUMIDITY,
SERVICE_SET_HVAC_MODE,
SERVICE_SET_PRESET_MODE,
+ SERVICE_SET_SWING_HORIZONTAL_MODE,
SERVICE_SET_SWING_MODE,
SERVICE_SET_TEMPERATURE,
HVACMode,
@@ -96,6 +98,7 @@ async def test_state_with_context(hass: HomeAssistant) -> None:
[
(SERVICE_SET_PRESET_MODE, ATTR_PRESET_MODE),
(SERVICE_SET_SWING_MODE, ATTR_SWING_MODE),
+ (SERVICE_SET_SWING_HORIZONTAL_MODE, ATTR_SWING_HORIZONTAL_MODE),
(SERVICE_SET_FAN_MODE, ATTR_FAN_MODE),
(SERVICE_SET_HUMIDITY, ATTR_HUMIDITY),
(SERVICE_SET_TEMPERATURE, ATTR_TEMPERATURE),
@@ -122,6 +125,7 @@ async def test_attribute(hass: HomeAssistant, service, attribute) -> None:
[
(SERVICE_SET_PRESET_MODE, ATTR_PRESET_MODE),
(SERVICE_SET_SWING_MODE, ATTR_SWING_MODE),
+ (SERVICE_SET_SWING_HORIZONTAL_MODE, ATTR_SWING_HORIZONTAL_MODE),
(SERVICE_SET_FAN_MODE, ATTR_FAN_MODE),
],
)
diff --git a/tests/components/climate/test_significant_change.py b/tests/components/climate/test_significant_change.py
index f060344722a..7d709090357 100644
--- a/tests/components/climate/test_significant_change.py
+++ b/tests/components/climate/test_significant_change.py
@@ -10,6 +10,7 @@ from homeassistant.components.climate import (
ATTR_HUMIDITY,
ATTR_HVAC_ACTION,
ATTR_PRESET_MODE,
+ ATTR_SWING_HORIZONTAL_MODE,
ATTR_SWING_MODE,
ATTR_TARGET_TEMP_HIGH,
ATTR_TARGET_TEMP_LOW,
@@ -66,6 +67,18 @@ async def test_significant_state_change(hass: HomeAssistant) -> None:
),
(METRIC, {ATTR_SWING_MODE: "old_value"}, {ATTR_SWING_MODE: "old_value"}, False),
(METRIC, {ATTR_SWING_MODE: "old_value"}, {ATTR_SWING_MODE: "new_value"}, True),
+ (
+ METRIC,
+ {ATTR_SWING_HORIZONTAL_MODE: "old_value"},
+ {ATTR_SWING_HORIZONTAL_MODE: "old_value"},
+ False,
+ ),
+ (
+ METRIC,
+ {ATTR_SWING_HORIZONTAL_MODE: "old_value"},
+ {ATTR_SWING_HORIZONTAL_MODE: "new_value"},
+ True,
+ ),
# multiple attributes
(
METRIC,
diff --git a/tests/components/cloud/__init__.py b/tests/components/cloud/__init__.py
index 18f8cd4d311..1fb9f2b0d40 100644
--- a/tests/components/cloud/__init__.py
+++ b/tests/components/cloud/__init__.py
@@ -35,6 +35,7 @@ PIPELINE_DATA = {
"tts_voice": "Arnold Schwarzenegger",
"wake_word_entity": None,
"wake_word_id": None,
+ "prefer_local_intents": False,
},
{
"conversation_engine": "conversation_engine_2",
@@ -49,6 +50,7 @@ PIPELINE_DATA = {
"tts_voice": "The Voice",
"wake_word_entity": None,
"wake_word_id": None,
+ "prefer_local_intents": False,
},
{
"conversation_engine": "conversation_engine_3",
@@ -63,6 +65,7 @@ PIPELINE_DATA = {
"tts_voice": None,
"wake_word_entity": None,
"wake_word_id": None,
+ "prefer_local_intents": False,
},
],
"preferred_item": "01GX8ZWBAQYWNB1XV3EXEZ75DY",
diff --git a/tests/components/cloud/test_backup.py b/tests/components/cloud/test_backup.py
new file mode 100644
index 00000000000..fc8c7f27e56
--- /dev/null
+++ b/tests/components/cloud/test_backup.py
@@ -0,0 +1,652 @@
+"""Test the cloud backup platform."""
+
+from collections.abc import AsyncGenerator, AsyncIterator, Generator
+from io import StringIO
+from typing import Any
+from unittest.mock import Mock, PropertyMock, patch
+
+from aiohttp import ClientError
+from hass_nabucasa import CloudError
+import pytest
+from yarl import URL
+
+from homeassistant.components.backup import (
+ DOMAIN as BACKUP_DOMAIN,
+ AddonInfo,
+ AgentBackup,
+ Folder,
+)
+from homeassistant.components.cloud import DOMAIN
+from homeassistant.components.cloud.backup import async_register_backup_agents_listener
+from homeassistant.components.cloud.const import EVENT_CLOUD_EVENT
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.dispatcher import async_dispatcher_send
+from homeassistant.setup import async_setup_component
+
+from tests.test_util.aiohttp import AiohttpClientMocker
+from tests.typing import ClientSessionGenerator, MagicMock, WebSocketGenerator
+
+
+@pytest.fixture(autouse=True)
+async def setup_integration(
+ hass: HomeAssistant,
+ aioclient_mock: AiohttpClientMocker,
+ cloud: MagicMock,
+ cloud_logged_in: None,
+) -> AsyncGenerator[None]:
+ """Set up cloud integration."""
+ with (
+ patch("homeassistant.components.backup.is_hassio", return_value=False),
+ patch("homeassistant.components.backup.store.STORE_DELAY_SAVE", 0),
+ ):
+ assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}})
+ assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}})
+ await hass.async_block_till_done()
+ yield
+
+
+@pytest.fixture
+def mock_delete_file() -> Generator[MagicMock]:
+ """Mock list files."""
+ with patch(
+ "homeassistant.components.cloud.backup.async_files_delete_file",
+ spec_set=True,
+ ) as delete_file:
+ yield delete_file
+
+
+@pytest.fixture
+def mock_get_download_details() -> Generator[MagicMock]:
+ """Mock list files."""
+ with patch(
+ "homeassistant.components.cloud.backup.async_files_download_details",
+ spec_set=True,
+ ) as download_details:
+ download_details.return_value = {
+ "url": (
+ "https://blabla.cloudflarestorage.com/blabla/backup/"
+ "462e16810d6841228828d9dd2f9e341e.tar?X-Amz-Algorithm=blah"
+ ),
+ }
+ yield download_details
+
+
+@pytest.fixture
+def mock_get_upload_details() -> Generator[MagicMock]:
+ """Mock list files."""
+ with patch(
+ "homeassistant.components.cloud.backup.async_files_upload_details",
+ spec_set=True,
+ ) as download_details:
+ download_details.return_value = {
+ "url": (
+ "https://blabla.cloudflarestorage.com/blabla/backup/"
+ "ea5c969e492c49df89d432a1483b8dc3.tar?X-Amz-Algorithm=blah"
+ ),
+ "headers": {
+ "content-md5": "HOhSM3WZkpHRYGiz4YRGIQ==",
+ "x-amz-meta-storage-type": "backup",
+ "x-amz-meta-b64json": (
+ "eyJhZGRvbnMiOltdLCJiYWNrdXBfaWQiOiJjNDNiNWU2MCIsImRhdGUiOiIyMDI0LT"
+ "EyLTAzVDA0OjI1OjUwLjMyMDcwMy0wNTowMCIsImRhdGFiYXNlX2luY2x1ZGVkIjpm"
+ "YWxzZSwiZm9sZGVycyI6W10sImhvbWVhc3Npc3RhbnRfaW5jbHVkZWQiOnRydWUsIm"
+ "hvbWVhc3Npc3RhbnRfdmVyc2lvbiI6IjIwMjQuMTIuMC5kZXYwIiwibmFtZSI6ImVy"
+ "aWsiLCJwcm90ZWN0ZWQiOnRydWUsInNpemUiOjM1NjI0OTYwfQ=="
+ ),
+ },
+ }
+ yield download_details
+
+
+@pytest.fixture
+def mock_list_files() -> Generator[MagicMock]:
+ """Mock list files."""
+ with patch(
+ "homeassistant.components.cloud.backup.async_files_list", spec_set=True
+ ) as list_files:
+ list_files.return_value = [
+ {
+ "Key": "462e16810d6841228828d9dd2f9e341e.tar",
+ "LastModified": "2024-11-22T10:49:01.182Z",
+ "Size": 34519040,
+ "Metadata": {
+ "addons": [],
+ "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,
+ "storage-type": "backup",
+ },
+ }
+ ]
+ yield list_files
+
+
+@pytest.fixture
+def cloud_logged_in(cloud: MagicMock):
+ """Mock cloud logged in."""
+ type(cloud).is_logged_in = PropertyMock(return_value=True)
+
+
+async def test_agents_info(
+ hass: HomeAssistant,
+ hass_ws_client: WebSocketGenerator,
+) -> None:
+ """Test backup agent info."""
+ client = await hass_ws_client(hass)
+
+ await client.send_json_auto_id({"type": "backup/agents/info"})
+ response = await client.receive_json()
+
+ assert response["success"]
+ assert response["result"] == {
+ "agents": [{"agent_id": "backup.local"}, {"agent_id": "cloud.cloud"}],
+ }
+
+
+async def test_agents_list_backups(
+ hass: HomeAssistant,
+ hass_ws_client: WebSocketGenerator,
+ cloud: MagicMock,
+ mock_list_files: Mock,
+) -> None:
+ """Test agent list backups."""
+ client = await hass_ws_client(hass)
+ await client.send_json_auto_id({"type": "backup/info"})
+ response = await client.receive_json()
+ mock_list_files.assert_called_once_with(cloud, storage_type="backup")
+
+ assert response["success"]
+ assert response["result"]["agent_errors"] == {}
+ assert response["result"]["backups"] == [
+ {
+ "addons": [],
+ "backup_id": "23e64aec",
+ "date": "2024-11-22T11:48:48.727189+01:00",
+ "database_included": True,
+ "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,
+ }
+ ]
+
+
+@pytest.mark.parametrize("side_effect", [ClientError, CloudError])
+async def test_agents_list_backups_fail_cloud(
+ hass: HomeAssistant,
+ hass_ws_client: WebSocketGenerator,
+ cloud: MagicMock,
+ mock_list_files: Mock,
+ side_effect: Exception,
+) -> None:
+ """Test agent list backups."""
+ client = await hass_ws_client(hass)
+ mock_list_files.side_effect = side_effect
+
+ await client.send_json_auto_id({"type": "backup/info"})
+ response = await client.receive_json()
+
+ assert response["success"]
+ assert response["result"] == {
+ "agent_errors": {"cloud.cloud": "Failed to list backups"},
+ "backups": [],
+ "last_attempted_automatic_backup": None,
+ "last_completed_automatic_backup": None,
+ }
+
+
+@pytest.mark.parametrize(
+ ("backup_id", "expected_result"),
+ [
+ (
+ "23e64aec",
+ {
+ "addons": [],
+ "backup_id": "23e64aec",
+ "date": "2024-11-22T11:48:48.727189+01:00",
+ "database_included": True,
+ "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,
+ },
+ ),
+ (
+ "12345",
+ None,
+ ),
+ ],
+ ids=["found", "not_found"],
+)
+async def test_agents_get_backup(
+ hass: HomeAssistant,
+ hass_ws_client: WebSocketGenerator,
+ cloud: MagicMock,
+ backup_id: str,
+ expected_result: dict[str, Any] | None,
+ mock_list_files: Mock,
+) -> None:
+ """Test agent get backup."""
+ client = await hass_ws_client(hass)
+ await client.send_json_auto_id({"type": "backup/details", "backup_id": backup_id})
+ response = await client.receive_json()
+ mock_list_files.assert_called_once_with(cloud, storage_type="backup")
+
+ assert response["success"]
+ assert response["result"]["agent_errors"] == {}
+ assert response["result"]["backup"] == expected_result
+
+
+@pytest.mark.usefixtures("cloud_logged_in", "mock_list_files")
+async def test_agents_download(
+ hass: HomeAssistant,
+ hass_client: ClientSessionGenerator,
+ aioclient_mock: AiohttpClientMocker,
+ mock_get_download_details: Mock,
+) -> None:
+ """Test agent download backup."""
+ client = await hass_client()
+ backup_id = "23e64aec"
+
+ aioclient_mock.get(
+ mock_get_download_details.return_value["url"], content=b"backup data"
+ )
+
+ resp = await client.get(f"/api/backup/download/{backup_id}?agent_id=cloud.cloud")
+ assert resp.status == 200
+ assert await resp.content.read() == b"backup data"
+
+
+@pytest.mark.parametrize("side_effect", [ClientError, CloudError])
+@pytest.mark.usefixtures("cloud_logged_in", "mock_list_files")
+async def test_agents_download_fail_cloud(
+ hass: HomeAssistant,
+ hass_client: ClientSessionGenerator,
+ mock_get_download_details: Mock,
+ side_effect: Exception,
+) -> None:
+ """Test agent download backup, when cloud user is logged in."""
+ client = await hass_client()
+ backup_id = "23e64aec"
+ mock_get_download_details.side_effect = side_effect
+
+ resp = await client.get(f"/api/backup/download/{backup_id}?agent_id=cloud.cloud")
+ assert resp.status == 500
+ content = await resp.content.read()
+ assert "Failed to get download details" in content.decode()
+
+
+@pytest.mark.usefixtures("cloud_logged_in", "mock_list_files")
+async def test_agents_download_fail_get(
+ hass: HomeAssistant,
+ hass_client: ClientSessionGenerator,
+ aioclient_mock: AiohttpClientMocker,
+ mock_get_download_details: Mock,
+) -> None:
+ """Test agent download backup, when cloud user is logged in."""
+ client = await hass_client()
+ backup_id = "23e64aec"
+
+ aioclient_mock.get(mock_get_download_details.return_value["url"], status=500)
+
+ resp = await client.get(f"/api/backup/download/{backup_id}?agent_id=cloud.cloud")
+ assert resp.status == 500
+ content = await resp.content.read()
+ assert "Failed to download backup" in content.decode()
+
+
+@pytest.mark.usefixtures("cloud_logged_in", "mock_list_files")
+async def test_agents_download_not_found(
+ hass: HomeAssistant,
+ hass_client: ClientSessionGenerator,
+) -> None:
+ """Test agent download backup raises error if not found."""
+ client = await hass_client()
+ backup_id = "1234"
+
+ resp = await client.get(f"/api/backup/download/{backup_id}?agent_id=cloud.cloud")
+ assert resp.status == 404
+ assert await resp.content.read() == b""
+
+
+@pytest.mark.usefixtures("cloud_logged_in", "mock_list_files")
+async def test_agents_upload(
+ hass: HomeAssistant,
+ hass_client: ClientSessionGenerator,
+ caplog: pytest.LogCaptureFixture,
+ aioclient_mock: AiohttpClientMocker,
+ mock_get_upload_details: Mock,
+) -> None:
+ """Test agent upload backup."""
+ client = await hass_client()
+ backup_id = "test-backup"
+ test_backup = AgentBackup(
+ addons=[AddonInfo(name="Test", slug="test", version="1.0.0")],
+ backup_id=backup_id,
+ database_included=True,
+ date="1970-01-01T00:00:00.000Z",
+ extra_metadata={},
+ folders=[Folder.MEDIA, Folder.SHARE],
+ homeassistant_included=True,
+ homeassistant_version="2024.12.0",
+ name="Test",
+ protected=True,
+ size=0,
+ )
+ aioclient_mock.put(mock_get_upload_details.return_value["url"])
+
+ with (
+ patch(
+ "homeassistant.components.backup.manager.BackupManager.async_get_backup",
+ ) as fetch_backup,
+ patch(
+ "homeassistant.components.backup.manager.read_backup",
+ return_value=test_backup,
+ ),
+ patch("pathlib.Path.open") as mocked_open,
+ ):
+ mocked_open.return_value.read = Mock(side_effect=[b"test", b""])
+ fetch_backup.return_value = test_backup
+ resp = await client.post(
+ "/api/backup/upload?agent_id=cloud.cloud",
+ data={"file": StringIO("test")},
+ )
+
+ assert len(aioclient_mock.mock_calls) == 1
+ assert aioclient_mock.mock_calls[-1][0] == "PUT"
+ assert aioclient_mock.mock_calls[-1][1] == URL(
+ mock_get_upload_details.return_value["url"]
+ )
+ assert isinstance(aioclient_mock.mock_calls[-1][2], AsyncIterator)
+
+ assert resp.status == 201
+ assert f"Uploading backup {backup_id}" in caplog.text
+
+
+@pytest.mark.parametrize("put_mock_kwargs", [{"status": 500}, {"exc": TimeoutError}])
+@pytest.mark.usefixtures("cloud_logged_in", "mock_list_files")
+async def test_agents_upload_fail_put(
+ hass: HomeAssistant,
+ hass_client: ClientSessionGenerator,
+ hass_storage: dict[str, Any],
+ aioclient_mock: AiohttpClientMocker,
+ mock_get_upload_details: Mock,
+ put_mock_kwargs: dict[str, Any],
+ caplog: pytest.LogCaptureFixture,
+) -> None:
+ """Test agent upload backup fails."""
+ client = await hass_client()
+ backup_id = "test-backup"
+ test_backup = AgentBackup(
+ addons=[AddonInfo(name="Test", slug="test", version="1.0.0")],
+ backup_id=backup_id,
+ database_included=True,
+ date="1970-01-01T00:00:00.000Z",
+ extra_metadata={},
+ folders=[Folder.MEDIA, Folder.SHARE],
+ homeassistant_included=True,
+ homeassistant_version="2024.12.0",
+ name="Test",
+ protected=True,
+ size=0,
+ )
+ aioclient_mock.put(mock_get_upload_details.return_value["url"], **put_mock_kwargs)
+
+ with (
+ patch(
+ "homeassistant.components.backup.manager.BackupManager.async_get_backup",
+ ) as fetch_backup,
+ patch(
+ "homeassistant.components.backup.manager.read_backup",
+ return_value=test_backup,
+ ),
+ patch("pathlib.Path.open") as mocked_open,
+ patch("homeassistant.components.cloud.backup.asyncio.sleep"),
+ patch("homeassistant.components.cloud.backup.random.randint", return_value=60),
+ patch("homeassistant.components.cloud.backup._RETRY_LIMIT", 2),
+ ):
+ mocked_open.return_value.read = Mock(side_effect=[b"test", b""])
+ fetch_backup.return_value = test_backup
+ resp = await client.post(
+ "/api/backup/upload?agent_id=cloud.cloud",
+ data={"file": StringIO("test")},
+ )
+ await hass.async_block_till_done()
+
+ assert len(aioclient_mock.mock_calls) == 2
+ assert "Failed to upload backup, retrying (2/2) in 60s" in caplog.text
+ assert resp.status == 201
+ store_backups = hass_storage[BACKUP_DOMAIN]["data"]["backups"]
+ assert len(store_backups) == 1
+ stored_backup = store_backups[0]
+ assert stored_backup["backup_id"] == backup_id
+ assert stored_backup["failed_agent_ids"] == ["cloud.cloud"]
+
+
+@pytest.mark.parametrize("side_effect", [ClientError, CloudError])
+@pytest.mark.usefixtures("cloud_logged_in")
+async def test_agents_upload_fail_cloud(
+ hass: HomeAssistant,
+ hass_client: ClientSessionGenerator,
+ hass_storage: dict[str, Any],
+ mock_get_upload_details: Mock,
+ side_effect: Exception,
+) -> None:
+ """Test agent upload backup, when cloud user is logged in."""
+ client = await hass_client()
+ backup_id = "test-backup"
+ mock_get_upload_details.side_effect = side_effect
+ test_backup = AgentBackup(
+ addons=[AddonInfo(name="Test", slug="test", version="1.0.0")],
+ backup_id=backup_id,
+ database_included=True,
+ date="1970-01-01T00:00:00.000Z",
+ extra_metadata={},
+ folders=[Folder.MEDIA, Folder.SHARE],
+ homeassistant_included=True,
+ homeassistant_version="2024.12.0",
+ name="Test",
+ protected=True,
+ size=0,
+ )
+ with (
+ patch(
+ "homeassistant.components.backup.manager.BackupManager.async_get_backup",
+ ) as fetch_backup,
+ patch(
+ "homeassistant.components.backup.manager.read_backup",
+ return_value=test_backup,
+ ),
+ patch("pathlib.Path.open") as mocked_open,
+ patch("homeassistant.components.cloud.backup.asyncio.sleep"),
+ ):
+ mocked_open.return_value.read = Mock(side_effect=[b"test", b""])
+ fetch_backup.return_value = test_backup
+ resp = await client.post(
+ "/api/backup/upload?agent_id=cloud.cloud",
+ data={"file": StringIO("test")},
+ )
+ await hass.async_block_till_done()
+
+ assert resp.status == 201
+ store_backups = hass_storage[BACKUP_DOMAIN]["data"]["backups"]
+ assert len(store_backups) == 1
+ stored_backup = store_backups[0]
+ assert stored_backup["backup_id"] == backup_id
+ assert stored_backup["failed_agent_ids"] == ["cloud.cloud"]
+
+
+async def test_agents_upload_not_protected(
+ hass: HomeAssistant,
+ hass_client: ClientSessionGenerator,
+ hass_storage: dict[str, Any],
+) -> None:
+ """Test agent upload backup, when cloud user is logged in."""
+ client = await hass_client()
+ backup_id = "test-backup"
+ test_backup = AgentBackup(
+ addons=[AddonInfo(name="Test", slug="test", version="1.0.0")],
+ backup_id=backup_id,
+ database_included=True,
+ date="1970-01-01T00:00:00.000Z",
+ extra_metadata={},
+ folders=[Folder.MEDIA, Folder.SHARE],
+ homeassistant_included=True,
+ homeassistant_version="2024.12.0",
+ name="Test",
+ protected=False,
+ size=0,
+ )
+ with (
+ patch("pathlib.Path.open"),
+ patch(
+ "homeassistant.components.backup.manager.read_backup",
+ return_value=test_backup,
+ ),
+ ):
+ resp = await client.post(
+ "/api/backup/upload?agent_id=cloud.cloud",
+ data={"file": StringIO("test")},
+ )
+ await hass.async_block_till_done()
+
+ assert resp.status == 201
+ store_backups = hass_storage[BACKUP_DOMAIN]["data"]["backups"]
+ assert len(store_backups) == 1
+ stored_backup = store_backups[0]
+ assert stored_backup["backup_id"] == backup_id
+ assert stored_backup["failed_agent_ids"] == ["cloud.cloud"]
+
+
+@pytest.mark.usefixtures("cloud_logged_in", "mock_list_files")
+async def test_agents_delete(
+ hass: HomeAssistant,
+ hass_ws_client: WebSocketGenerator,
+ mock_delete_file: Mock,
+) -> None:
+ """Test agent delete backup."""
+ client = await hass_ws_client(hass)
+ backup_id = "23e64aec"
+
+ await client.send_json_auto_id(
+ {
+ "type": "backup/delete",
+ "backup_id": backup_id,
+ }
+ )
+ response = await client.receive_json()
+
+ assert response["success"]
+ assert response["result"] == {"agent_errors": {}}
+ mock_delete_file.assert_called_once()
+
+
+@pytest.mark.parametrize("side_effect", [ClientError, CloudError])
+@pytest.mark.usefixtures("cloud_logged_in", "mock_list_files")
+async def test_agents_delete_fail_cloud(
+ hass: HomeAssistant,
+ hass_ws_client: WebSocketGenerator,
+ mock_delete_file: Mock,
+ side_effect: Exception,
+) -> None:
+ """Test agent delete backup."""
+ client = await hass_ws_client(hass)
+ backup_id = "23e64aec"
+ mock_delete_file.side_effect = side_effect
+
+ await client.send_json_auto_id(
+ {
+ "type": "backup/delete",
+ "backup_id": backup_id,
+ }
+ )
+ response = await client.receive_json()
+
+ assert response["success"]
+ assert response["result"] == {
+ "agent_errors": {"cloud.cloud": "Failed to delete backup"}
+ }
+
+
+@pytest.mark.usefixtures("cloud_logged_in", "mock_list_files")
+async def test_agents_delete_not_found(
+ hass: HomeAssistant,
+ hass_ws_client: WebSocketGenerator,
+) -> None:
+ """Test agent download backup raises error if not found."""
+ client = await hass_ws_client(hass)
+ backup_id = "1234"
+
+ await client.send_json_auto_id(
+ {
+ "type": "backup/delete",
+ "backup_id": backup_id,
+ }
+ )
+ response = await client.receive_json()
+
+ assert response["success"]
+ assert response["result"] == {"agent_errors": {}}
+
+
+@pytest.mark.parametrize("event_type", ["login", "logout"])
+async def test_calling_listener_on_login_logout(
+ hass: HomeAssistant,
+ event_type: str,
+) -> None:
+ """Test calling listener for login and logout events."""
+ listener = MagicMock()
+ async_register_backup_agents_listener(hass, listener=listener)
+
+ assert listener.call_count == 0
+ async_dispatcher_send(hass, EVENT_CLOUD_EVENT, {"type": event_type})
+ await hass.async_block_till_done()
+
+ assert listener.call_count == 1
+
+
+async def test_not_calling_listener_after_unsub(hass: HomeAssistant) -> None:
+ """Test only calling listener until unsub."""
+ listener = MagicMock()
+ unsub = async_register_backup_agents_listener(hass, listener=listener)
+
+ assert listener.call_count == 0
+ async_dispatcher_send(hass, EVENT_CLOUD_EVENT, {"type": "login"})
+ await hass.async_block_till_done()
+ assert listener.call_count == 1
+
+ unsub()
+
+ async_dispatcher_send(hass, EVENT_CLOUD_EVENT, {"type": "login"})
+ await hass.async_block_till_done()
+ assert listener.call_count == 1
+
+
+async def test_not_calling_listener_with_unknown_event_type(
+ hass: HomeAssistant,
+) -> None:
+ """Test not calling listener if we did not get the expected event type."""
+ listener = MagicMock()
+ async_register_backup_agents_listener(hass, listener=listener)
+
+ assert listener.call_count == 0
+ async_dispatcher_send(hass, EVENT_CLOUD_EVENT, {"type": "unknown"})
+ await hass.async_block_till_done()
+ assert listener.call_count == 0
diff --git a/tests/components/cloud/test_client.py b/tests/components/cloud/test_client.py
index 43eccc5ef9c..52457fe558c 100644
--- a/tests/components/cloud/test_client.py
+++ b/tests/components/cloud/test_client.py
@@ -441,6 +441,7 @@ async def test_cloud_connection_info(hass: HomeAssistant) -> None:
assert response == {
"instance_id": "12345678901234567890",
+ "name": "test home",
"remote": {
"alias": None,
"can_enable": True,
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 216fc77db48..d915f158af0 100644
--- a/tests/components/cloud/test_http_api.py
+++ b/tests/components/cloud/test_http_api.py
@@ -8,7 +8,12 @@ from unittest.mock import AsyncMock, MagicMock, Mock, patch
import aiohttp
from hass_nabucasa import thingtalk
-from hass_nabucasa.auth import Unauthenticated, UnknownError
+from hass_nabucasa.auth import (
+ InvalidTotpCode,
+ MFARequired,
+ Unauthenticated,
+ UnknownError,
+)
from hass_nabucasa.const import STATE_CONNECTED
from hass_nabucasa.voice import TTS_VOICES
import pytest
@@ -378,6 +383,128 @@ async def test_login_view_invalid_credentials(
assert req.status == HTTPStatus.UNAUTHORIZED
+async def test_login_view_mfa_required(
+ cloud: MagicMock,
+ setup_cloud: None,
+ hass_client: ClientSessionGenerator,
+) -> None:
+ """Test logging in when MFA is required."""
+ cloud_client = await hass_client()
+ cloud.login.side_effect = MFARequired(mfa_tokens={"session": "tokens"})
+
+ req = await cloud_client.post(
+ "/api/cloud/login", json={"email": "my_username", "password": "my_password"}
+ )
+
+ assert req.status == HTTPStatus.UNAUTHORIZED
+ res = await req.json()
+ assert res["code"] == "mfarequired"
+
+
+async def test_login_view_mfa_required_tokens_missing(
+ cloud: MagicMock,
+ setup_cloud: None,
+ hass_client: ClientSessionGenerator,
+) -> None:
+ """Test logging in when MFA is required, code is provided, but session tokens are missing."""
+ cloud_client = await hass_client()
+ cloud.login.side_effect = MFARequired(mfa_tokens={})
+
+ # Login with password and get MFA required error
+ req = await cloud_client.post(
+ "/api/cloud/login", json={"email": "my_username", "password": "my_password"}
+ )
+
+ assert req.status == HTTPStatus.UNAUTHORIZED
+ res = await req.json()
+ assert res["code"] == "mfarequired"
+
+ # Login with TOTP code and get MFA expired error
+ req = await cloud_client.post(
+ "/api/cloud/login",
+ json={"email": "my_username", "code": "123346"},
+ )
+
+ assert req.status == HTTPStatus.BAD_REQUEST
+ res = await req.json()
+ assert res["code"] == "mfaexpiredornotstarted"
+
+
+async def test_login_view_mfa_password_and_totp_provided(
+ cloud: MagicMock,
+ setup_cloud: None,
+ hass_client: ClientSessionGenerator,
+) -> None:
+ """Test logging in when password and TOTP code provided at once."""
+ cloud_client = await hass_client()
+
+ req = await cloud_client.post(
+ "/api/cloud/login",
+ json={"email": "my_username", "password": "my_password", "code": "123346"},
+ )
+
+ assert req.status == HTTPStatus.BAD_REQUEST
+
+
+async def test_login_view_invalid_totp_code(
+ cloud: MagicMock,
+ setup_cloud: None,
+ hass_client: ClientSessionGenerator,
+) -> None:
+ """Test logging in when MFA is required and invalid code is provided."""
+ cloud_client = await hass_client()
+ cloud.login.side_effect = MFARequired(mfa_tokens={"session": "tokens"})
+ cloud.login_verify_totp.side_effect = InvalidTotpCode
+
+ # Login with password and get MFA required error
+ req = await cloud_client.post(
+ "/api/cloud/login", json={"email": "my_username", "password": "my_password"}
+ )
+
+ assert req.status == HTTPStatus.UNAUTHORIZED
+ res = await req.json()
+ assert res["code"] == "mfarequired"
+
+ # Login with TOTP code and get invalid TOTP code error
+ req = await cloud_client.post(
+ "/api/cloud/login",
+ json={"email": "my_username", "code": "123346"},
+ )
+
+ assert req.status == HTTPStatus.BAD_REQUEST
+ res = await req.json()
+ assert res["code"] == "invalidtotpcode"
+
+
+async def test_login_view_valid_totp_provided(
+ cloud: MagicMock,
+ setup_cloud: None,
+ hass_client: ClientSessionGenerator,
+) -> None:
+ """Test logging in with valid TOTP code."""
+ cloud_client = await hass_client()
+ cloud.login.side_effect = MFARequired(mfa_tokens={"session": "tokens"})
+
+ # Login with password and get MFA required error
+ req = await cloud_client.post(
+ "/api/cloud/login", json={"email": "my_username", "password": "my_password"}
+ )
+
+ assert req.status == HTTPStatus.UNAUTHORIZED
+ res = await req.json()
+ assert res["code"] == "mfarequired"
+
+ # Login with TOTP code and get success response
+ req = await cloud_client.post(
+ "/api/cloud/login",
+ json={"email": "my_username", "code": "123346"},
+ )
+
+ assert req.status == HTTPStatus.OK
+ result = await req.json()
+ assert result == {"success": True, "cloud_pipeline": None}
+
+
async def test_login_view_unknown_error(
cloud: MagicMock,
setup_cloud: None,
@@ -1692,3 +1819,45 @@ async def test_api_calls_require_admin(
resp = await client.post(endpoint, json=data)
assert resp.status == HTTPStatus.UNAUTHORIZED
+
+
+async def test_login_view_dispatch_event(
+ hass: HomeAssistant,
+ cloud: MagicMock,
+ hass_client: ClientSessionGenerator,
+) -> None:
+ """Test dispatching event while logging in."""
+ assert await async_setup_component(hass, "homeassistant", {})
+ assert await async_setup_component(hass, DOMAIN, {"cloud": {}})
+ await hass.async_block_till_done()
+
+ cloud_client = await hass_client()
+
+ with patch(
+ "homeassistant.components.cloud.http_api.async_dispatcher_send"
+ ) as async_dispatcher_send_mock:
+ await cloud_client.post(
+ "/api/cloud/login", json={"email": "my_username", "password": "my_password"}
+ )
+
+ 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": "login"}
+
+
+async def test_logout_view_dispatch_event(
+ cloud: MagicMock,
+ setup_cloud: None,
+ hass_client: ClientSessionGenerator,
+) -> None:
+ """Test dispatching event while logging out."""
+ cloud_client = await hass_client()
+
+ with patch(
+ "homeassistant.components.cloud.http_api.async_dispatcher_send"
+ ) as async_dispatcher_send_mock:
+ await cloud_client.post("/api/cloud/logout")
+
+ 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"}
diff --git a/tests/components/cloud/test_tts.py b/tests/components/cloud/test_tts.py
index 499981c643d..bf9fd7302ae 100644
--- a/tests/components/cloud/test_tts.py
+++ b/tests/components/cloud/test_tts.py
@@ -227,25 +227,21 @@ async def test_get_tts_audio(
await on_start_callback()
client = await hass_client()
- url = "/api/tts_get_url"
- data |= {"message": "There is someone at the door."}
+ with patch(
+ "homeassistant.components.tts.secrets.token_urlsafe", return_value="test_token"
+ ):
+ url = "/api/tts_get_url"
+ data |= {"message": "There is someone at the door."}
- req = await client.post(url, json=data)
- assert req.status == HTTPStatus.OK
- response = await req.json()
+ req = await client.post(url, json=data)
+ assert req.status == HTTPStatus.OK
+ response = await req.json()
- assert response == {
- "url": (
- "http://example.local:8123/api/tts_proxy/"
- "42f18378fd4393d18c8dd11d03fa9563c1e54491"
- f"_en-us_6e8b81ac47_{expected_url_suffix}.mp3"
- ),
- "path": (
- "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491"
- f"_en-us_6e8b81ac47_{expected_url_suffix}.mp3"
- ),
- }
- await hass.async_block_till_done()
+ assert response == {
+ "url": ("http://example.local:8123/api/tts_proxy/test_token.mp3"),
+ "path": ("/api/tts_proxy/test_token.mp3"),
+ }
+ await hass.async_block_till_done()
assert mock_process_tts.call_count == 1
assert mock_process_tts.call_args is not None
@@ -280,25 +276,21 @@ async def test_get_tts_audio_logged_out(
await hass.async_block_till_done()
client = await hass_client()
- url = "/api/tts_get_url"
- data |= {"message": "There is someone at the door."}
+ with patch(
+ "homeassistant.components.tts.secrets.token_urlsafe", return_value="test_token"
+ ):
+ url = "/api/tts_get_url"
+ data |= {"message": "There is someone at the door."}
- req = await client.post(url, json=data)
- assert req.status == HTTPStatus.OK
- response = await req.json()
+ req = await client.post(url, json=data)
+ assert req.status == HTTPStatus.OK
+ response = await req.json()
- assert response == {
- "url": (
- "http://example.local:8123/api/tts_proxy/"
- "42f18378fd4393d18c8dd11d03fa9563c1e54491"
- f"_en-us_6e8b81ac47_{expected_url_suffix}.mp3"
- ),
- "path": (
- "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491"
- f"_en-us_6e8b81ac47_{expected_url_suffix}.mp3"
- ),
- }
- await hass.async_block_till_done()
+ assert response == {
+ "url": ("http://example.local:8123/api/tts_proxy/test_token.mp3"),
+ "path": ("/api/tts_proxy/test_token.mp3"),
+ }
+ await hass.async_block_till_done()
assert mock_process_tts.call_count == 1
assert mock_process_tts.call_args is not None
@@ -342,28 +334,24 @@ async def test_tts_entity(
assert state
assert state.state == STATE_UNKNOWN
- url = "/api/tts_get_url"
- data = {
- "engine_id": entity_id,
- "message": "There is someone at the door.",
- }
+ with patch(
+ "homeassistant.components.tts.secrets.token_urlsafe", return_value="test_token"
+ ):
+ url = "/api/tts_get_url"
+ data = {
+ "engine_id": entity_id,
+ "message": "There is someone at the door.",
+ }
- req = await client.post(url, json=data)
- assert req.status == HTTPStatus.OK
- response = await req.json()
+ req = await client.post(url, json=data)
+ assert req.status == HTTPStatus.OK
+ response = await req.json()
- assert response == {
- "url": (
- "http://example.local:8123/api/tts_proxy/"
- "42f18378fd4393d18c8dd11d03fa9563c1e54491"
- f"_en-us_6e8b81ac47_{entity_id}.mp3"
- ),
- "path": (
- "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491"
- f"_en-us_6e8b81ac47_{entity_id}.mp3"
- ),
- }
- await hass.async_block_till_done()
+ assert response == {
+ "url": ("http://example.local:8123/api/tts_proxy/test_token.mp3"),
+ "path": ("/api/tts_proxy/test_token.mp3"),
+ }
+ await hass.async_block_till_done()
assert mock_process_tts.call_count == 1
assert mock_process_tts.call_args is not None
@@ -482,29 +470,25 @@ async def test_deprecated_voice(
client = await hass_client()
# Test with non deprecated voice.
- url = "/api/tts_get_url"
- data |= {
- "message": "There is someone at the door.",
- "language": language,
- "options": {"voice": replacement_voice},
- }
+ with patch(
+ "homeassistant.components.tts.secrets.token_urlsafe", return_value="test_token"
+ ):
+ url = "/api/tts_get_url"
+ data |= {
+ "message": "There is someone at the door.",
+ "language": language,
+ "options": {"voice": replacement_voice},
+ }
- req = await client.post(url, json=data)
- assert req.status == HTTPStatus.OK
- response = await req.json()
+ req = await client.post(url, json=data)
+ assert req.status == HTTPStatus.OK
+ response = await req.json()
- assert response == {
- "url": (
- "http://example.local:8123/api/tts_proxy/"
- "42f18378fd4393d18c8dd11d03fa9563c1e54491"
- f"_{language.lower()}_87567e3e29_{expected_url_suffix}.mp3"
- ),
- "path": (
- "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491"
- f"_{language.lower()}_87567e3e29_{expected_url_suffix}.mp3"
- ),
- }
- await hass.async_block_till_done()
+ assert response == {
+ "url": ("http://example.local:8123/api/tts_proxy/test_token.mp3"),
+ "path": ("/api/tts_proxy/test_token.mp3"),
+ }
+ await hass.async_block_till_done()
assert mock_process_tts.call_count == 1
assert mock_process_tts.call_args is not None
@@ -522,22 +506,18 @@ async def test_deprecated_voice(
# Test with deprecated voice.
data["options"] = {"voice": deprecated_voice}
- req = await client.post(url, json=data)
- assert req.status == HTTPStatus.OK
- response = await req.json()
+ with patch(
+ "homeassistant.components.tts.secrets.token_urlsafe", return_value="test_token"
+ ):
+ req = await client.post(url, json=data)
+ assert req.status == HTTPStatus.OK
+ response = await req.json()
- assert response == {
- "url": (
- "http://example.local:8123/api/tts_proxy/"
- "42f18378fd4393d18c8dd11d03fa9563c1e54491"
- f"_{language.lower()}_13646b7d32_{expected_url_suffix}.mp3"
- ),
- "path": (
- "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491"
- f"_{language.lower()}_13646b7d32_{expected_url_suffix}.mp3"
- ),
- }
- await hass.async_block_till_done()
+ assert response == {
+ "url": ("http://example.local:8123/api/tts_proxy/test_token.mp3"),
+ "path": ("/api/tts_proxy/test_token.mp3"),
+ }
+ await hass.async_block_till_done()
issue_id = f"deprecated_voice_{deprecated_voice}"
@@ -631,28 +611,24 @@ async def test_deprecated_gender(
client = await hass_client()
# Test without deprecated gender option.
- url = "/api/tts_get_url"
- data |= {
- "message": "There is someone at the door.",
- "language": language,
- }
+ with patch(
+ "homeassistant.components.tts.secrets.token_urlsafe", return_value="test_token"
+ ):
+ url = "/api/tts_get_url"
+ data |= {
+ "message": "There is someone at the door.",
+ "language": language,
+ }
- req = await client.post(url, json=data)
- assert req.status == HTTPStatus.OK
- response = await req.json()
+ req = await client.post(url, json=data)
+ assert req.status == HTTPStatus.OK
+ response = await req.json()
- assert response == {
- "url": (
- "http://example.local:8123/api/tts_proxy/"
- "42f18378fd4393d18c8dd11d03fa9563c1e54491"
- f"_{language.lower()}_6e8b81ac47_{expected_url_suffix}.mp3"
- ),
- "path": (
- "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491"
- f"_{language.lower()}_6e8b81ac47_{expected_url_suffix}.mp3"
- ),
- }
- await hass.async_block_till_done()
+ assert response == {
+ "url": ("http://example.local:8123/api/tts_proxy/test_token.mp3"),
+ "path": ("/api/tts_proxy/test_token.mp3"),
+ }
+ await hass.async_block_till_done()
assert mock_process_tts.call_count == 1
assert mock_process_tts.call_args is not None
@@ -667,22 +643,18 @@ async def test_deprecated_gender(
# Test with deprecated gender option.
data["options"] = {"gender": gender_option}
- req = await client.post(url, json=data)
- assert req.status == HTTPStatus.OK
- response = await req.json()
+ with patch(
+ "homeassistant.components.tts.secrets.token_urlsafe", return_value="test_token"
+ ):
+ req = await client.post(url, json=data)
+ assert req.status == HTTPStatus.OK
+ response = await req.json()
- assert response == {
- "url": (
- "http://example.local:8123/api/tts_proxy/"
- "42f18378fd4393d18c8dd11d03fa9563c1e54491"
- f"_{language.lower()}_dd0e95eb04_{expected_url_suffix}.mp3"
- ),
- "path": (
- "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491"
- f"_{language.lower()}_dd0e95eb04_{expected_url_suffix}.mp3"
- ),
- }
- await hass.async_block_till_done()
+ assert response == {
+ "url": ("http://example.local:8123/api/tts_proxy/test_token.mp3"),
+ "path": ("/api/tts_proxy/test_token.mp3"),
+ }
+ await hass.async_block_till_done()
issue_id = "deprecated_gender"
diff --git a/tests/components/co2signal/test_config_flow.py b/tests/components/co2signal/test_config_flow.py
index 92d9450b670..f8f94d44126 100644
--- a/tests/components/co2signal/test_config_flow.py
+++ b/tests/components/co2signal/test_config_flow.py
@@ -44,7 +44,7 @@ async def test_form_home(hass: HomeAssistant) -> None:
await hass.async_block_till_done()
assert result2["type"] is FlowResultType.CREATE_ENTRY
- assert result2["title"] == "CO2 Signal"
+ assert result2["title"] == "Electricity Maps"
assert result2["data"] == {
"api_key": "api_key",
}
@@ -185,7 +185,7 @@ async def test_form_error_handling(
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
- assert result["title"] == "CO2 Signal"
+ assert result["title"] == "Electricity Maps"
assert result["data"] == {
"api_key": "api_key",
}
diff --git a/tests/components/color_extractor/test_service.py b/tests/components/color_extractor/test_service.py
index 7b603420bdf..23ba5e7808c 100644
--- a/tests/components/color_extractor/test_service.py
+++ b/tests/components/color_extractor/test_service.py
@@ -78,7 +78,7 @@ async def setup_light(hass: HomeAssistant):
# Validate starting values
assert state.state == STATE_ON
assert state.attributes.get(ATTR_BRIGHTNESS) == 180
- assert state.attributes.get(ATTR_RGB_COLOR) == (255, 63, 111)
+ assert state.attributes.get(ATTR_RGB_COLOR) == (255, 64, 112)
await hass.services.async_call(
LIGHT_DOMAIN,
diff --git a/tests/components/command_line/test_binary_sensor.py b/tests/components/command_line/test_binary_sensor.py
index 5d1cd845e27..aa49410aacb 100644
--- a/tests/components/command_line/test_binary_sensor.py
+++ b/tests/components/command_line/test_binary_sensor.py
@@ -87,7 +87,7 @@ async def test_setup_platform_yaml(hass: HomeAssistant) -> None:
"payload_off": "0",
"value_template": "{{ value | multiply(0.1) }}",
"icon": (
- '{% if this.state=="on" %} mdi:on {% else %} mdi:off {% endif %}'
+ '{% if this.attributes.icon=="mdi:icon2" %} mdi:icon1 {% else %} mdi:icon2 {% endif %}'
),
}
}
@@ -101,7 +101,15 @@ async def test_template(hass: HomeAssistant, load_yaml_integration: None) -> Non
entity_state = hass.states.get("binary_sensor.test")
assert entity_state
assert entity_state.state == STATE_ON
- assert entity_state.attributes.get("icon") == "mdi:on"
+ assert entity_state.attributes.get("icon") == "mdi:icon2"
+
+ async_fire_time_changed(hass, dt_util.now() + timedelta(seconds=30))
+ await hass.async_block_till_done(wait_background_tasks=True)
+
+ entity_state = hass.states.get("binary_sensor.test")
+ assert entity_state
+ assert entity_state.state == STATE_ON
+ assert entity_state.attributes.get("icon") == "mdi:icon1"
@pytest.mark.parametrize(
diff --git a/tests/components/command_line/test_cover.py b/tests/components/command_line/test_cover.py
index da9d86ba8a5..426968eccc5 100644
--- a/tests/components/command_line/test_cover.py
+++ b/tests/components/command_line/test_cover.py
@@ -422,13 +422,19 @@ async def test_icon_template(hass: HomeAssistant) -> None:
"command_close": f"echo 0 > {path}",
"command_stop": f"echo 0 > {path}",
"name": "Test",
- "icon": "{% if this.state=='open' %} mdi:open {% else %} mdi:closed {% endif %}",
+ "icon": '{% if this.attributes.icon=="mdi:icon2" %} mdi:icon1 {% else %} mdi:icon2 {% endif %}',
}
}
]
},
)
await hass.async_block_till_done()
+ await hass.services.async_call(
+ COVER_DOMAIN,
+ SERVICE_OPEN_COVER,
+ {ATTR_ENTITY_ID: "cover.test"},
+ blocking=True,
+ )
await hass.services.async_call(
COVER_DOMAIN,
@@ -438,7 +444,7 @@ async def test_icon_template(hass: HomeAssistant) -> None:
)
entity_state = hass.states.get("cover.test")
assert entity_state
- assert entity_state.attributes.get("icon") == "mdi:closed"
+ assert entity_state.attributes.get("icon") == "mdi:icon1"
await hass.services.async_call(
COVER_DOMAIN,
@@ -448,4 +454,4 @@ async def test_icon_template(hass: HomeAssistant) -> None:
)
entity_state = hass.states.get("cover.test")
assert entity_state
- assert entity_state.attributes.get("icon") == "mdi:open"
+ assert entity_state.attributes.get("icon") == "mdi:icon2"
diff --git a/tests/components/command_line/test_switch.py b/tests/components/command_line/test_switch.py
index 549e729892c..d62410fa792 100644
--- a/tests/components/command_line/test_switch.py
+++ b/tests/components/command_line/test_switch.py
@@ -552,7 +552,7 @@ async def test_templating(hass: HomeAssistant) -> None:
"command_off": f"echo 0 > {path}",
"value_template": '{{ value=="1" }}',
"icon": (
- '{% if this.state=="on" %} mdi:on {% else %} mdi:off {% endif %}'
+ '{% if this.attributes.icon=="mdi:icon2" %} mdi:icon1 {% else %} mdi:icon2 {% endif %}'
),
"name": "Test",
}
@@ -564,7 +564,7 @@ async def test_templating(hass: HomeAssistant) -> None:
"command_off": f"echo 0 > {path}",
"value_template": '{{ value=="1" }}',
"icon": (
- '{% if states("switch.test2")=="on" %} mdi:on {% else %} mdi:off {% endif %}'
+ '{% if states("switch.test")=="off" %} mdi:off {% else %} mdi:on {% endif %}'
),
"name": "Test2",
},
@@ -595,7 +595,7 @@ async def test_templating(hass: HomeAssistant) -> None:
entity_state = hass.states.get("switch.test")
entity_state2 = hass.states.get("switch.test2")
assert entity_state.state == STATE_ON
- assert entity_state.attributes.get("icon") == "mdi:on"
+ assert entity_state.attributes.get("icon") == "mdi:icon2"
assert entity_state2.state == STATE_ON
assert entity_state2.attributes.get("icon") == "mdi:on"
diff --git a/tests/components/config/test_config_entries.py b/tests/components/config/test_config_entries.py
index b96aa9ae006..ee000c5ada2 100644
--- a/tests/components/config/test_config_entries.py
+++ b/tests/components/config/test_config_entries.py
@@ -255,9 +255,7 @@ async def test_get_entries(hass: HomeAssistant, client: TestClient) -> None:
async def test_remove_entry(hass: HomeAssistant, client: TestClient) -> None:
"""Test removing an entry via the API."""
- entry = MockConfigEntry(
- domain="kitchen_sink", state=core_ce.ConfigEntryState.LOADED
- )
+ entry = MockConfigEntry(domain="test", state=core_ce.ConfigEntryState.LOADED)
entry.add_to_hass(hass)
resp = await client.delete(f"/api/config/config_entries/entry/{entry.entry_id}")
assert resp.status == HTTPStatus.OK
@@ -268,11 +266,9 @@ async def test_remove_entry(hass: HomeAssistant, client: TestClient) -> None:
async def test_reload_entry(hass: HomeAssistant, client: TestClient) -> None:
"""Test reloading an entry via the API."""
- entry = MockConfigEntry(
- domain="kitchen_sink", state=core_ce.ConfigEntryState.LOADED
- )
+ entry = MockConfigEntry(domain="test", state=core_ce.ConfigEntryState.LOADED)
entry.add_to_hass(hass)
- hass.config.components.add("kitchen_sink")
+ hass.config.components.add("test")
resp = await client.post(
f"/api/config/config_entries/entry/{entry.entry_id}/reload"
)
@@ -409,7 +405,7 @@ async def test_initialize_flow(hass: HomeAssistant, client: TestClient) -> None:
return self.async_show_form(
step_id="user",
- data_schema=schema,
+ data_schema=vol.Schema(schema),
description_placeholders={
"url": "https://example.com",
"show_advanced_options": self.show_advanced_options,
@@ -792,7 +788,7 @@ async def test_get_progress_flow(hass: HomeAssistant, client: TestClient) -> Non
return self.async_show_form(
step_id="user",
- data_schema=schema,
+ data_schema=vol.Schema(schema),
errors={"username": "Should be unique."},
)
@@ -830,7 +826,7 @@ async def test_get_progress_flow_unauth(
return self.async_show_form(
step_id="user",
- data_schema=schema,
+ data_schema=vol.Schema(schema),
errors={"username": "Should be unique."},
)
@@ -862,7 +858,7 @@ async def test_options_flow(hass: HomeAssistant, client: TestClient) -> None:
schema[vol.Required("enabled")] = bool
return self.async_show_form(
step_id="user",
- data_schema=schema,
+ data_schema=vol.Schema(schema),
description_placeholders={"enabled": "Set to true to be true"},
)
@@ -1157,11 +1153,9 @@ async def test_update_prefrences(
assert await async_setup_component(hass, "config", {})
ws_client = await hass_ws_client(hass)
- entry = MockConfigEntry(
- domain="kitchen_sink", state=core_ce.ConfigEntryState.LOADED
- )
+ entry = MockConfigEntry(domain="test", state=core_ce.ConfigEntryState.LOADED)
entry.add_to_hass(hass)
- hass.config.components.add("kitchen_sink")
+ hass.config.components.add("test")
assert entry.pref_disable_new_entities is False
assert entry.pref_disable_polling is False
@@ -1257,12 +1251,10 @@ async def test_disable_entry(
assert await async_setup_component(hass, "config", {})
ws_client = await hass_ws_client(hass)
- entry = MockConfigEntry(
- domain="kitchen_sink", state=core_ce.ConfigEntryState.LOADED
- )
+ entry = MockConfigEntry(domain="test", state=core_ce.ConfigEntryState.LOADED)
entry.add_to_hass(hass)
assert entry.disabled_by is None
- hass.config.components.add("kitchen_sink")
+ hass.config.components.add("test")
# Disable
await ws_client.send_json(
diff --git a/tests/components/conftest.py b/tests/components/conftest.py
index 5535ec3b976..81f7b2044d6 100644
--- a/tests/components/conftest.py
+++ b/tests/components/conftest.py
@@ -2,7 +2,9 @@
from __future__ import annotations
-from collections.abc import Callable, Generator
+import asyncio
+from collections.abc import AsyncGenerator, Callable, Generator
+from functools import lru_cache
from importlib.util import find_spec
from pathlib import Path
import string
@@ -17,7 +19,9 @@ from aiohasupervisor.models import (
StoreInfo,
)
import pytest
+import voluptuous as vol
+from homeassistant.components import repairs
from homeassistant.config_entries import (
DISCOVERY_SOURCES,
ConfigEntriesFlowManager,
@@ -25,9 +29,18 @@ from homeassistant.config_entries import (
OptionsFlowManager,
)
from homeassistant.const import STATE_OFF, STATE_ON
-from homeassistant.core import HomeAssistant
-from homeassistant.data_entry_flow import FlowHandler, FlowManager, FlowResultType
+from homeassistant.core import Context, HomeAssistant, ServiceRegistry, ServiceResponse
+from homeassistant.data_entry_flow import (
+ FlowContext,
+ FlowHandler,
+ FlowManager,
+ FlowResultType,
+ section,
+)
+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
if TYPE_CHECKING:
from homeassistant.components.hassio import AddonManager
@@ -61,9 +74,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
@@ -503,10 +522,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.mounts = []
supervisor_client = AsyncMock()
supervisor_client.addons = AsyncMock()
supervisor_client.discovery = AsyncMock()
supervisor_client.homeassistant = AsyncMock()
+ supervisor_client.host = AsyncMock()
+ supervisor_client.mounts.info.return_value = mounts_info_mock
supervisor_client.os = AsyncMock()
supervisor_client.resolution = AsyncMock()
supervisor_client.supervisor = AsyncMock()
@@ -523,6 +546,10 @@ def supervisor_client() -> Generator[AsyncMock]:
"homeassistant.components.hassio.addon_manager.get_supervisor_client",
return_value=supervisor_client,
),
+ patch(
+ "homeassistant.components.hassio.backup.get_supervisor_client",
+ return_value=supervisor_client,
+ ),
patch(
"homeassistant.components.hassio.discovery.get_supervisor_client",
return_value=supervisor_client,
@@ -547,6 +574,7 @@ def _validate_translation_placeholders(
full_key: str,
translation: str,
description_placeholders: dict[str, str] | None,
+ translation_errors: dict[str, str],
) -> str | None:
"""Raise if translation exists with missing placeholders."""
tuples = list(string.Formatter().parse(translation))
@@ -557,14 +585,14 @@ def _validate_translation_placeholders(
description_placeholders is None
or placeholder not in description_placeholders
):
- pytest.fail(
+ translation_errors[full_key] = (
f"Description not found for placeholder `{placeholder}` in {full_key}"
)
-async def _ensure_translation_exists(
+async def _validate_translation(
hass: HomeAssistant,
- ignore_translations: dict[str, StoreInfo],
+ translation_errors: dict[str, str],
category: str,
component: str,
key: str,
@@ -577,18 +605,18 @@ async def _ensure_translation_exists(
translations = await async_get_translations(hass, "en", category, [component])
if (translation := translations.get(full_key)) is not None:
_validate_translation_placeholders(
- full_key, translation, description_placeholders
+ full_key, translation, description_placeholders, translation_errors
)
return
if not translation_required:
return
- if full_key in ignore_translations:
- ignore_translations[full_key] = "used"
+ if full_key in translation_errors:
+ translation_errors[full_key] = "used"
return
- pytest.fail(
+ translation_errors[full_key] = (
f"Translation not found for {component}: `{category}.{key}`. "
f"Please add to homeassistant/components/{component}/strings.json"
)
@@ -604,84 +632,291 @@ def ignore_translations() -> str | list[str]:
return []
+@lru_cache
+def _get_integration_quality_scale(integration: str) -> dict[str, Any]:
+ """Get the quality scale for an integration."""
+ try:
+ return yaml.load_yaml_dict(
+ f"homeassistant/components/{integration}/quality_scale.yaml"
+ ).get("rules", {})
+ except FileNotFoundError:
+ return {}
+
+
+def _get_integration_quality_scale_rule(integration: str, rule: str) -> str:
+ """Get the quality scale for an integration."""
+ quality_scale = _get_integration_quality_scale(integration)
+ if not quality_scale or rule not in quality_scale:
+ return "todo"
+ status = quality_scale[rule]
+ return status if isinstance(status, str) else status["status"]
+
+
+async def _check_step_or_section_translations(
+ hass: HomeAssistant,
+ translation_errors: dict[str, str],
+ category: str,
+ integration: str,
+ translation_prefix: str,
+ description_placeholders: dict[str, str],
+ data_schema: vol.Schema | None,
+) -> None:
+ # neither title nor description are required
+ # - title defaults to integration name
+ # - description is optional
+ for header in ("title", "description"):
+ await _validate_translation(
+ hass,
+ translation_errors,
+ category,
+ integration,
+ f"{translation_prefix}.{header}",
+ description_placeholders,
+ translation_required=False,
+ )
+
+ if not data_schema:
+ return
+
+ for data_key, data_value in data_schema.schema.items():
+ if isinstance(data_value, section):
+ # check the nested section
+ await _check_step_or_section_translations(
+ hass,
+ translation_errors,
+ category,
+ integration,
+ f"{translation_prefix}.sections.{data_key}",
+ description_placeholders,
+ data_value.schema,
+ )
+ continue
+ iqs_config_flow = _get_integration_quality_scale_rule(
+ integration, "config-flow"
+ )
+ # data and data_description are compulsory
+ for header in ("data", "data_description"):
+ await _validate_translation(
+ hass,
+ translation_errors,
+ category,
+ integration,
+ f"{translation_prefix}.{header}.{data_key}",
+ description_placeholders,
+ translation_required=(iqs_config_flow == "done"),
+ )
+
+
+async def _check_config_flow_result_translations(
+ manager: FlowManager,
+ flow: FlowHandler,
+ result: FlowResult[FlowContext, str],
+ translation_errors: dict[str, str],
+) -> None:
+ if result["type"] is FlowResultType.CREATE_ENTRY:
+ # No need to check translations for a completed flow
+ return
+
+ key_prefix = ""
+ if isinstance(manager, ConfigEntriesFlowManager):
+ category = "config"
+ integration = flow.handler
+ elif isinstance(manager, OptionsFlowManager):
+ category = "options"
+ integration = flow.hass.config_entries.async_get_entry(flow.handler).domain
+ elif isinstance(manager, repairs.RepairsFlowManager):
+ category = "issues"
+ integration = flow.handler
+ issue_id = flow.issue_id
+ issue = ir.async_get(flow.hass).async_get_issue(integration, issue_id)
+ key_prefix = f"{issue.translation_key}.fix_flow."
+ else:
+ return
+
+ # Check if this flow has been seen before
+ # Gets set to False on first run, and to True on subsequent runs
+ setattr(flow, "__flow_seen_before", hasattr(flow, "__flow_seen_before"))
+
+ if result["type"] is FlowResultType.FORM:
+ if step_id := result.get("step_id"):
+ await _check_step_or_section_translations(
+ flow.hass,
+ translation_errors,
+ category,
+ integration,
+ f"{key_prefix}step.{step_id}",
+ result["description_placeholders"],
+ result["data_schema"],
+ )
+
+ if errors := result.get("errors"):
+ for error in errors.values():
+ await _validate_translation(
+ flow.hass,
+ translation_errors,
+ category,
+ integration,
+ f"{key_prefix}error.{error}",
+ result["description_placeholders"],
+ )
+ return
+
+ if result["type"] is FlowResultType.ABORT:
+ # We don't need translations for a discovery flow which immediately
+ # aborts, since such flows won't be seen by users
+ if not flow.__flow_seen_before and flow.source in DISCOVERY_SOURCES:
+ return
+ await _validate_translation(
+ flow.hass,
+ translation_errors,
+ category,
+ integration,
+ f"{key_prefix}abort.{result["reason"]}",
+ result["description_placeholders"],
+ )
+
+
+async def _check_create_issue_translations(
+ issue_registry: ir.IssueRegistry,
+ issue: ir.IssueEntry,
+ translation_errors: dict[str, str],
+) -> None:
+ if issue.translation_key is None:
+ # `translation_key` is only None on dismissed issues
+ return
+ await _validate_translation(
+ issue_registry.hass,
+ translation_errors,
+ "issues",
+ issue.domain,
+ f"{issue.translation_key}.title",
+ issue.translation_placeholders,
+ )
+ if not issue.is_fixable:
+ # Description is required for non-fixable issues
+ await _validate_translation(
+ issue_registry.hass,
+ translation_errors,
+ "issues",
+ issue.domain,
+ f"{issue.translation_key}.description",
+ issue.translation_placeholders,
+ )
+
+
+async def _check_exception_translation(
+ hass: HomeAssistant,
+ exception: HomeAssistantError,
+ translation_errors: dict[str, str],
+) -> None:
+ if exception.translation_key is None:
+ return
+ await _validate_translation(
+ hass,
+ translation_errors,
+ "exceptions",
+ exception.translation_domain,
+ f"{exception.translation_key}.message",
+ exception.translation_placeholders,
+ )
+
+
@pytest.fixture(autouse=True)
-def check_config_translations(ignore_translations: str | list[str]) -> Generator[None]:
- """Ensure config_flow translations are available."""
+async def check_translations(
+ ignore_translations: str | list[str],
+) -> AsyncGenerator[None]:
+ """Check that translation requirements are met.
+
+ Current checks:
+ - data entry flow results (ConfigFlow/OptionsFlow/RepairFlow)
+ - issue registry entries
+ """
if not isinstance(ignore_translations, list):
ignore_translations = [ignore_translations]
- _ignore_translations = {k: "unused" for k in ignore_translations}
- _original = FlowManager._async_handle_step
+ translation_errors = {k: "unused" for k in ignore_translations}
- async def _async_handle_step(
+ translation_coros = set()
+
+ # Keep reference to original functions
+ _original_flow_manager_async_handle_step = FlowManager._async_handle_step
+ _original_issue_registry_async_create_issue = ir.IssueRegistry.async_get_or_create
+ _original_service_registry_async_call = ServiceRegistry.async_call
+
+ # Prepare override functions
+ async def _flow_manager_async_handle_step(
self: FlowManager, flow: FlowHandler, *args
) -> FlowResult:
- result = await _original(self, flow, *args)
- if isinstance(self, ConfigEntriesFlowManager):
- category = "config"
- component = flow.handler
- elif isinstance(self, OptionsFlowManager):
- category = "options"
- component = flow.hass.config_entries.async_get_entry(flow.handler).domain
- else:
- return result
-
- # Check if this flow has been seen before
- # Gets set to False on first run, and to True on subsequent runs
- setattr(flow, "__flow_seen_before", hasattr(flow, "__flow_seen_before"))
-
- if result["type"] is FlowResultType.FORM:
- if step_id := result.get("step_id"):
- # neither title nor description are required
- # - title defaults to integration name
- # - description is optional
- for header in ("title", "description"):
- await _ensure_translation_exists(
- flow.hass,
- _ignore_translations,
- category,
- component,
- f"step.{step_id}.{header}",
- result["description_placeholders"],
- translation_required=False,
- )
- if errors := result.get("errors"):
- for error in errors.values():
- await _ensure_translation_exists(
- flow.hass,
- _ignore_translations,
- category,
- component,
- f"error.{error}",
- result["description_placeholders"],
- )
- return result
-
- if result["type"] is FlowResultType.ABORT:
- # We don't need translations for a discovery flow which immediately
- # aborts, since such flows won't be seen by users
- if not flow.__flow_seen_before and flow.source in DISCOVERY_SOURCES:
- return result
- await _ensure_translation_exists(
- flow.hass,
- _ignore_translations,
- category,
- component,
- f"abort.{result["reason"]}",
- result["description_placeholders"],
- )
-
+ result = await _original_flow_manager_async_handle_step(self, flow, *args)
+ await _check_config_flow_result_translations(
+ self, flow, result, translation_errors
+ )
return result
- with patch(
- "homeassistant.data_entry_flow.FlowManager._async_handle_step",
- _async_handle_step,
+ def _issue_registry_async_create_issue(
+ self: ir.IssueRegistry, domain: str, issue_id: str, *args, **kwargs
+ ) -> None:
+ result = _original_issue_registry_async_create_issue(
+ self, domain, issue_id, *args, **kwargs
+ )
+ translation_coros.add(
+ _check_create_issue_translations(self, result, translation_errors)
+ )
+ return result
+
+ async def _service_registry_async_call(
+ self: ServiceRegistry,
+ domain: str,
+ service: str,
+ service_data: dict[str, Any] | None = None,
+ blocking: bool = False,
+ context: Context | None = None,
+ target: dict[str, Any] | None = None,
+ return_response: bool = False,
+ ) -> ServiceResponse:
+ try:
+ return await _original_service_registry_async_call(
+ self,
+ domain,
+ service,
+ service_data,
+ blocking,
+ context,
+ target,
+ return_response,
+ )
+ except HomeAssistantError as err:
+ translation_coros.add(
+ _check_exception_translation(self._hass, err, translation_errors)
+ )
+ raise
+
+ # Use override functions
+ with (
+ patch(
+ "homeassistant.data_entry_flow.FlowManager._async_handle_step",
+ _flow_manager_async_handle_step,
+ ),
+ patch(
+ "homeassistant.helpers.issue_registry.IssueRegistry.async_get_or_create",
+ _issue_registry_async_create_issue,
+ ),
+ patch(
+ "homeassistant.core.ServiceRegistry.async_call",
+ _service_registry_async_call,
+ ),
):
yield
- unused_ignore = [k for k, v in _ignore_translations.items() if v == "unused"]
+ await asyncio.gather(*translation_coros)
+
+ # Run final checks
+ unused_ignore = [k for k, v in translation_errors.items() if v == "unused"]
if unused_ignore:
pytest.fail(
f"Unused ignore translations: {', '.join(unused_ignore)}. "
"Please remove them from the ignore_translations fixture."
)
+ for description in translation_errors.values():
+ if description not in {"used", "unused"}:
+ pytest.fail(description)
diff --git a/tests/components/conversation/snapshots/test_default_agent.ambr b/tests/components/conversation/snapshots/test_default_agent.ambr
index b1f2ea0db75..f1e220b10b2 100644
--- a/tests/components/conversation/snapshots/test_default_agent.ambr
+++ b/tests/components/conversation/snapshots/test_default_agent.ambr
@@ -308,7 +308,7 @@
'speech': dict({
'plain': dict({
'extra_data': None,
- 'speech': 'Sorry, I am not aware of any device called late added light',
+ 'speech': 'Sorry, I am not aware of any area called late added',
}),
}),
}),
@@ -378,7 +378,7 @@
'speech': dict({
'plain': dict({
'extra_data': None,
- 'speech': 'Sorry, I am not aware of any device called kitchen light',
+ 'speech': 'Sorry, I am not aware of any area called kitchen',
}),
}),
}),
@@ -428,7 +428,7 @@
'speech': dict({
'plain': dict({
'extra_data': None,
- 'speech': 'Sorry, I am not aware of any device called renamed light',
+ 'speech': 'Sorry, I am not aware of any area called renamed',
}),
}),
}),
diff --git a/tests/components/conversation/snapshots/test_http.ambr b/tests/components/conversation/snapshots/test_http.ambr
index 08aca43aba5..0de575790db 100644
--- a/tests/components/conversation/snapshots/test_http.ambr
+++ b/tests/components/conversation/snapshots/test_http.ambr
@@ -6,7 +6,6 @@
'id': 'conversation.home_assistant',
'name': 'Home Assistant',
'supported_languages': list([
- 'af',
'ar',
'bg',
'bn',
@@ -24,7 +23,6 @@
'fi',
'fr',
'gl',
- 'gu',
'he',
'hi',
'hr',
@@ -33,7 +31,6 @@
'is',
'it',
'ka',
- 'kn',
'ko',
'lb',
'lt',
@@ -42,6 +39,7 @@
'mn',
'ms',
'nb',
+ 'ne',
'nl',
'pl',
'pt',
@@ -541,7 +539,7 @@
'name': 'HassTurnOn',
}),
'match': True,
- 'sentence_template': ' on [all] in ',
+ 'sentence_template': ' on [] ',
'slots': dict({
'area': 'kitchen',
'domain': 'light',
@@ -577,7 +575,7 @@
'name': 'HassGetState',
}),
'match': True,
- 'sentence_template': '[tell me] how many {on_off_domains:domain} (is|are) {on_off_states:state} [in ]',
+ 'sentence_template': '[tell me] how many {on_off_domains:domain} (is|are) {on_off_states:state} []',
'slots': dict({
'area': 'kitchen',
'domain': 'lights',
@@ -612,7 +610,7 @@
'name': 'OrderBeer',
}),
'match': True,
- 'sentence_template': "I'd like to order a {beer_style} [please]",
+ 'sentence_template': "[I'd like to ]order a {beer_style} [please]",
'slots': dict({
'beer_style': 'lager',
}),
@@ -639,7 +637,7 @@
'details': dict({
'brightness': dict({
'name': 'brightness',
- 'text': '100%',
+ 'text': '100',
'value': 100,
}),
'name': dict({
@@ -654,7 +652,7 @@
'match': True,
'sentence_template': '[] brightness [to] ',
'slots': dict({
- 'brightness': '100%',
+ 'brightness': '100',
'name': 'test light',
}),
'source': 'builtin',
@@ -699,6 +697,14 @@
})
# ---
# name: test_ws_hass_agent_debug_sentence_trigger
+ dict({
+ 'trigger_sentences': list([
+ 'hello',
+ 'hello[ world]',
+ ]),
+ })
+# ---
+# name: test_ws_hass_agent_debug_sentence_trigger.1
dict({
'results': list([
dict({
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 14a9b0ca88c..7e05476a349 100644
--- a/tests/components/conversation/test_default_agent.py
+++ b/tests/components/conversation/test_default_agent.py
@@ -30,6 +30,7 @@ from homeassistant.const import (
ATTR_DEVICE_CLASS,
ATTR_FRIENDLY_NAME,
STATE_CLOSED,
+ STATE_OFF,
STATE_ON,
STATE_UNKNOWN,
EntityCategory,
@@ -397,7 +398,7 @@ async def test_trigger_sentences(hass: HomeAssistant) -> None:
callback.reset_mock()
result = await conversation.async_converse(hass, sentence, None, Context())
assert callback.call_count == 1
- assert callback.call_args[0][0] == sentence
+ assert callback.call_args[0][0].text == sentence
assert (
result.response.response_type == intent.IntentResponseType.ACTION_DONE
), sentence
@@ -418,6 +419,44 @@ async def test_trigger_sentences(hass: HomeAssistant) -> None:
assert len(callback.mock_calls) == 0
+@pytest.mark.parametrize(
+ ("language", "expected"),
+ [("en", "English done"), ("de", "German done"), ("not_translated", "Done")],
+)
+@pytest.mark.usefixtures("init_components")
+async def test_trigger_sentence_response_translation(
+ hass: HomeAssistant, language: str, expected: str
+) -> None:
+ """Test translation of default response 'done'."""
+ hass.config.language = language
+
+ agent = hass.data[DATA_DEFAULT_ENTITY]
+ assert isinstance(agent, default_agent.DefaultAgent)
+
+ translations = {
+ "en": {"component.conversation.conversation.agent.done": "English done"},
+ "de": {"component.conversation.conversation.agent.done": "German done"},
+ "not_translated": {},
+ }
+
+ with patch(
+ "homeassistant.components.conversation.default_agent.translation.async_get_translations",
+ return_value=translations.get(language),
+ ):
+ unregister = agent.register_trigger(
+ ["test sentence"], AsyncMock(return_value=None)
+ )
+ result = await conversation.async_converse(
+ hass, "test sentence", None, Context()
+ )
+ assert result.response.response_type == intent.IntentResponseType.ACTION_DONE
+ assert result.response.speech == {
+ "plain": {"speech": expected, "extra_data": None}
+ }
+
+ unregister()
+
+
@pytest.mark.usefixtures("init_components", "sl_setup")
async def test_shopping_list_add_item(hass: HomeAssistant) -> None:
"""Test adding an item to the shopping list through the default agent."""
@@ -732,8 +771,8 @@ async def test_error_no_device_on_floor_exposed(
)
with patch(
- "homeassistant.components.conversation.default_agent.recognize_all",
- return_value=[recognize_result],
+ "homeassistant.components.conversation.default_agent.recognize_best",
+ return_value=recognize_result,
):
result = await conversation.async_converse(
hass, "turn on test light on the ground floor", None, Context(), None
@@ -800,8 +839,8 @@ async def test_error_no_domain(hass: HomeAssistant) -> None:
)
with patch(
- "homeassistant.components.conversation.default_agent.recognize_all",
- return_value=[recognize_result],
+ "homeassistant.components.conversation.default_agent.recognize_best",
+ return_value=recognize_result,
):
result = await conversation.async_converse(
hass, "turn on the fans", None, Context(), None
@@ -835,8 +874,8 @@ async def test_error_no_domain_exposed(hass: HomeAssistant) -> None:
)
with patch(
- "homeassistant.components.conversation.default_agent.recognize_all",
- return_value=[recognize_result],
+ "homeassistant.components.conversation.default_agent.recognize_best",
+ return_value=recognize_result,
):
result = await conversation.async_converse(
hass, "turn on the fans", None, Context(), None
@@ -1009,8 +1048,8 @@ async def test_error_no_device_class(hass: HomeAssistant) -> None:
)
with patch(
- "homeassistant.components.conversation.default_agent.recognize_all",
- return_value=[recognize_result],
+ "homeassistant.components.conversation.default_agent.recognize_best",
+ return_value=recognize_result,
):
result = await conversation.async_converse(
hass, "open the windows", None, Context(), None
@@ -1058,8 +1097,8 @@ async def test_error_no_device_class_exposed(hass: HomeAssistant) -> None:
)
with patch(
- "homeassistant.components.conversation.default_agent.recognize_all",
- return_value=[recognize_result],
+ "homeassistant.components.conversation.default_agent.recognize_best",
+ return_value=recognize_result,
):
result = await conversation.async_converse(
hass, "open all the windows", None, Context(), None
@@ -1169,8 +1208,8 @@ async def test_error_no_device_class_on_floor_exposed(
)
with patch(
- "homeassistant.components.conversation.default_agent.recognize_all",
- return_value=[recognize_result],
+ "homeassistant.components.conversation.default_agent.recognize_best",
+ return_value=recognize_result,
):
result = await conversation.async_converse(
hass, "open ground floor windows", None, Context(), None
@@ -1191,8 +1230,8 @@ async def test_error_no_device_class_on_floor_exposed(
async def test_error_no_intent(hass: HomeAssistant) -> None:
"""Test response with an intent match failure."""
with patch(
- "homeassistant.components.conversation.default_agent.recognize_all",
- return_value=[],
+ "homeassistant.components.conversation.default_agent.recognize_best",
+ return_value=None,
):
result = await conversation.async_converse(
hass, "do something", None, Context(), None
@@ -1697,7 +1736,7 @@ async def test_empty_aliases(
return_value=None,
) as mock_recognize_all:
await conversation.async_converse(
- hass, "turn on lights in the kitchen", None, Context(), None
+ hass, "turn on kitchen light", None, Context(), None
)
assert mock_recognize_all.call_count > 0
@@ -2795,3 +2834,273 @@ async def test_query_same_name_different_areas(
assert result.response.response_type == intent.IntentResponseType.QUERY_ANSWER
assert len(result.response.matched_states) == 1
assert result.response.matched_states[0].entity_id == kitchen_light.entity_id
+
+
+@pytest.mark.usefixtures("init_components")
+async def test_intent_cache_exposed(hass: HomeAssistant) -> None:
+ """Test that intent recognition results are cached for exposed entities."""
+ agent = hass.data[DATA_DEFAULT_ENTITY]
+ assert isinstance(agent, default_agent.DefaultAgent)
+
+ entity_id = "light.test_light"
+ hass.states.async_set(entity_id, "off")
+ expose_entity(hass, entity_id, True)
+ await hass.async_block_till_done()
+
+ user_input = ConversationInput(
+ text="turn on test light",
+ context=Context(),
+ conversation_id=None,
+ device_id=None,
+ language=hass.config.language,
+ agent_id=None,
+ )
+ result = await agent.async_recognize_intent(user_input)
+ assert result is not None
+ assert result.entities["name"].text == "test light"
+
+ # Mark this result so we know it is from cache next time
+ mark = "_from_cache"
+ setattr(result, mark, True)
+
+ # Should be from cache this time
+ result = await agent.async_recognize_intent(user_input)
+ assert result is not None
+ assert getattr(result, mark, None) is True
+
+ # Unexposing clears the cache
+ expose_entity(hass, entity_id, False)
+ result = await agent.async_recognize_intent(user_input)
+ assert result is not None
+ assert getattr(result, mark, None) is None
+
+
+@pytest.mark.usefixtures("init_components")
+async def test_intent_cache_all_entities(hass: HomeAssistant) -> None:
+ """Test that intent recognition results are cached for all entities."""
+ agent = hass.data[DATA_DEFAULT_ENTITY]
+ assert isinstance(agent, default_agent.DefaultAgent)
+
+ entity_id = "light.test_light"
+ hass.states.async_set(entity_id, "off")
+ expose_entity(hass, entity_id, False) # not exposed
+ await hass.async_block_till_done()
+
+ user_input = ConversationInput(
+ text="turn on test light",
+ context=Context(),
+ conversation_id=None,
+ device_id=None,
+ language=hass.config.language,
+ agent_id=None,
+ )
+ result = await agent.async_recognize_intent(user_input)
+ assert result is not None
+ assert result.entities["name"].text == "test light"
+
+ # Mark this result so we know it is from cache next time
+ mark = "_from_cache"
+ setattr(result, mark, True)
+
+ # Should be from cache this time
+ result = await agent.async_recognize_intent(user_input)
+ assert result is not None
+ assert getattr(result, mark, None) is True
+
+ # Adding a new entity clears the cache
+ hass.states.async_set("light.new_light", "off")
+ result = await agent.async_recognize_intent(user_input)
+ assert result is not None
+ assert getattr(result, mark, None) is None
+
+
+@pytest.mark.usefixtures("init_components")
+async def test_intent_cache_fuzzy(hass: HomeAssistant) -> None:
+ """Test that intent recognition results are cached for fuzzy matches."""
+ agent = hass.data[DATA_DEFAULT_ENTITY]
+ assert isinstance(agent, default_agent.DefaultAgent)
+
+ # There is no entity named test light
+ user_input = ConversationInput(
+ text="turn on test light",
+ context=Context(),
+ conversation_id=None,
+ device_id=None,
+ language=hass.config.language,
+ agent_id=None,
+ )
+ result = await agent.async_recognize_intent(user_input)
+ assert result is not None
+ assert result.unmatched_entities["area"].text == "test "
+
+ # Mark this result so we know it is from cache next time
+ mark = "_from_cache"
+ setattr(result, mark, True)
+
+ # Should be from cache this time
+ result = await agent.async_recognize_intent(user_input)
+ assert result is not None
+ assert getattr(result, mark, None) is True
+
+
+@pytest.mark.usefixtures("init_components")
+async def test_entities_filtered_by_input(hass: HomeAssistant) -> None:
+ """Test that entities are filtered by the input text before intent matching."""
+ agent = hass.data[DATA_DEFAULT_ENTITY]
+ assert isinstance(agent, default_agent.DefaultAgent)
+
+ # Only the switch is exposed
+ hass.states.async_set("light.test_light", "off")
+ hass.states.async_set(
+ "light.test_light_2", "off", attributes={ATTR_FRIENDLY_NAME: "test light"}
+ )
+ hass.states.async_set("cover.garage_door", "closed")
+ hass.states.async_set("switch.test_switch", "off")
+ expose_entity(hass, "light.test_light", False)
+ expose_entity(hass, "light.test_light_2", False)
+ expose_entity(hass, "cover.garage_door", False)
+ expose_entity(hass, "switch.test_switch", True)
+ await hass.async_block_till_done()
+
+ # test switch is exposed
+ user_input = ConversationInput(
+ text="turn on test switch",
+ context=Context(),
+ conversation_id=None,
+ device_id=None,
+ language=hass.config.language,
+ agent_id=None,
+ )
+
+ with patch(
+ "homeassistant.components.conversation.default_agent.recognize_best",
+ return_value=None,
+ ) as recognize_best:
+ await agent.async_recognize_intent(user_input)
+
+ # (1) exposed, (2) all entities
+ assert len(recognize_best.call_args_list) == 2
+
+ # Only the test light should have been considered because its name shows
+ # up in the input text.
+ slot_lists = recognize_best.call_args_list[0].kwargs["slot_lists"]
+ name_list = slot_lists["name"]
+ assert len(name_list.values) == 1
+ assert name_list.values[0].text_in.text == "test switch"
+
+ # test light is not exposed
+ user_input = ConversationInput(
+ text="turn on Test Light", # different casing for name
+ context=Context(),
+ conversation_id=None,
+ device_id=None,
+ language=hass.config.language,
+ agent_id=None,
+ )
+
+ with patch(
+ "homeassistant.components.conversation.default_agent.recognize_best",
+ return_value=None,
+ ) as recognize_best:
+ await agent.async_recognize_intent(user_input)
+
+ # (1) exposed, (2) all entities
+ assert len(recognize_best.call_args_list) == 2
+
+ # Both test lights should have been considered because their name shows
+ # up in the input text.
+ slot_lists = recognize_best.call_args_list[1].kwargs["slot_lists"]
+ name_list = slot_lists["name"]
+ assert len(name_list.values) == 2
+ assert name_list.values[0].text_in.text == "test light"
+ assert name_list.values[1].text_in.text == "test light"
+
+
+@pytest.mark.usefixtures("init_components")
+async def test_entities_names_are_not_templates(hass: HomeAssistant) -> None:
+ """Test that entities names are not treated as hassil templates."""
+ # Contains hassil template characters
+ hass.states.async_set(
+ "light.test_light", "off", attributes={ATTR_FRIENDLY_NAME: " None:
+ """Test turn on/off in multiple languages."""
+ entity_id = "light.light1234"
+ hass.states.async_set(
+ entity_id, STATE_OFF, attributes={ATTR_FRIENDLY_NAME: light_name}
+ )
+
+ on_calls = async_mock_service(hass, LIGHT_DOMAIN, "turn_on")
+ await conversation.async_converse(
+ hass,
+ on_sentence,
+ None,
+ Context(),
+ language=language,
+ )
+ assert len(on_calls) == 1
+ assert on_calls[0].data.get("entity_id") == [entity_id]
+
+ off_calls = async_mock_service(hass, LIGHT_DOMAIN, "turn_off")
+ await conversation.async_converse(
+ hass,
+ off_sentence,
+ None,
+ Context(),
+ language=language,
+ )
+ assert len(off_calls) == 1
+ assert off_calls[0].data.get("entity_id") == [entity_id]
diff --git a/tests/components/conversation/test_default_agent_intents.py b/tests/components/conversation/test_default_agent_intents.py
index 7bae9c43f70..244fa6bda7b 100644
--- a/tests/components/conversation/test_default_agent_intents.py
+++ b/tests/components/conversation/test_default_agent_intents.py
@@ -36,6 +36,7 @@ from homeassistant.helpers import (
intent,
)
from homeassistant.setup import async_setup_component
+from homeassistant.util import dt as dt_util
from tests.common import async_mock_service
@@ -445,12 +446,22 @@ async def test_todo_add_item_fr(
assert intent_obj.slots.get("item", {}).get("value", "").strip() == "farine"
-@freeze_time(datetime(year=2013, month=9, day=17, hour=1, minute=2))
+@freeze_time(
+ datetime(
+ year=2013,
+ month=9,
+ day=17,
+ hour=1,
+ minute=2,
+ tzinfo=dt_util.UTC,
+ )
+)
async def test_date_time(
hass: HomeAssistant,
init_components,
) -> None:
"""Test the date and time intents."""
+ await hass.config.async_set_time_zone("UTC")
result = await conversation.async_converse(
hass, "what is the date", None, Context(), None
)
diff --git a/tests/components/conversation/test_http.py b/tests/components/conversation/test_http.py
index 5b6f7072a2d..6d69ec3c739 100644
--- a/tests/components/conversation/test_http.py
+++ b/tests/components/conversation/test_http.py
@@ -355,15 +355,15 @@ async def test_ws_hass_agent_debug_null_result(
"""Test homeassistant agent debug websocket command with a null result."""
client = await hass_ws_client(hass)
- async def async_recognize(self, user_input, *args, **kwargs):
+ async def async_recognize_intent(self, user_input, *args, **kwargs):
if user_input.text == "bad sentence":
return None
return await self.async_recognize(user_input, *args, **kwargs)
with patch(
- "homeassistant.components.conversation.default_agent.DefaultAgent.async_recognize",
- async_recognize,
+ "homeassistant.components.conversation.default_agent.DefaultAgent.async_recognize_intent",
+ async_recognize_intent,
):
await client.send_json_auto_id(
{
@@ -501,6 +501,19 @@ async def test_ws_hass_agent_debug_sentence_trigger(
client = await hass_ws_client(hass)
+ # List sentence
+ await client.send_json_auto_id(
+ {
+ "type": "conversation/sentences/list",
+ }
+ )
+ await hass.async_block_till_done()
+
+ msg = await client.receive_json()
+
+ assert msg["success"]
+ assert msg["result"] == snapshot
+
# Use trigger sentence
await client.send_json_auto_id(
{
diff --git a/tests/components/conversation/test_init.py b/tests/components/conversation/test_init.py
index e92b1ab538f..6900ba2d419 100644
--- a/tests/components/conversation/test_init.py
+++ b/tests/components/conversation/test_init.py
@@ -8,10 +8,15 @@ from syrupy.assertion import SnapshotAssertion
import voluptuous as vol
from homeassistant.components import conversation
-from homeassistant.components.conversation import default_agent
+from homeassistant.components.conversation import (
+ ConversationInput,
+ async_handle_intents,
+ async_handle_sentence_triggers,
+ default_agent,
+)
from homeassistant.components.conversation.const import DATA_DEFAULT_ENTITY
from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN
-from homeassistant.core import HomeAssistant
+from homeassistant.core import Context, HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import intent
from homeassistant.setup import async_setup_component
@@ -229,3 +234,97 @@ async def test_prepare_agent(
await conversation.async_prepare_agent(hass, agent_id, "en")
assert len(mock_prepare.mock_calls) == 1
+
+
+@pytest.mark.parametrize(
+ ("response_template", "expected_response"),
+ [("response {{ trigger.device_id }}", "response 1234"), ("", "")],
+)
+async def test_async_handle_sentence_triggers(
+ hass: HomeAssistant, response_template: str, expected_response: str
+) -> None:
+ """Test handling sentence triggers with async_handle_sentence_triggers."""
+ assert await async_setup_component(hass, "homeassistant", {})
+ assert await async_setup_component(hass, "conversation", {})
+
+ assert await async_setup_component(
+ hass,
+ "automation",
+ {
+ "automation": {
+ "trigger": {
+ "platform": "conversation",
+ "command": ["my trigger"],
+ },
+ "action": {
+ "set_conversation_response": response_template,
+ },
+ }
+ },
+ )
+
+ # Device id will be available in response template
+ device_id = "1234"
+ actual_response = await async_handle_sentence_triggers(
+ hass,
+ ConversationInput(
+ text="my trigger",
+ context=Context(),
+ conversation_id=None,
+ device_id=device_id,
+ language=hass.config.language,
+ ),
+ )
+ assert actual_response == expected_response
+
+
+async def test_async_handle_intents(hass: HomeAssistant) -> None:
+ """Test handling registered intents with async_handle_intents."""
+ assert await async_setup_component(hass, "homeassistant", {})
+ assert await async_setup_component(hass, "conversation", {})
+
+ # Reuse custom sentences in test config to trigger default agent.
+ class OrderBeerIntentHandler(intent.IntentHandler):
+ intent_type = "OrderBeer"
+
+ def __init__(self) -> None:
+ super().__init__()
+ self.was_handled = False
+
+ async def async_handle(
+ self, intent_obj: intent.Intent
+ ) -> intent.IntentResponse:
+ self.was_handled = True
+ return intent_obj.create_response()
+
+ handler = OrderBeerIntentHandler()
+ intent.async_register(hass, handler)
+
+ # Registered intent will be handled
+ result = await async_handle_intents(
+ hass,
+ ConversationInput(
+ text="I'd like to order a stout",
+ context=Context(),
+ conversation_id=None,
+ device_id=None,
+ language=hass.config.language,
+ ),
+ )
+ assert result is not None
+ assert result.intent is not None
+ assert result.intent.intent_type == handler.intent_type
+ assert handler.was_handled
+
+ # No error messages, just None as a result
+ result = await async_handle_intents(
+ hass,
+ ConversationInput(
+ text="this sentence does not exist",
+ context=Context(),
+ conversation_id=None,
+ device_id=None,
+ language=hass.config.language,
+ ),
+ )
+ assert result is None
diff --git a/tests/components/conversation/test_trace.py b/tests/components/conversation/test_trace.py
index 59cd10d2510..7c00b9a80b2 100644
--- a/tests/components/conversation/test_trace.py
+++ b/tests/components/conversation/test_trace.py
@@ -56,7 +56,7 @@ async def test_converation_trace(
"intent_name": "HassListAddItem",
"slots": {
"name": "Shopping List",
- "item": "apples ",
+ "item": "apples",
},
}
diff --git a/tests/components/conversation/test_trigger.py b/tests/components/conversation/test_trigger.py
index 903bc405cf0..9b57bb43b58 100644
--- a/tests/components/conversation/test_trigger.py
+++ b/tests/components/conversation/test_trigger.py
@@ -40,18 +40,31 @@ async def test_if_fires_on_event(
},
"action": {
"service": "test.automation",
- "data_template": {"data": "{{ trigger }}"},
+ "data": {
+ "data": {
+ "alias": "{{ trigger.alias }}",
+ "id": "{{ trigger.id }}",
+ "idx": "{{ trigger.idx }}",
+ "platform": "{{ trigger.platform }}",
+ "sentence": "{{ trigger.sentence }}",
+ "slots": "{{ trigger.slots }}",
+ "details": "{{ trigger.details }}",
+ "device_id": "{{ trigger.device_id }}",
+ "user_input": "{{ trigger.user_input }}",
+ }
+ },
},
}
},
)
-
+ context = Context()
service_response = await hass.services.async_call(
"conversation",
"process",
{"text": "Ha ha ha"},
blocking=True,
return_response=True,
+ context=context,
)
assert service_response["response"]["speech"]["plain"]["speech"] == "Done"
@@ -61,13 +74,22 @@ async def test_if_fires_on_event(
assert service_calls[1].service == "automation"
assert service_calls[1].data["data"] == {
"alias": None,
- "id": "0",
- "idx": "0",
+ "id": 0,
+ "idx": 0,
"platform": "conversation",
"sentence": "Ha ha ha",
"slots": {},
"details": {},
"device_id": None,
+ "user_input": {
+ "agent_id": None,
+ "context": context.as_dict(),
+ "conversation_id": None,
+ "device_id": None,
+ "language": "en",
+ "text": "Ha ha ha",
+ "extra_system_prompt": None,
+ },
}
@@ -152,7 +174,19 @@ async def test_response_same_sentence(
{"delay": "0:0:0.100"},
{
"service": "test.automation",
- "data_template": {"data": "{{ trigger }}"},
+ "data_template": {
+ "data": {
+ "alias": "{{ trigger.alias }}",
+ "id": "{{ trigger.id }}",
+ "idx": "{{ trigger.idx }}",
+ "platform": "{{ trigger.platform }}",
+ "sentence": "{{ trigger.sentence }}",
+ "slots": "{{ trigger.slots }}",
+ "details": "{{ trigger.details }}",
+ "device_id": "{{ trigger.device_id }}",
+ "user_input": "{{ trigger.user_input }}",
+ }
+ },
},
{"set_conversation_response": "response 2"},
],
@@ -168,13 +202,14 @@ async def test_response_same_sentence(
]
},
)
-
+ context = Context()
service_response = await hass.services.async_call(
"conversation",
"process",
{"text": "test sentence"},
blocking=True,
return_response=True,
+ context=context,
)
await hass.async_block_till_done()
@@ -188,12 +223,21 @@ async def test_response_same_sentence(
assert service_calls[1].data["data"] == {
"alias": None,
"id": "trigger1",
- "idx": "0",
+ "idx": 0,
"platform": "conversation",
"sentence": "test sentence",
"slots": {},
"details": {},
"device_id": None,
+ "user_input": {
+ "agent_id": None,
+ "context": context.as_dict(),
+ "conversation_id": None,
+ "device_id": None,
+ "language": "en",
+ "text": "test sentence",
+ "extra_system_prompt": None,
+ },
}
@@ -231,13 +275,14 @@ async def test_response_same_sentence_with_error(
]
},
)
-
+ context = Context()
service_response = await hass.services.async_call(
"conversation",
"process",
{"text": "test sentence"},
blocking=True,
return_response=True,
+ context=context,
)
await hass.async_block_till_done()
@@ -320,12 +365,24 @@ async def test_same_trigger_multiple_sentences(
},
"action": {
"service": "test.automation",
- "data_template": {"data": "{{ trigger }}"},
+ "data_template": {
+ "data": {
+ "alias": "{{ trigger.alias }}",
+ "id": "{{ trigger.id }}",
+ "idx": "{{ trigger.idx }}",
+ "platform": "{{ trigger.platform }}",
+ "sentence": "{{ trigger.sentence }}",
+ "slots": "{{ trigger.slots }}",
+ "details": "{{ trigger.details }}",
+ "device_id": "{{ trigger.device_id }}",
+ "user_input": "{{ trigger.user_input }}",
+ }
+ },
},
}
},
)
-
+ context = Context()
await hass.services.async_call(
"conversation",
"process",
@@ -333,6 +390,7 @@ async def test_same_trigger_multiple_sentences(
"text": "hello",
},
blocking=True,
+ context=context,
)
# Only triggers once
@@ -342,13 +400,22 @@ async def test_same_trigger_multiple_sentences(
assert service_calls[1].service == "automation"
assert service_calls[1].data["data"] == {
"alias": None,
- "id": "0",
- "idx": "0",
+ "id": 0,
+ "idx": 0,
"platform": "conversation",
"sentence": "hello",
"slots": {},
"details": {},
"device_id": None,
+ "user_input": {
+ "agent_id": None,
+ "context": context.as_dict(),
+ "conversation_id": None,
+ "device_id": None,
+ "language": "en",
+ "text": "hello",
+ "extra_system_prompt": None,
+ },
}
@@ -371,7 +438,19 @@ async def test_same_sentence_multiple_triggers(
},
"action": {
"service": "test.automation",
- "data_template": {"data": "{{ trigger }}"},
+ "data_template": {
+ "data": {
+ "alias": "{{ trigger.alias }}",
+ "id": "{{ trigger.id }}",
+ "idx": "{{ trigger.idx }}",
+ "platform": "{{ trigger.platform }}",
+ "sentence": "{{ trigger.sentence }}",
+ "slots": "{{ trigger.slots }}",
+ "details": "{{ trigger.details }}",
+ "device_id": "{{ trigger.device_id }}",
+ "user_input": "{{ trigger.user_input }}",
+ }
+ },
},
},
{
@@ -384,7 +463,19 @@ async def test_same_sentence_multiple_triggers(
},
"action": {
"service": "test.automation",
- "data_template": {"data": "{{ trigger }}"},
+ "data_template": {
+ "data": {
+ "alias": "{{ trigger.alias }}",
+ "id": "{{ trigger.id }}",
+ "idx": "{{ trigger.idx }}",
+ "platform": "{{ trigger.platform }}",
+ "sentence": "{{ trigger.sentence }}",
+ "slots": "{{ trigger.slots }}",
+ "details": "{{ trigger.details }}",
+ "device_id": "{{ trigger.device_id }}",
+ "user_input": "{{ trigger.user_input }}",
+ }
+ },
},
},
],
@@ -488,12 +579,25 @@ async def test_wildcards(hass: HomeAssistant, service_calls: list[ServiceCall])
},
"action": {
"service": "test.automation",
- "data_template": {"data": "{{ trigger }}"},
+ "data_template": {
+ "data": {
+ "alias": "{{ trigger.alias }}",
+ "id": "{{ trigger.id }}",
+ "idx": "{{ trigger.idx }}",
+ "platform": "{{ trigger.platform }}",
+ "sentence": "{{ trigger.sentence }}",
+ "slots": "{{ trigger.slots }}",
+ "details": "{{ trigger.details }}",
+ "device_id": "{{ trigger.device_id }}",
+ "user_input": "{{ trigger.user_input }}",
+ }
+ },
},
}
},
)
+ context = Context()
await hass.services.async_call(
"conversation",
"process",
@@ -501,6 +605,7 @@ async def test_wildcards(hass: HomeAssistant, service_calls: list[ServiceCall])
"text": "play the white album by the beatles",
},
blocking=True,
+ context=context,
)
await hass.async_block_till_done()
@@ -509,8 +614,8 @@ async def test_wildcards(hass: HomeAssistant, service_calls: list[ServiceCall])
assert service_calls[1].service == "automation"
assert service_calls[1].data["data"] == {
"alias": None,
- "id": "0",
- "idx": "0",
+ "id": 0,
+ "idx": 0,
"platform": "conversation",
"sentence": "play the white album by the beatles",
"slots": {
@@ -530,6 +635,15 @@ async def test_wildcards(hass: HomeAssistant, service_calls: list[ServiceCall])
},
},
"device_id": None,
+ "user_input": {
+ "agent_id": None,
+ "context": context.as_dict(),
+ "conversation_id": None,
+ "device_id": None,
+ "language": "en",
+ "text": "play the white album by the beatles",
+ "extra_system_prompt": None,
+ },
}
diff --git a/tests/components/cookidoo/__init__.py b/tests/components/cookidoo/__init__.py
new file mode 100644
index 00000000000..043f627ecc6
--- /dev/null
+++ b/tests/components/cookidoo/__init__.py
@@ -0,0 +1,15 @@
+"""Tests for the Cookidoo integration."""
+
+from homeassistant.core import HomeAssistant
+
+from tests.common import MockConfigEntry
+
+
+async def setup_integration(
+ hass: HomeAssistant,
+ cookidoo_config_entry: MockConfigEntry,
+) -> None:
+ """Mock setup of the cookidoo integration."""
+ cookidoo_config_entry.add_to_hass(hass)
+ await hass.config_entries.async_setup(cookidoo_config_entry.entry_id)
+ await hass.async_block_till_done()
diff --git a/tests/components/cookidoo/conftest.py b/tests/components/cookidoo/conftest.py
new file mode 100644
index 00000000000..66c2064eb3a
--- /dev/null
+++ b/tests/components/cookidoo/conftest.py
@@ -0,0 +1,77 @@
+"""Common fixtures for the Cookidoo tests."""
+
+from collections.abc import Generator
+from typing import cast
+from unittest.mock import AsyncMock, patch
+
+from cookidoo_api import (
+ CookidooAdditionalItem,
+ CookidooAuthResponse,
+ CookidooIngredientItem,
+)
+import pytest
+
+from homeassistant.components.cookidoo.const import DOMAIN
+from homeassistant.const import CONF_COUNTRY, CONF_EMAIL, CONF_LANGUAGE, CONF_PASSWORD
+
+from tests.common import MockConfigEntry, load_json_object_fixture
+
+EMAIL = "test-email"
+PASSWORD = "test-password"
+COUNTRY = "CH"
+LANGUAGE = "de-CH"
+
+
+@pytest.fixture
+def mock_setup_entry() -> Generator[AsyncMock]:
+ """Override async_setup_entry."""
+ with patch(
+ "homeassistant.components.cookidoo.async_setup_entry", return_value=True
+ ) as mock_setup_entry:
+ yield mock_setup_entry
+
+
+@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,
+ ),
+ ):
+ client = mock_client.return_value
+ client.login.return_value = cast(CookidooAuthResponse, {"name": "Cookidoo"})
+ client.get_ingredient_items.return_value = [
+ CookidooIngredientItem(**item)
+ for item in load_json_object_fixture("ingredient_items.json", DOMAIN)[
+ "data"
+ ]
+ ]
+ client.get_additional_items.return_value = [
+ CookidooAdditionalItem(**item)
+ for item in load_json_object_fixture("additional_items.json", DOMAIN)[
+ "data"
+ ]
+ ]
+ client.login.return_value = None
+ yield client
+
+
+@pytest.fixture(name="cookidoo_config_entry")
+def mock_cookidoo_config_entry() -> MockConfigEntry:
+ """Mock cookidoo configuration entry."""
+ return MockConfigEntry(
+ domain=DOMAIN,
+ data={
+ CONF_EMAIL: EMAIL,
+ CONF_PASSWORD: PASSWORD,
+ CONF_COUNTRY: COUNTRY,
+ CONF_LANGUAGE: LANGUAGE,
+ },
+ entry_id="01JBVVVJ87F6G5V0QJX6HBC94T",
+ )
diff --git a/tests/components/cookidoo/fixtures/additional_items.json b/tests/components/cookidoo/fixtures/additional_items.json
new file mode 100644
index 00000000000..97cd206f6ad
--- /dev/null
+++ b/tests/components/cookidoo/fixtures/additional_items.json
@@ -0,0 +1,9 @@
+{
+ "data": [
+ {
+ "id": "unique_id_tomaten",
+ "name": "Tomaten",
+ "is_owned": false
+ }
+ ]
+}
diff --git a/tests/components/cookidoo/fixtures/ingredient_items.json b/tests/components/cookidoo/fixtures/ingredient_items.json
new file mode 100644
index 00000000000..7fbeb90e91a
--- /dev/null
+++ b/tests/components/cookidoo/fixtures/ingredient_items.json
@@ -0,0 +1,10 @@
+{
+ "data": [
+ {
+ "id": "unique_id_mehl",
+ "name": "Mehl",
+ "description": "200 g",
+ "is_owned": false
+ }
+ ]
+}
diff --git a/tests/components/cookidoo/snapshots/test_button.ambr b/tests/components/cookidoo/snapshots/test_button.ambr
new file mode 100644
index 00000000000..60f9e95bee7
--- /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': '01JBVVVJ87F6G5V0QJX6HBC94T_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_todo.ambr b/tests/components/cookidoo/snapshots/test_todo.ambr
new file mode 100644
index 00000000000..965cbb0adde
--- /dev/null
+++ b/tests/components/cookidoo/snapshots/test_todo.ambr
@@ -0,0 +1,95 @@
+# serializer version: 1
+# name: test_todo[todo.cookidoo_additional_purchases-entry]
+ EntityRegistryEntrySnapshot({
+ 'aliases': set({
+ }),
+ 'area_id': None,
+ 'capabilities': None,
+ 'config_entry_id': ,
+ 'device_class': None,
+ 'device_id': ,
+ 'disabled_by': None,
+ 'domain': 'todo',
+ 'entity_category': None,
+ 'entity_id': 'todo.cookidoo_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': 'Additional purchases',
+ 'platform': 'cookidoo',
+ 'previous_unique_id': None,
+ 'supported_features': ,
+ 'translation_key': 'additional_item_list',
+ 'unique_id': '01JBVVVJ87F6G5V0QJX6HBC94T_additional_items',
+ 'unit_of_measurement': None,
+ })
+# ---
+# name: test_todo[todo.cookidoo_additional_purchases-state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ 'friendly_name': 'Cookidoo Additional purchases',
+ 'supported_features': ,
+ }),
+ 'context': ,
+ 'entity_id': 'todo.cookidoo_additional_purchases',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated':