Compare commits

..

3 commits

Author SHA1 Message Date
Paulus Schoutsen
98cff0bd74 Add integration back 2024-11-02 18:58:03 +00:00
Paulus Schoutsen
b3a32b5f59 Update format 2024-11-02 18:58:03 +00:00
Paulus Schoutsen
d7d2b7ad76 Add method to generate devices analytics payload 2024-11-02 18:58:01 +00:00
796 changed files with 6695 additions and 23089 deletions

View file

@ -79,7 +79,6 @@ components: &components
- homeassistant/components/group/** - homeassistant/components/group/**
- homeassistant/components/hassio/** - homeassistant/components/hassio/**
- homeassistant/components/homeassistant/** - homeassistant/components/homeassistant/**
- homeassistant/components/homeassistant_hardware/**
- homeassistant/components/http/** - homeassistant/components/http/**
- homeassistant/components/image/** - homeassistant/components/image/**
- homeassistant/components/input_boolean/** - homeassistant/components/input_boolean/**

View file

@ -10,7 +10,7 @@ on:
env: env:
BUILD_TYPE: core BUILD_TYPE: core
DEFAULT_PYTHON: "3.13" DEFAULT_PYTHON: "3.12"
PIP_TIMEOUT: 60 PIP_TIMEOUT: 60
UV_HTTP_TIMEOUT: 60 UV_HTTP_TIMEOUT: 60
UV_SYSTEM_PYTHON: "true" UV_SYSTEM_PYTHON: "true"
@ -531,7 +531,7 @@ jobs:
- name: Generate artifact attestation - name: Generate artifact attestation
if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true' 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@1c608d11d69870c2092266b3f9a6f3abbf17002c # v1.4.3
with: with:
subject-name: ${{ env.HASSFEST_IMAGE_NAME }} subject-name: ${{ env.HASSFEST_IMAGE_NAME }}
subject-digest: ${{ steps.push.outputs.digest }} subject-digest: ${{ steps.push.outputs.digest }}

View file

@ -42,7 +42,7 @@ env:
MYPY_CACHE_VERSION: 9 MYPY_CACHE_VERSION: 9
HA_SHORT_VERSION: "2024.12" HA_SHORT_VERSION: "2024.12"
DEFAULT_PYTHON: "3.12" DEFAULT_PYTHON: "3.12"
ALL_PYTHON_VERSIONS: "['3.12', '3.13']" ALL_PYTHON_VERSIONS: "['3.12']"
# 10.3 is the oldest supported version # 10.3 is the oldest supported version
# - 10.3.32 is the version currently shipped with Synology (as of 17 Feb 2022) # - 10.3.32 is the version currently shipped with Synology (as of 17 Feb 2022)
# 10.6 is the current long-term-support # 10.6 is the current long-term-support
@ -622,13 +622,13 @@ jobs:
steps: steps:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@v4.2.2 uses: actions/checkout@v4.2.2
- name: Set up Python ${{ matrix.python-version }} - name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python id: python
uses: actions/setup-python@v5.3.0 uses: actions/setup-python@v5.3.0
with: with:
python-version: ${{ matrix.python-version }} python-version: ${{ env.DEFAULT_PYTHON }}
check-latest: true check-latest: true
- name: Restore full Python ${{ matrix.python-version }} virtual environment - name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment
id: cache-venv id: cache-venv
uses: actions/cache/restore@v4.1.2 uses: actions/cache/restore@v4.1.2
with: with:
@ -819,7 +819,11 @@ jobs:
needs: needs:
- info - info
- base - base
name: Split tests for full run strategy:
fail-fast: false
matrix:
python-version: ${{ fromJson(needs.info.outputs.python_versions) }}
name: Split tests for full run Python ${{ matrix.python-version }}
steps: steps:
- name: Install additional OS dependencies - name: Install additional OS dependencies
run: | run: |
@ -832,11 +836,11 @@ jobs:
libgammu-dev libgammu-dev
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@v4.2.2 uses: actions/checkout@v4.2.2
- name: Set up Python ${{ env.DEFAULT_PYTHON }} - name: Set up Python ${{ matrix.python-version }}
id: python id: python
uses: actions/setup-python@v5.3.0 uses: actions/setup-python@v5.3.0
with: with:
python-version: ${{ env.DEFAULT_PYTHON }} python-version: ${{ matrix.python-version }}
check-latest: true check-latest: true
- name: Restore base Python virtual environment - name: Restore base Python virtual environment
id: cache-venv id: cache-venv
@ -854,7 +858,7 @@ jobs:
- name: Upload pytest_buckets - name: Upload pytest_buckets
uses: actions/upload-artifact@v4.4.3 uses: actions/upload-artifact@v4.4.3
with: with:
name: pytest_buckets name: pytest_buckets-${{ matrix.python-version }}
path: pytest_buckets.txt path: pytest_buckets.txt
overwrite: true overwrite: true
@ -919,7 +923,7 @@ jobs:
- name: Download pytest_buckets - name: Download pytest_buckets
uses: actions/download-artifact@v4.1.8 uses: actions/download-artifact@v4.1.8
with: with:
name: pytest_buckets name: pytest_buckets-${{ matrix.python-version }}
- name: Compile English translations - name: Compile English translations
run: | run: |
. venv/bin/activate . venv/bin/activate
@ -945,7 +949,6 @@ jobs:
--timeout=9 \ --timeout=9 \
--durations=10 \ --durations=10 \
--numprocesses auto \ --numprocesses auto \
--snapshot-details \
--dist=loadfile \ --dist=loadfile \
${cov_params[@]} \ ${cov_params[@]} \
-o console_output_style=count \ -o console_output_style=count \
@ -1068,7 +1071,6 @@ jobs:
-qq \ -qq \
--timeout=20 \ --timeout=20 \
--numprocesses 1 \ --numprocesses 1 \
--snapshot-details \
${cov_params[@]} \ ${cov_params[@]} \
-o console_output_style=count \ -o console_output_style=count \
--durations=10 \ --durations=10 \
@ -1197,7 +1199,6 @@ jobs:
-qq \ -qq \
--timeout=9 \ --timeout=9 \
--numprocesses 1 \ --numprocesses 1 \
--snapshot-details \
${cov_params[@]} \ ${cov_params[@]} \
-o console_output_style=count \ -o console_output_style=count \
--durations=0 \ --durations=0 \
@ -1344,7 +1345,6 @@ jobs:
-qq \ -qq \
--timeout=9 \ --timeout=9 \
--numprocesses auto \ --numprocesses auto \
--snapshot-details \
${cov_params[@]} \ ${cov_params[@]} \
-o console_output_style=count \ -o console_output_style=count \
--durations=0 \ --durations=0 \

View file

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

View file

@ -112,7 +112,7 @@ jobs:
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
abi: ["cp312", "cp313"] abi: ["cp312"]
arch: ${{ fromJson(needs.init.outputs.architectures) }} arch: ${{ fromJson(needs.init.outputs.architectures) }}
steps: steps:
- name: Checkout the repository - name: Checkout the repository
@ -135,14 +135,14 @@ jobs:
sed -i "/uv/d" requirements_diff.txt sed -i "/uv/d" requirements_diff.txt
- name: Build wheels - name: Build wheels
uses: home-assistant/wheels@2024.11.0 uses: home-assistant/wheels@2024.07.1
with: with:
abi: ${{ matrix.abi }} abi: ${{ matrix.abi }}
tag: musllinux_1_2 tag: musllinux_1_2
arch: ${{ matrix.arch }} arch: ${{ matrix.arch }}
wheels-key: ${{ secrets.WHEELS_KEY }} wheels-key: ${{ secrets.WHEELS_KEY }}
env-file: true env-file: true
apk: "libffi-dev;openssl-dev;yaml-dev;nasm;zlib-dev" apk: "libffi-dev;openssl-dev;yaml-dev;nasm"
skip-binary: aiohttp;multidict;yarl skip-binary: aiohttp;multidict;yarl
constraints: "homeassistant/package_constraints.txt" constraints: "homeassistant/package_constraints.txt"
requirements-diff: "requirements_diff.txt" requirements-diff: "requirements_diff.txt"
@ -156,7 +156,7 @@ jobs:
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
abi: ["cp312", "cp313"] abi: ["cp312"]
arch: ${{ fromJson(needs.init.outputs.architectures) }} arch: ${{ fromJson(needs.init.outputs.architectures) }}
steps: steps:
- name: Checkout the repository - name: Checkout the repository
@ -198,7 +198,6 @@ jobs:
split -l $(expr $(expr $(cat requirements_all.txt | wc -l) + 1) / 3) requirements_all_wheels_${{ matrix.arch }}.txt requirements_all.txt 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 - name: Create requirements for cython<3
if: matrix.abi == 'cp312'
run: | run: |
# Some dependencies still require 'cython<3' # Some dependencies still require 'cython<3'
# and don't yet use isolated build environments. # and don't yet use isolated build environments.
@ -209,8 +208,7 @@ jobs:
cat homeassistant/package_constraints.txt | grep 'pydantic==' >> requirements_old-cython.txt cat homeassistant/package_constraints.txt | grep 'pydantic==' >> requirements_old-cython.txt
- name: Build wheels (old cython) - name: Build wheels (old cython)
uses: home-assistant/wheels@2024.11.0 uses: home-assistant/wheels@2024.07.1
if: matrix.abi == 'cp312'
with: with:
abi: ${{ matrix.abi }} abi: ${{ matrix.abi }}
tag: musllinux_1_2 tag: musllinux_1_2
@ -225,43 +223,43 @@ jobs:
pip: "'cython<3'" pip: "'cython<3'"
- name: Build wheels (part 1) - name: Build wheels (part 1)
uses: home-assistant/wheels@2024.11.0 uses: home-assistant/wheels@2024.07.1
with: with:
abi: ${{ matrix.abi }} abi: ${{ matrix.abi }}
tag: musllinux_1_2 tag: musllinux_1_2
arch: ${{ matrix.arch }} arch: ${{ matrix.arch }}
wheels-key: ${{ secrets.WHEELS_KEY }} wheels-key: ${{ secrets.WHEELS_KEY }}
env-file: true env-file: true
apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev;nasm;zlib-dev" apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev;nasm"
skip-binary: aiohttp;charset-normalizer;grpcio;multidict;SQLAlchemy;propcache;protobuf;pymicro-vad;yarl skip-binary: aiohttp;charset-normalizer;grpcio;multidict;SQLAlchemy;propcache;protobuf;pydantic;pymicro-vad;yarl
constraints: "homeassistant/package_constraints.txt" constraints: "homeassistant/package_constraints.txt"
requirements-diff: "requirements_diff.txt" requirements-diff: "requirements_diff.txt"
requirements: "requirements_all.txtaa" requirements: "requirements_all.txtaa"
- name: Build wheels (part 2) - name: Build wheels (part 2)
uses: home-assistant/wheels@2024.11.0 uses: home-assistant/wheels@2024.07.1
with: with:
abi: ${{ matrix.abi }} abi: ${{ matrix.abi }}
tag: musllinux_1_2 tag: musllinux_1_2
arch: ${{ matrix.arch }} arch: ${{ matrix.arch }}
wheels-key: ${{ secrets.WHEELS_KEY }} wheels-key: ${{ secrets.WHEELS_KEY }}
env-file: true env-file: true
apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev;nasm;zlib-dev" apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev;nasm"
skip-binary: aiohttp;charset-normalizer;grpcio;multidict;SQLAlchemy;propcache;protobuf;pymicro-vad;yarl skip-binary: aiohttp;charset-normalizer;grpcio;multidict;SQLAlchemy;propcache;protobuf;pydantic;pymicro-vad;yarl
constraints: "homeassistant/package_constraints.txt" constraints: "homeassistant/package_constraints.txt"
requirements-diff: "requirements_diff.txt" requirements-diff: "requirements_diff.txt"
requirements: "requirements_all.txtab" requirements: "requirements_all.txtab"
- name: Build wheels (part 3) - name: Build wheels (part 3)
uses: home-assistant/wheels@2024.11.0 uses: home-assistant/wheels@2024.07.1
with: with:
abi: ${{ matrix.abi }} abi: ${{ matrix.abi }}
tag: musllinux_1_2 tag: musllinux_1_2
arch: ${{ matrix.arch }} arch: ${{ matrix.arch }}
wheels-key: ${{ secrets.WHEELS_KEY }} wheels-key: ${{ secrets.WHEELS_KEY }}
env-file: true env-file: true
apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev;nasm;zlib-dev" apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev;nasm"
skip-binary: aiohttp;charset-normalizer;grpcio;multidict;SQLAlchemy;propcache;protobuf;pymicro-vad;yarl skip-binary: aiohttp;charset-normalizer;grpcio;multidict;SQLAlchemy;propcache;protobuf;pydantic;pymicro-vad;yarl
constraints: "homeassistant/package_constraints.txt" constraints: "homeassistant/package_constraints.txt"
requirements-diff: "requirements_diff.txt" requirements-diff: "requirements_diff.txt"
requirements: "requirements_all.txtac" requirements: "requirements_all.txtac"

View file

@ -1,6 +1,6 @@
repos: repos:
- repo: https://github.com/astral-sh/ruff-pre-commit - repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.7.3 rev: v0.7.2
hooks: hooks:
- id: ruff - id: ruff
args: args:
@ -90,7 +90,7 @@ repos:
pass_filenames: false pass_filenames: false
language: script language: script
types: [text] types: [text]
files: ^(script/hassfest/metadata\.py|homeassistant/const\.py$|pyproject\.toml|homeassistant/components/go2rtc/const\.py)$ files: ^(script/hassfest/metadata\.py|homeassistant/const\.py$|pyproject\.toml)$
- id: hassfest-mypy-config - id: hassfest-mypy-config
name: hassfest-mypy-config name: hassfest-mypy-config
entry: script/run-in-env.sh python3 -m script.hassfest -p mypy_config entry: script/run-in-env.sh python3 -m script.hassfest -p mypy_config

View file

@ -330,7 +330,6 @@ homeassistant.components.mysensors.*
homeassistant.components.myuplink.* homeassistant.components.myuplink.*
homeassistant.components.nam.* homeassistant.components.nam.*
homeassistant.components.nanoleaf.* homeassistant.components.nanoleaf.*
homeassistant.components.nasweb.*
homeassistant.components.neato.* homeassistant.components.neato.*
homeassistant.components.nest.* homeassistant.components.nest.*
homeassistant.components.netatmo.* homeassistant.components.netatmo.*
@ -340,7 +339,6 @@ homeassistant.components.nfandroidtv.*
homeassistant.components.nightscout.* homeassistant.components.nightscout.*
homeassistant.components.nissan_leaf.* homeassistant.components.nissan_leaf.*
homeassistant.components.no_ip.* homeassistant.components.no_ip.*
homeassistant.components.nordpool.*
homeassistant.components.notify.* homeassistant.components.notify.*
homeassistant.components.notion.* homeassistant.components.notion.*
homeassistant.components.number.* homeassistant.components.number.*

View file

@ -40,8 +40,6 @@ build.json @home-assistant/supervisor
# Integrations # Integrations
/homeassistant/components/abode/ @shred86 /homeassistant/components/abode/ @shred86
/tests/components/abode/ @shred86 /tests/components/abode/ @shred86
/homeassistant/components/acaia/ @zweckj
/tests/components/acaia/ @zweckj
/homeassistant/components/accuweather/ @bieniu /homeassistant/components/accuweather/ @bieniu
/tests/components/accuweather/ @bieniu /tests/components/accuweather/ @bieniu
/homeassistant/components/acmeda/ @atmurray /homeassistant/components/acmeda/ @atmurray
@ -498,8 +496,8 @@ build.json @home-assistant/supervisor
/tests/components/freebox/ @hacf-fr @Quentame /tests/components/freebox/ @hacf-fr @Quentame
/homeassistant/components/freedompro/ @stefano055415 /homeassistant/components/freedompro/ @stefano055415
/tests/components/freedompro/ @stefano055415 /tests/components/freedompro/ @stefano055415
/homeassistant/components/fritz/ @AaronDavidSchneider @chemelli74 @mib1185 /homeassistant/components/fritz/ @mammuth @AaronDavidSchneider @chemelli74 @mib1185
/tests/components/fritz/ @AaronDavidSchneider @chemelli74 @mib1185 /tests/components/fritz/ @mammuth @AaronDavidSchneider @chemelli74 @mib1185
/homeassistant/components/fritzbox/ @mib1185 @flabbamann /homeassistant/components/fritzbox/ @mib1185 @flabbamann
/tests/components/fritzbox/ @mib1185 @flabbamann /tests/components/fritzbox/ @mib1185 @flabbamann
/homeassistant/components/fritzbox_callmonitor/ @cdce8p /homeassistant/components/fritzbox_callmonitor/ @cdce8p
@ -972,8 +970,6 @@ build.json @home-assistant/supervisor
/tests/components/nam/ @bieniu /tests/components/nam/ @bieniu
/homeassistant/components/nanoleaf/ @milanmeu @joostlek /homeassistant/components/nanoleaf/ @milanmeu @joostlek
/tests/components/nanoleaf/ @milanmeu @joostlek /tests/components/nanoleaf/ @milanmeu @joostlek
/homeassistant/components/nasweb/ @nasWebio
/tests/components/nasweb/ @nasWebio
/homeassistant/components/neato/ @Santobert /homeassistant/components/neato/ @Santobert
/tests/components/neato/ @Santobert /tests/components/neato/ @Santobert
/homeassistant/components/nederlandse_spoorwegen/ @YarmoM /homeassistant/components/nederlandse_spoorwegen/ @YarmoM
@ -1014,8 +1010,6 @@ build.json @home-assistant/supervisor
/homeassistant/components/noaa_tides/ @jdelaney72 /homeassistant/components/noaa_tides/ @jdelaney72
/homeassistant/components/nobo_hub/ @echoromeo @oyvindwe /homeassistant/components/nobo_hub/ @echoromeo @oyvindwe
/tests/components/nobo_hub/ @echoromeo @oyvindwe /tests/components/nobo_hub/ @echoromeo @oyvindwe
/homeassistant/components/nordpool/ @gjohansson-ST
/tests/components/nordpool/ @gjohansson-ST
/homeassistant/components/notify/ @home-assistant/core /homeassistant/components/notify/ @home-assistant/core
/tests/components/notify/ @home-assistant/core /tests/components/notify/ @home-assistant/core
/homeassistant/components/notify_events/ @matrozov @papajojo /homeassistant/components/notify_events/ @matrozov @papajojo
@ -1346,8 +1340,6 @@ build.json @home-assistant/supervisor
/tests/components/siren/ @home-assistant/core @raman325 /tests/components/siren/ @home-assistant/core @raman325
/homeassistant/components/sisyphus/ @jkeljo /homeassistant/components/sisyphus/ @jkeljo
/homeassistant/components/sky_hub/ @rogerselwyn /homeassistant/components/sky_hub/ @rogerselwyn
/homeassistant/components/sky_remote/ @dunnmj @saty9
/tests/components/sky_remote/ @dunnmj @saty9
/homeassistant/components/skybell/ @tkdrob /homeassistant/components/skybell/ @tkdrob
/tests/components/skybell/ @tkdrob /tests/components/skybell/ @tkdrob
/homeassistant/components/slack/ @tkdrob @fletcherau /homeassistant/components/slack/ @tkdrob @fletcherau
@ -1489,8 +1481,8 @@ build.json @home-assistant/supervisor
/tests/components/tedee/ @patrickhilker @zweckj /tests/components/tedee/ @patrickhilker @zweckj
/homeassistant/components/tellduslive/ @fredrike /homeassistant/components/tellduslive/ @fredrike
/tests/components/tellduslive/ @fredrike /tests/components/tellduslive/ @fredrike
/homeassistant/components/template/ @PhracturedBlue @home-assistant/core /homeassistant/components/template/ @PhracturedBlue @tetienne @home-assistant/core
/tests/components/template/ @PhracturedBlue @home-assistant/core /tests/components/template/ @PhracturedBlue @tetienne @home-assistant/core
/homeassistant/components/tesla_fleet/ @Bre77 /homeassistant/components/tesla_fleet/ @Bre77
/tests/components/tesla_fleet/ @Bre77 /tests/components/tesla_fleet/ @Bre77
/homeassistant/components/tesla_wall_connector/ @einarhauks /homeassistant/components/tesla_wall_connector/ @einarhauks

View file

@ -7,13 +7,12 @@ FROM ${BUILD_FROM}
# Synchronize with homeassistant/core.py:async_stop # Synchronize with homeassistant/core.py:async_stop
ENV \ ENV \
S6_SERVICES_GRACETIME=240000 \ S6_SERVICES_GRACETIME=240000 \
UV_SYSTEM_PYTHON=true \ UV_SYSTEM_PYTHON=true
UV_NO_CACHE=true
ARG QEMU_CPU ARG QEMU_CPU
# Install uv # Install uv
RUN pip3 install uv==0.5.0 RUN pip3 install uv==0.4.28
WORKDIR /usr/src WORKDIR /usr/src
@ -55,7 +54,7 @@ RUN \
"armv7") go2rtc_suffix='arm' ;; \ "armv7") go2rtc_suffix='arm' ;; \
*) go2rtc_suffix=${BUILD_ARCH} ;; \ *) go2rtc_suffix=${BUILD_ARCH} ;; \
esac \ esac \
&& curl -L https://github.com/AlexxIT/go2rtc/releases/download/v1.9.7/go2rtc_linux_${go2rtc_suffix} --output /bin/go2rtc \ && curl -L https://github.com/AlexxIT/go2rtc/releases/download/v1.9.6/go2rtc_linux_${go2rtc_suffix} --output /bin/go2rtc \
&& chmod +x /bin/go2rtc \ && chmod +x /bin/go2rtc \
# Verify go2rtc can be executed # Verify go2rtc can be executed
&& go2rtc --version && go2rtc --version

View file

@ -35,9 +35,6 @@ RUN \
&& apt-get clean \ && apt-get clean \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Add go2rtc binary
COPY --from=ghcr.io/alexxit/go2rtc:latest /usr/local/bin/go2rtc /bin/go2rtc
# Install uv # Install uv
RUN pip3 install uv RUN pip3 install uv

View file

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

View file

@ -30,11 +30,11 @@ def restore_backup_file_content(config_dir: Path) -> RestoreBackupFileContent |
"""Return the contents of the restore backup file.""" """Return the contents of the restore backup file."""
instruction_path = config_dir.joinpath(RESTORE_BACKUP_FILE) instruction_path = config_dir.joinpath(RESTORE_BACKUP_FILE)
try: try:
instruction_content = json.loads(instruction_path.read_text(encoding="utf-8")) instruction_content = instruction_path.read_text(encoding="utf-8")
return RestoreBackupFileContent( return RestoreBackupFileContent(
backup_file_path=Path(instruction_content["path"]) backup_file_path=Path(instruction_content.split(";")[0])
) )
except (FileNotFoundError, json.JSONDecodeError): except FileNotFoundError:
return None return None

View file

@ -515,7 +515,7 @@ async def async_from_config_dict(
issue_registry.async_create_issue( issue_registry.async_create_issue(
hass, hass,
core.DOMAIN, core.DOMAIN,
f"python_version_{required_python_version}", "python_version",
is_fixable=False, is_fixable=False,
severity=issue_registry.IssueSeverity.WARNING, severity=issue_registry.IssueSeverity.WARNING,
breaks_in_ha_version=REQUIRED_NEXT_PYTHON_HA_RELEASE, breaks_in_ha_version=REQUIRED_NEXT_PYTHON_HA_RELEASE,

View file

@ -1,5 +0,0 @@
{
"domain": "sky",
"name": "Sky",
"integrations": ["sky_hub", "sky_remote"]
}

View file

@ -1,29 +0,0 @@
"""Initialize the Acaia component."""
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from .coordinator import AcaiaConfigEntry, AcaiaCoordinator
PLATFORMS = [
Platform.BUTTON,
]
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)

View file

@ -1,61 +0,0 @@
"""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
@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)

View file

@ -1,149 +0,0 @@
"""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 = format_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(mac)
self._abort_if_unique_id_configured()
if not errors:
return self.async_create_entry(
title=self._discovered_devices[user_input[CONF_ADDRESS]],
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] = mac = format_mac(discovery_info.address)
self._discovered[CONF_NAME] = discovery_info.name
await self.async_set_unique_id(mac)
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,
)

View file

@ -1,4 +0,0 @@
"""Constants for component."""
DOMAIN = "acaia"
CONF_IS_NEW_STYLE_SCALE = "is_new_style_scale"

View file

@ -1,86 +0,0 @@
"""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",
)
)

View file

@ -1,40 +0,0 @@
"""Base class for Acaia entities."""
from dataclasses import dataclass
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 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
self._attr_unique_id = f"{self._scale.mac}_{entity_description.key}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, self._scale.mac)},
manufacturer="Acaia",
model=self._scale.model,
suggested_area="Kitchen",
)
@property
def available(self) -> bool:
"""Returns whether entity is available."""
return super().available and self._scale.connected

View file

@ -1,15 +0,0 @@
{
"entity": {
"button": {
"tare": {
"default": "mdi:scale-balance"
},
"reset_timer": {
"default": "mdi:timer-refresh"
},
"start_stop": {
"default": "mdi:timer-play"
}
}
}
}

View file

@ -1,29 +0,0 @@
{
"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"],
"requirements": ["aioacaia==0.1.6"]
}

View file

@ -1,38 +0,0 @@
{
"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%]"
}
}
}
},
"entity": {
"button": {
"tare": {
"name": "Tare"
},
"reset_timer": {
"name": "Reset timer"
},
"start_stop": {
"name": "Start/stop timer"
}
}
}
}

View file

@ -1,5 +1,6 @@
"""The AEMET OpenData component.""" """The AEMET OpenData component."""
from dataclasses import dataclass
import logging import logging
from aemet_opendata.exceptions import AemetError, TownNotFound from aemet_opendata.exceptions import AemetError, TownNotFound
@ -12,10 +13,20 @@ from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import aiohttp_client from homeassistant.helpers import aiohttp_client
from .const import CONF_STATION_UPDATES, PLATFORMS from .const import CONF_STATION_UPDATES, PLATFORMS
from .coordinator import AemetConfigEntry, AemetData, WeatherUpdateCoordinator from .coordinator import WeatherUpdateCoordinator
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
type AemetConfigEntry = ConfigEntry[AemetData]
@dataclass
class AemetData:
"""Aemet runtime data."""
name: str
coordinator: WeatherUpdateCoordinator
async def async_setup_entry(hass: HomeAssistant, entry: AemetConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: AemetConfigEntry) -> bool:
"""Set up AEMET OpenData as config entry.""" """Set up AEMET OpenData as config entry."""
@ -35,7 +46,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: AemetConfigEntry) -> boo
except AemetError as err: except AemetError as err:
raise ConfigEntryNotReady(err) from err raise ConfigEntryNotReady(err) from err
weather_coordinator = WeatherUpdateCoordinator(hass, entry, aemet) weather_coordinator = WeatherUpdateCoordinator(hass, aemet)
await weather_coordinator.async_config_entry_first_refresh() await weather_coordinator.async_config_entry_first_refresh()
entry.runtime_data = AemetData(name=name, coordinator=weather_coordinator) entry.runtime_data = AemetData(name=name, coordinator=weather_coordinator)

View file

@ -3,7 +3,6 @@
from __future__ import annotations from __future__ import annotations
from asyncio import timeout from asyncio import timeout
from dataclasses import dataclass
from datetime import timedelta from datetime import timedelta
import logging import logging
from typing import Any, Final, cast from typing import Any, Final, cast
@ -20,7 +19,6 @@ from aemet_opendata.helpers import dict_nested_value
from aemet_opendata.interface import AEMET from aemet_opendata.interface import AEMET
from homeassistant.components.weather import Forecast from homeassistant.components.weather import Forecast
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
@ -31,16 +29,6 @@ _LOGGER = logging.getLogger(__name__)
API_TIMEOUT: Final[int] = 120 API_TIMEOUT: Final[int] = 120
WEATHER_UPDATE_INTERVAL = timedelta(minutes=10) WEATHER_UPDATE_INTERVAL = timedelta(minutes=10)
type AemetConfigEntry = ConfigEntry[AemetData]
@dataclass
class AemetData:
"""Aemet runtime data."""
name: str
coordinator: WeatherUpdateCoordinator
class WeatherUpdateCoordinator(DataUpdateCoordinator): class WeatherUpdateCoordinator(DataUpdateCoordinator):
"""Weather data update coordinator.""" """Weather data update coordinator."""
@ -48,7 +36,6 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator):
def __init__( def __init__(
self, self,
hass: HomeAssistant, hass: HomeAssistant,
entry: AemetConfigEntry,
aemet: AEMET, aemet: AEMET,
) -> None: ) -> None:
"""Initialize coordinator.""" """Initialize coordinator."""
@ -57,7 +44,6 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator):
super().__init__( super().__init__(
hass, hass,
_LOGGER, _LOGGER,
config_entry=entry,
name=DOMAIN, name=DOMAIN,
update_interval=WEATHER_UPDATE_INTERVAL, update_interval=WEATHER_UPDATE_INTERVAL,
) )

View file

@ -15,7 +15,7 @@ from homeassistant.const import (
) )
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from .coordinator import AemetConfigEntry from . import AemetConfigEntry
TO_REDACT_CONFIG = [ TO_REDACT_CONFIG = [
CONF_API_KEY, CONF_API_KEY,

View file

@ -55,6 +55,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util import dt as dt_util from homeassistant.util import dt as dt_util
from . import AemetConfigEntry
from .const import ( from .const import (
ATTR_API_CONDITION, ATTR_API_CONDITION,
ATTR_API_FORECAST_CONDITION, ATTR_API_FORECAST_CONDITION,
@ -86,7 +87,7 @@ from .const import (
ATTR_API_WIND_SPEED, ATTR_API_WIND_SPEED,
CONDITIONS_MAP, CONDITIONS_MAP,
) )
from .coordinator import AemetConfigEntry, WeatherUpdateCoordinator from .coordinator import WeatherUpdateCoordinator
from .entity import AemetEntity from .entity import AemetEntity
@ -248,7 +249,6 @@ WEATHER_SENSORS: Final[tuple[AemetSensorEntityDescription, ...]] = (
name="Rain", name="Rain",
native_unit_of_measurement=UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR, native_unit_of_measurement=UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR,
device_class=SensorDeviceClass.PRECIPITATION_INTENSITY, device_class=SensorDeviceClass.PRECIPITATION_INTENSITY,
state_class=SensorStateClass.MEASUREMENT,
), ),
AemetSensorEntityDescription( AemetSensorEntityDescription(
key=ATTR_API_RAIN_PROB, key=ATTR_API_RAIN_PROB,
@ -263,7 +263,6 @@ WEATHER_SENSORS: Final[tuple[AemetSensorEntityDescription, ...]] = (
name="Snow", name="Snow",
native_unit_of_measurement=UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR, native_unit_of_measurement=UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR,
device_class=SensorDeviceClass.PRECIPITATION_INTENSITY, device_class=SensorDeviceClass.PRECIPITATION_INTENSITY,
state_class=SensorStateClass.MEASUREMENT,
), ),
AemetSensorEntityDescription( AemetSensorEntityDescription(
key=ATTR_API_SNOW_PROB, key=ATTR_API_SNOW_PROB,

View file

@ -27,8 +27,9 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import AemetConfigEntry
from .const import CONDITIONS_MAP from .const import CONDITIONS_MAP
from .coordinator import AemetConfigEntry, WeatherUpdateCoordinator from .coordinator import WeatherUpdateCoordinator
from .entity import AemetEntity from .entity import AemetEntity

View file

@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/agent_dvr", "documentation": "https://www.home-assistant.io/integrations/agent_dvr",
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["agent"], "loggers": ["agent"],
"requirements": ["agent-py==0.0.24"] "requirements": ["agent-py==0.0.23"]
} }

View file

@ -24,5 +24,5 @@
"dependencies": ["bluetooth_adapters"], "dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/airthings_ble", "documentation": "https://www.home-assistant.io/integrations/airthings_ble",
"iot_class": "local_polling", "iot_class": "local_polling",
"requirements": ["airthings-ble==0.9.2"] "requirements": ["airthings-ble==0.9.1"]
} }

View file

@ -11,5 +11,5 @@
"documentation": "https://www.home-assistant.io/integrations/airzone", "documentation": "https://www.home-assistant.io/integrations/airzone",
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["aioairzone"], "loggers": ["aioairzone"],
"requirements": ["aioairzone==0.9.6"] "requirements": ["aioairzone==0.9.5"]
} }

View file

@ -6,7 +6,7 @@ import asyncio
from datetime import timedelta from datetime import timedelta
from functools import partial from functools import partial
import logging import logging
from typing import TYPE_CHECKING, Any, Final, final from typing import Any, Final, final
from propcache import cached_property from propcache import cached_property
import voluptuous as vol import voluptuous as vol
@ -221,15 +221,9 @@ class AlarmControlPanelEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_A
@property @property
def state(self) -> str | None: def state(self) -> str | None:
"""Return the current state.""" """Return the current state."""
if (alarm_state := self.alarm_state) is not None: if (alarm_state := self.alarm_state) is 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 return None
return alarm_state
@cached_property @cached_property
def alarm_state(self) -> AlarmControlPanelState | None: def alarm_state(self) -> AlarmControlPanelState | None:

View file

@ -1083,13 +1083,7 @@ async def async_api_arm(
arm_state = directive.payload["armState"] arm_state = directive.payload["armState"]
data: dict[str, Any] = {ATTR_ENTITY_ID: entity.entity_id} data: dict[str, Any] = {ATTR_ENTITY_ID: entity.entity_id}
# Per Alexa Documentation: users are not allowed to switch from armed_away if entity.state != alarm_control_panel.AlarmControlPanelState.DISARMED:
# directly to another armed state without first disarming the system.
# https://developer.amazon.com/en-US/docs/alexa/device-apis/alexa-securitypanelcontroller.html#arming
if (
entity.state == alarm_control_panel.AlarmControlPanelState.ARMED_AWAY
and arm_state != "ARMED_AWAY"
):
msg = "You must disarm the system before you can set the requested arm state." msg = "You must disarm the system before you can set the requested arm state."
raise AlexaSecurityPanelAuthorizationRequired(msg) raise AlexaSecurityPanelAuthorizationRequired(msg)

View file

@ -27,8 +27,8 @@ from homeassistant.config_entries import SOURCE_IGNORE
from homeassistant.const import ATTR_DOMAIN, __version__ as HA_VERSION from homeassistant.const import ATTR_DOMAIN, __version__ as HA_VERSION
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
import homeassistant.helpers.entity_registry as er
from homeassistant.helpers.hassio import is_hassio from homeassistant.helpers.hassio import is_hassio
from homeassistant.helpers.storage import Store from homeassistant.helpers.storage import Store
from homeassistant.helpers.system_info import async_get_system_info from homeassistant.helpers.system_info import async_get_system_info
@ -370,3 +370,71 @@ class Analytics:
for entry in entries for entry in entries
if entry.source != SOURCE_IGNORE and entry.disabled_by is None if entry.source != SOURCE_IGNORE and entry.disabled_by is None
) )
@callback
def async_devices_payload(hass: HomeAssistant) -> dict:
"""Return the devices payload."""
integrations_without_model_id: set[str] = set()
devices: list[dict[str, Any]] = []
dev_reg = dr.async_get(hass)
ignored_integrations = {
"bluetooth",
"esphome",
"hassio",
"mqtt",
}
# Devices that need via device info set
new_indexes: dict[str, int] = {}
via_devices: dict[str, str] = {}
for device in dev_reg.devices.values():
# Ignore services
if device.entry_type:
continue
if not device.primary_config_entry:
continue
config_entry = hass.config_entries.async_get_entry(device.primary_config_entry)
if config_entry is None:
continue
if config_entry.domain in ignored_integrations:
continue
if not device.model_id:
integrations_without_model_id.add(config_entry.domain)
continue
if not device.manufacturer:
continue
new_indexes[device.id] = len(devices)
devices.append(
{
"integration": config_entry.domain,
"manufacturer": device.manufacturer,
"model_id": device.model_id,
"model": device.model,
"sw_version": device.sw_version,
"hw_version": device.hw_version,
"has_suggested_area": device.suggested_area is not None,
"has_configuration_url": device.configuration_url is not None,
"via_device": None,
}
)
if device.via_device_id:
via_devices[device.id] = device.via_device_id
for from_device, via_device in via_devices.items():
if via_device not in new_indexes:
continue
devices[new_indexes[from_device]]["via_device"] = new_indexes[via_device]
return {
"version": "home-assistant:1",
"no_model_id": sorted(integrations_without_model_id),
"devices": devices,
}

View file

@ -16,6 +16,7 @@ from homeassistant.config_entries import (
ConfigFlow, ConfigFlow,
ConfigFlowResult, ConfigFlowResult,
OptionsFlow, OptionsFlow,
OptionsFlowWithConfigEntry,
) )
from homeassistant.core import callback from homeassistant.core import callback
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
@ -45,11 +46,9 @@ class HomeassistantAnalyticsConfigFlow(ConfigFlow, domain=DOMAIN):
@staticmethod @staticmethod
@callback @callback
def async_get_options_flow( def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow:
config_entry: ConfigEntry,
) -> HomeassistantAnalyticsOptionsFlowHandler:
"""Get the options flow for this handler.""" """Get the options flow for this handler."""
return HomeassistantAnalyticsOptionsFlowHandler() return HomeassistantAnalyticsOptionsFlowHandler(config_entry)
async def async_step_user( async def async_step_user(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
@ -133,7 +132,7 @@ class HomeassistantAnalyticsConfigFlow(ConfigFlow, domain=DOMAIN):
) )
class HomeassistantAnalyticsOptionsFlowHandler(OptionsFlow): class HomeassistantAnalyticsOptionsFlowHandler(OptionsFlowWithConfigEntry):
"""Handle Homeassistant Analytics options.""" """Handle Homeassistant Analytics options."""
async def async_step_init( async def async_step_init(
@ -212,6 +211,6 @@ class HomeassistantAnalyticsOptionsFlowHandler(OptionsFlow):
), ),
}, },
), ),
self.config_entry.options, self.options,
), ),
) )

View file

@ -13,7 +13,7 @@ from homeassistant.config_entries import (
ConfigEntry, ConfigEntry,
ConfigFlow, ConfigFlow,
ConfigFlowResult, ConfigFlowResult,
OptionsFlow, OptionsFlowWithConfigEntry,
) )
from homeassistant.const import CONF_DEVICE_CLASS, CONF_HOST, CONF_PORT from homeassistant.const import CONF_DEVICE_CLASS, CONF_HOST, CONF_PORT
from homeassistant.core import callback from homeassistant.core import callback
@ -186,14 +186,16 @@ class AndroidTVFlowHandler(ConfigFlow, domain=DOMAIN):
return OptionsFlowHandler(config_entry) return OptionsFlowHandler(config_entry)
class OptionsFlowHandler(OptionsFlow): class OptionsFlowHandler(OptionsFlowWithConfigEntry):
"""Handle an option flow for Android Debug Bridge.""" """Handle an option flow for Android Debug Bridge."""
def __init__(self, config_entry: ConfigEntry) -> None: def __init__(self, config_entry: ConfigEntry) -> None:
"""Initialize options flow.""" """Initialize options flow."""
self._apps: dict[str, Any] = dict(config_entry.options.get(CONF_APPS, {})) super().__init__(config_entry)
self._state_det_rules: dict[str, Any] = dict(
config_entry.options.get(CONF_STATE_DETECTION_RULES, {}) self._apps: dict[str, Any] = self.options.setdefault(CONF_APPS, {})
self._state_det_rules: dict[str, Any] = self.options.setdefault(
CONF_STATE_DETECTION_RULES, {}
) )
self._conf_app_id: str | None = None self._conf_app_id: str | None = None
self._conf_rule_id: str | None = None self._conf_rule_id: str | None = None
@ -235,7 +237,7 @@ class OptionsFlowHandler(OptionsFlow):
SelectOptionDict(value=k, label=v) for k, v in apps_list.items() SelectOptionDict(value=k, label=v) for k, v in apps_list.items()
] ]
rules = [RULES_NEW_ID, *self._state_det_rules] rules = [RULES_NEW_ID, *self._state_det_rules]
options = self.config_entry.options options = self.options
data_schema = vol.Schema( data_schema = vol.Schema(
{ {

View file

@ -20,7 +20,7 @@ from homeassistant.config_entries import (
ConfigEntry, ConfigEntry,
ConfigFlow, ConfigFlow,
ConfigFlowResult, ConfigFlowResult,
OptionsFlow, OptionsFlowWithConfigEntry,
) )
from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME
from homeassistant.core import callback from homeassistant.core import callback
@ -221,12 +221,13 @@ class AndroidTVRemoteConfigFlow(ConfigFlow, domain=DOMAIN):
return AndroidTVRemoteOptionsFlowHandler(config_entry) return AndroidTVRemoteOptionsFlowHandler(config_entry)
class AndroidTVRemoteOptionsFlowHandler(OptionsFlow): class AndroidTVRemoteOptionsFlowHandler(OptionsFlowWithConfigEntry):
"""Android TV Remote options flow.""" """Android TV Remote options flow."""
def __init__(self, config_entry: ConfigEntry) -> None: def __init__(self, config_entry: ConfigEntry) -> None:
"""Initialize options flow.""" """Initialize options flow."""
self._apps: dict[str, Any] = dict(config_entry.options.get(CONF_APPS, {})) super().__init__(config_entry)
self._apps: dict[str, Any] = self.options.setdefault(CONF_APPS, {})
self._conf_app_id: str | None = None self._conf_app_id: str | None = None
@callback @callback

View file

@ -121,6 +121,7 @@ class AnthropicOptionsFlow(OptionsFlow):
def __init__(self, config_entry: ConfigEntry) -> None: def __init__(self, config_entry: ConfigEntry) -> None:
"""Initialize options flow.""" """Initialize options flow."""
self.config_entry = config_entry
self.last_rendered_recommended = config_entry.options.get( self.last_rendered_recommended = config_entry.options.get(
CONF_RECOMMENDED, False CONF_RECOMMENDED, False
) )

View file

@ -22,8 +22,8 @@ class EnhancedAudioChunk:
timestamp_ms: int timestamp_ms: int
"""Timestamp relative to start of audio stream (milliseconds)""" """Timestamp relative to start of audio stream (milliseconds)"""
speech_probability: float | None is_speech: bool | None
"""Probability that audio chunk contains speech (0-1), None if unknown""" """True if audio chunk likely contains speech, False if not, None if unknown"""
class AudioEnhancer(ABC): class AudioEnhancer(ABC):
@ -70,27 +70,27 @@ class MicroVadSpeexEnhancer(AudioEnhancer):
) )
self.vad: MicroVad | None = None self.vad: MicroVad | None = None
self.threshold = 0.5
if self.is_vad_enabled: if self.is_vad_enabled:
self.vad = MicroVad() self.vad = MicroVad()
_LOGGER.debug("Initialized microVAD") _LOGGER.debug("Initialized microVAD with threshold=%s", self.threshold)
def enhance_chunk(self, audio: bytes, timestamp_ms: int) -> EnhancedAudioChunk: def enhance_chunk(self, audio: bytes, timestamp_ms: int) -> EnhancedAudioChunk:
"""Enhance 10ms chunk of PCM audio @ 16Khz with 16-bit mono samples.""" """Enhance 10ms chunk of PCM audio @ 16Khz with 16-bit mono samples."""
speech_probability: float | None = None is_speech: bool | None = None
assert len(audio) == BYTES_PER_CHUNK assert len(audio) == BYTES_PER_CHUNK
if self.vad is not None: if self.vad is not None:
# Run VAD # Run VAD
speech_probability = self.vad.Process10ms(audio) speech_prob = self.vad.Process10ms(audio)
is_speech = speech_prob > self.threshold
if self.audio_processor is not None: if self.audio_processor is not None:
# Run noise suppression and auto gain # Run noise suppression and auto gain
audio = self.audio_processor.Process10ms(audio).audio audio = self.audio_processor.Process10ms(audio).audio
return EnhancedAudioChunk( return EnhancedAudioChunk(
audio=audio, audio=audio, timestamp_ms=timestamp_ms, is_speech=is_speech
timestamp_ms=timestamp_ms,
speech_probability=speech_probability,
) )

View file

@ -780,9 +780,7 @@ class PipelineRun:
# speaking the voice command. # speaking the voice command.
audio_chunks_for_stt.extend( audio_chunks_for_stt.extend(
EnhancedAudioChunk( EnhancedAudioChunk(
audio=chunk_ts[0], audio=chunk_ts[0], timestamp_ms=chunk_ts[1], is_speech=False
timestamp_ms=chunk_ts[1],
speech_probability=None,
) )
for chunk_ts in result.queued_audio for chunk_ts in result.queued_audio
) )
@ -829,7 +827,7 @@ class PipelineRun:
if wake_word_vad is not None: if wake_word_vad is not None:
chunk_seconds = (len(chunk.audio) // sample_width) / sample_rate chunk_seconds = (len(chunk.audio) // sample_width) / sample_rate
if not wake_word_vad.process(chunk_seconds, chunk.speech_probability): if not wake_word_vad.process(chunk_seconds, chunk.is_speech):
raise WakeWordTimeoutError( raise WakeWordTimeoutError(
code="wake-word-timeout", message="Wake word was not detected" code="wake-word-timeout", message="Wake word was not detected"
) )
@ -957,7 +955,7 @@ class PipelineRun:
if stt_vad is not None: if stt_vad is not None:
chunk_seconds = (len(chunk.audio) // sample_width) / sample_rate chunk_seconds = (len(chunk.audio) // sample_width) / sample_rate
if not stt_vad.process(chunk_seconds, chunk.speech_probability): if not stt_vad.process(chunk_seconds, chunk.is_speech):
# Silence detected at the end of voice command # Silence detected at the end of voice command
self.process_event( self.process_event(
PipelineEvent( PipelineEvent(
@ -1223,7 +1221,7 @@ class PipelineRun:
yield EnhancedAudioChunk( yield EnhancedAudioChunk(
audio=sub_chunk, audio=sub_chunk,
timestamp_ms=timestamp_ms, timestamp_ms=timestamp_ms,
speech_probability=None, # no VAD is_speech=None, # no VAD
) )
timestamp_ms += MS_PER_CHUNK timestamp_ms += MS_PER_CHUNK

View file

@ -75,7 +75,7 @@ class AudioBuffer:
class VoiceCommandSegmenter: class VoiceCommandSegmenter:
"""Segments an audio stream into voice commands.""" """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.""" """Seconds of speech before voice command has started."""
command_seconds: float = 1.0 command_seconds: float = 1.0
@ -96,12 +96,6 @@ class VoiceCommandSegmenter:
timed_out: bool = False timed_out: bool = False
"""True a timeout occurred during voice command.""" """True a timeout occurred during voice command."""
before_command_speech_threshold: float = 0.2
"""Probability threshold for speech before voice command."""
in_command_speech_threshold: float = 0.5
"""Probability threshold for speech during voice command."""
_speech_seconds_left: float = 0.0 _speech_seconds_left: float = 0.0
"""Seconds left before considering voice command as started.""" """Seconds left before considering voice command as started."""
@ -130,7 +124,7 @@ class VoiceCommandSegmenter:
self._reset_seconds_left = self.reset_seconds self._reset_seconds_left = self.reset_seconds
self.in_command = False self.in_command = False
def process(self, chunk_seconds: float, speech_probability: float | None) -> bool: def process(self, chunk_seconds: float, is_speech: bool | None) -> bool:
"""Process samples using external VAD. """Process samples using external VAD.
Returns False when command is done. Returns False when command is done.
@ -148,12 +142,7 @@ class VoiceCommandSegmenter:
self.timed_out = True self.timed_out = True
return False return False
if speech_probability is None:
speech_probability = 0.0
if not self.in_command: if not self.in_command:
# Before command
is_speech = speech_probability > self.before_command_speech_threshold
if is_speech: if is_speech:
self._reset_seconds_left = self.reset_seconds self._reset_seconds_left = self.reset_seconds
self._speech_seconds_left -= chunk_seconds self._speech_seconds_left -= chunk_seconds
@ -171,17 +160,12 @@ class VoiceCommandSegmenter:
if self._reset_seconds_left <= 0: if self._reset_seconds_left <= 0:
self._speech_seconds_left = self.speech_seconds self._speech_seconds_left = self.speech_seconds
self._reset_seconds_left = self.reset_seconds self._reset_seconds_left = self.reset_seconds
else: elif not is_speech:
# In command
is_speech = speech_probability > self.in_command_speech_threshold
if not is_speech:
# Silence in command # Silence in command
self._reset_seconds_left = self.reset_seconds self._reset_seconds_left = self.reset_seconds
self._silence_seconds_left -= chunk_seconds self._silence_seconds_left -= chunk_seconds
self._command_seconds_left -= chunk_seconds self._command_seconds_left -= chunk_seconds
if (self._silence_seconds_left <= 0) and ( if (self._silence_seconds_left <= 0) and (self._command_seconds_left <= 0):
self._command_seconds_left <= 0
):
# Command finished successfully # Command finished successfully
self.reset() self.reset()
_LOGGER.debug("Voice command finished") _LOGGER.debug("Voice command finished")
@ -242,9 +226,6 @@ class VoiceActivityTimeout:
reset_seconds: float = 0.5 reset_seconds: float = 0.5
"""Seconds of speech before resetting timeout.""" """Seconds of speech before resetting timeout."""
speech_threshold: float = 0.5
"""Threshold for speech."""
_silence_seconds_left: float = 0.0 _silence_seconds_left: float = 0.0
"""Seconds left before considering voice command as stopped.""" """Seconds left before considering voice command as stopped."""
@ -260,15 +241,12 @@ class VoiceActivityTimeout:
self._silence_seconds_left = self.silence_seconds self._silence_seconds_left = self.silence_seconds
self._reset_seconds_left = self.reset_seconds self._reset_seconds_left = self.reset_seconds
def process(self, chunk_seconds: float, speech_probability: float | None) -> bool: def process(self, chunk_seconds: float, is_speech: bool | None) -> bool:
"""Process samples using external VAD. """Process samples using external VAD.
Returns False when timeout is reached. Returns False when timeout is reached.
""" """
if speech_probability is None: if is_speech:
speech_probability = 0.0
if speech_probability > self.speech_threshold:
# Speech # Speech
self._reset_seconds_left -= chunk_seconds self._reset_seconds_left -= chunk_seconds
if self._reset_seconds_left <= 0: if self._reset_seconds_left <= 0:

View file

@ -18,7 +18,7 @@ from homeassistant.config_entries import (
ConfigEntry, ConfigEntry,
ConfigFlow, ConfigFlow,
ConfigFlowResult, ConfigFlowResult,
OptionsFlow, OptionsFlowWithConfigEntry,
) )
from homeassistant.const import ( from homeassistant.const import (
CONF_HOST, CONF_HOST,
@ -59,11 +59,9 @@ class AxisFlowHandler(ConfigFlow, domain=AXIS_DOMAIN):
@staticmethod @staticmethod
@callback @callback
def async_get_options_flow( def async_get_options_flow(config_entry: ConfigEntry) -> AxisOptionsFlowHandler:
config_entry: ConfigEntry,
) -> AxisOptionsFlowHandler:
"""Get the options flow for this handler.""" """Get the options flow for this handler."""
return AxisOptionsFlowHandler() return AxisOptionsFlowHandler(config_entry)
def __init__(self) -> None: def __init__(self) -> None:
"""Initialize the Axis config flow.""" """Initialize the Axis config flow."""
@ -266,7 +264,7 @@ class AxisFlowHandler(ConfigFlow, domain=AXIS_DOMAIN):
return await self.async_step_user() return await self.async_step_user()
class AxisOptionsFlowHandler(OptionsFlow): class AxisOptionsFlowHandler(OptionsFlowWithConfigEntry):
"""Handle Axis device options.""" """Handle Axis device options."""
config_entry: AxisConfigEntry config_entry: AxisConfigEntry
@ -284,7 +282,8 @@ class AxisOptionsFlowHandler(OptionsFlow):
) -> ConfigFlowResult: ) -> ConfigFlowResult:
"""Manage the Axis device stream options.""" """Manage the Axis device stream options."""
if user_input is not None: if user_input is not None:
return self.async_create_entry(data=self.config_entry.options | user_input) self.options.update(user_input)
return self.async_create_entry(title="", data=self.options)
schema = {} schema = {}

View file

@ -124,9 +124,7 @@ class AEHConfigFlow(ConfigFlow, domain=DOMAIN):
step_id=STEP_CONN_STRING, step_id=STEP_CONN_STRING,
data_schema=CONN_STRING_SCHEMA, data_schema=CONN_STRING_SCHEMA,
errors=errors, errors=errors,
description_placeholders={ description_placeholders=self._data[CONF_EVENT_HUB_INSTANCE_NAME],
"event_hub_instance_name": self._data[CONF_EVENT_HUB_INSTANCE_NAME]
},
last_step=True, last_step=True,
) )
@ -146,9 +144,7 @@ class AEHConfigFlow(ConfigFlow, domain=DOMAIN):
step_id=STEP_SAS, step_id=STEP_SAS,
data_schema=SAS_SCHEMA, data_schema=SAS_SCHEMA,
errors=errors, errors=errors,
description_placeholders={ description_placeholders=self._data[CONF_EVENT_HUB_INSTANCE_NAME],
"event_hub_instance_name": self._data[CONF_EVENT_HUB_INSTANCE_NAME]
},
last_step=True, last_step=True,
) )

View file

@ -32,9 +32,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async def async_handle_create_service(call: ServiceCall) -> None: async def async_handle_create_service(call: ServiceCall) -> None:
"""Service handler for creating backups.""" """Service handler for creating backups."""
await backup_manager.async_create_backup(on_progress=None) await backup_manager.async_create_backup()
if backup_task := backup_manager.backup_task:
await backup_task
hass.services.async_register(DOMAIN, "create", async_handle_create_service) hass.services.async_register(DOMAIN, "create", async_handle_create_service)

View file

@ -2,26 +2,23 @@
from __future__ import annotations from __future__ import annotations
import asyncio
from http import HTTPStatus from http import HTTPStatus
from typing import cast
from aiohttp import BodyPartReader
from aiohttp.hdrs import CONTENT_DISPOSITION from aiohttp.hdrs import CONTENT_DISPOSITION
from aiohttp.web import FileResponse, Request, Response from aiohttp.web import FileResponse, Request, Response
from homeassistant.components.http import KEY_HASS, HomeAssistantView, require_admin from homeassistant.components.http import KEY_HASS, HomeAssistantView
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.util import slugify from homeassistant.util import slugify
from .const import DATA_MANAGER from .const import DOMAIN
from .manager import BaseBackupManager
@callback @callback
def async_register_http_views(hass: HomeAssistant) -> None: def async_register_http_views(hass: HomeAssistant) -> None:
"""Register the http views.""" """Register the http views."""
hass.http.register_view(DownloadBackupView) hass.http.register_view(DownloadBackupView)
hass.http.register_view(UploadBackupView)
class DownloadBackupView(HomeAssistantView): class DownloadBackupView(HomeAssistantView):
@ -39,7 +36,7 @@ class DownloadBackupView(HomeAssistantView):
if not request["hass_user"].is_admin: if not request["hass_user"].is_admin:
return Response(status=HTTPStatus.UNAUTHORIZED) return Response(status=HTTPStatus.UNAUTHORIZED)
manager = request.app[KEY_HASS].data[DATA_MANAGER] manager: BaseBackupManager = request.app[KEY_HASS].data[DOMAIN]
backup = await manager.async_get_backup(slug=slug) backup = await manager.async_get_backup(slug=slug)
if backup is None or not backup.path.exists(): if backup is None or not backup.path.exists():
@ -51,29 +48,3 @@ class DownloadBackupView(HomeAssistantView):
CONTENT_DISPOSITION: f"attachment; filename={slugify(backup.name)}.tar" CONTENT_DISPOSITION: f"attachment; filename={slugify(backup.name)}.tar"
}, },
) )
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."""
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)
except OSError as err:
return Response(
body=f"Can't write backup file {err}",
status=HTTPStatus.INTERNAL_SERVER_ERROR,
)
except asyncio.CancelledError:
return Response(status=HTTPStatus.INTERNAL_SERVER_ERROR)
return Response(status=HTTPStatus.CREATED)

View file

@ -4,21 +4,16 @@ from __future__ import annotations
import abc import abc
import asyncio import asyncio
from collections.abc import Callable
from dataclasses import asdict, dataclass from dataclasses import asdict, dataclass
import hashlib import hashlib
import io import io
import json import json
from pathlib import Path from pathlib import Path
from queue import SimpleQueue
import shutil
import tarfile import tarfile
from tarfile import TarError from tarfile import TarError
from tempfile import TemporaryDirectory
import time import time
from typing import Any, Protocol, cast from typing import Any, Protocol, cast
import aiohttp
from securetar import SecureTarFile, atomic_contents_add from securetar import SecureTarFile, atomic_contents_add
from homeassistant.backup_restore import RESTORE_BACKUP_FILE from homeassistant.backup_restore import RESTORE_BACKUP_FILE
@ -35,13 +30,6 @@ from .const import DOMAIN, EXCLUDE_FROM_BACKUP, LOGGER
BUF_SIZE = 2**20 * 4 # 4MB BUF_SIZE = 2**20 * 4 # 4MB
@dataclass(slots=True)
class NewBackup:
"""New backup class."""
slug: str
@dataclass(slots=True) @dataclass(slots=True)
class Backup: class Backup:
"""Backup class.""" """Backup class."""
@ -57,15 +45,6 @@ class Backup:
return {**asdict(self), "path": self.path.as_posix()} return {**asdict(self), "path": self.path.as_posix()}
@dataclass(slots=True)
class BackupProgress:
"""Backup progress class."""
done: bool
stage: str | None
success: bool | None
class BackupPlatformProtocol(Protocol): class BackupPlatformProtocol(Protocol):
"""Define the format that backup platforms can have.""" """Define the format that backup platforms can have."""
@ -82,7 +61,7 @@ class BaseBackupManager(abc.ABC):
def __init__(self, hass: HomeAssistant) -> None: def __init__(self, hass: HomeAssistant) -> None:
"""Initialize the backup manager.""" """Initialize the backup manager."""
self.hass = hass self.hass = hass
self.backup_task: asyncio.Task | None = None self.backing_up = False
self.backups: dict[str, Backup] = {} self.backups: dict[str, Backup] = {}
self.loaded_platforms = False self.loaded_platforms = False
self.platforms: dict[str, BackupPlatformProtocol] = {} self.platforms: dict[str, BackupPlatformProtocol] = {}
@ -147,15 +126,10 @@ class BaseBackupManager(abc.ABC):
@abc.abstractmethod @abc.abstractmethod
async def async_restore_backup(self, slug: str, **kwargs: Any) -> None: async def async_restore_backup(self, slug: str, **kwargs: Any) -> None:
"""Restore a backup.""" """Restpre a backup."""
@abc.abstractmethod @abc.abstractmethod
async def async_create_backup( async def async_create_backup(self, **kwargs: Any) -> Backup:
self,
*,
on_progress: Callable[[BackupProgress], None] | None,
**kwargs: Any,
) -> NewBackup:
"""Generate a backup.""" """Generate a backup."""
@abc.abstractmethod @abc.abstractmethod
@ -173,15 +147,6 @@ class BaseBackupManager(abc.ABC):
async def async_remove_backup(self, *, slug: str, **kwargs: Any) -> None: async def async_remove_backup(self, *, slug: str, **kwargs: Any) -> None:
"""Remove a backup.""" """Remove a backup."""
@abc.abstractmethod
async def async_receive_backup(
self,
*,
contents: aiohttp.BodyPartReader,
**kwargs: Any,
) -> None:
"""Receive and store a backup file from upload."""
class BackupManager(BaseBackupManager): class BackupManager(BaseBackupManager):
"""Backup manager for the Backup integration.""" """Backup manager for the Backup integration."""
@ -257,93 +222,17 @@ class BackupManager(BaseBackupManager):
LOGGER.debug("Removed backup located at %s", backup.path) LOGGER.debug("Removed backup located at %s", backup.path)
self.backups.pop(slug) self.backups.pop(slug)
async def async_receive_backup( async def async_create_backup(self, **kwargs: Any) -> Backup:
self,
*,
contents: aiohttp.BodyPartReader,
**kwargs: Any,
) -> None:
"""Receive and store a backup file from upload."""
queue: SimpleQueue[tuple[bytes, asyncio.Future[None] | None] | None] = (
SimpleQueue()
)
temp_dir_handler = await self.hass.async_add_executor_job(TemporaryDirectory)
target_temp_file = Path(
temp_dir_handler.name, contents.filename or "backup.tar"
)
def _sync_queue_consumer() -> None:
with target_temp_file.open("wb") as file_handle:
while True:
if (_chunk_future := queue.get()) is None:
break
_chunk, _future = _chunk_future
if _future is not None:
self.hass.loop.call_soon_threadsafe(_future.set_result, None)
file_handle.write(_chunk)
fut: asyncio.Future[None] | None = None
try:
fut = self.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 = self.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
def _move_and_cleanup() -> None:
shutil.move(target_temp_file, self.backup_dir / target_temp_file.name)
temp_dir_handler.cleanup()
await self.hass.async_add_executor_job(_move_and_cleanup)
await self.load_backups()
async def async_create_backup(
self,
*,
on_progress: Callable[[BackupProgress], None] | None,
**kwargs: Any,
) -> NewBackup:
"""Generate a backup.""" """Generate a backup."""
if self.backup_task: if self.backing_up:
raise HomeAssistantError("Backup already in progress") raise HomeAssistantError("Backup already in progress")
try:
self.backing_up = True
await self.async_pre_backup_actions()
backup_name = f"Core {HAVERSION}" backup_name = f"Core {HAVERSION}"
date_str = dt_util.now().isoformat() date_str = dt_util.now().isoformat()
slug = _generate_slug(date_str, backup_name) slug = _generate_slug(date_str, backup_name)
self.backup_task = self.hass.async_create_task(
self._async_create_backup(backup_name, date_str, slug, on_progress),
name="backup_manager_create_backup",
eager_start=False, # To ensure the task is not started before we return
)
return NewBackup(slug=slug)
async def _async_create_backup(
self,
backup_name: str,
date_str: str,
slug: str,
on_progress: Callable[[BackupProgress], None] | None,
) -> Backup:
"""Generate a backup."""
success = False
try:
await self.async_pre_backup_actions()
backup_data = { backup_data = {
"slug": slug, "slug": slug,
@ -370,12 +259,9 @@ class BackupManager(BaseBackupManager):
if self.loaded_backups: if self.loaded_backups:
self.backups[slug] = backup self.backups[slug] = backup
LOGGER.debug("Generated new backup with slug %s", slug) LOGGER.debug("Generated new backup with slug %s", slug)
success = True
return backup return backup
finally: finally:
if on_progress: self.backing_up = False
on_progress(BackupProgress(done=True, stage=None, success=success))
self.backup_task = None
await self.async_post_backup_actions() await self.async_post_backup_actions()
def _mkdir_and_generate_backup_contents( def _mkdir_and_generate_backup_contents(
@ -422,7 +308,7 @@ class BackupManager(BaseBackupManager):
def _write_restore_file() -> None: def _write_restore_file() -> None:
"""Write the restore file.""" """Write the restore file."""
Path(self.hass.config.path(RESTORE_BACKUP_FILE)).write_text( Path(self.hass.config.path(RESTORE_BACKUP_FILE)).write_text(
json.dumps({"path": backup.path.as_posix()}), f"{backup.path.as_posix()};",
encoding="utf-8", encoding="utf-8",
) )

View file

@ -8,7 +8,6 @@ from homeassistant.components import websocket_api
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from .const import DATA_MANAGER, LOGGER from .const import DATA_MANAGER, LOGGER
from .manager import BackupProgress
@callback @callback
@ -41,7 +40,7 @@ async def handle_info(
msg["id"], msg["id"],
{ {
"backups": list(backups.values()), "backups": list(backups.values()),
"backing_up": manager.backup_task is not None, "backing_up": manager.backing_up,
}, },
) )
@ -114,11 +113,7 @@ async def handle_create(
msg: dict[str, Any], msg: dict[str, Any],
) -> None: ) -> None:
"""Generate a backup.""" """Generate a backup."""
backup = await hass.data[DATA_MANAGER].async_create_backup()
def on_progress(progress: BackupProgress) -> None:
connection.send_message(websocket_api.event_message(msg["id"], progress))
backup = await hass.data[DATA_MANAGER].async_create_backup(on_progress=on_progress)
connection.send_result(msg["id"], backup) connection.send_result(msg["id"], backup)
@ -132,6 +127,7 @@ async def handle_backup_start(
) -> None: ) -> None:
"""Backup start notification.""" """Backup start notification."""
manager = hass.data[DATA_MANAGER] manager = hass.data[DATA_MANAGER]
manager.backing_up = True
LOGGER.debug("Backup start notification") LOGGER.debug("Backup start notification")
try: try:
@ -153,6 +149,7 @@ async def handle_backup_end(
) -> None: ) -> None:
"""Backup end notification.""" """Backup end notification."""
manager = hass.data[DATA_MANAGER] manager = hass.data[DATA_MANAGER]
manager.backing_up = False
LOGGER.debug("Backup end notification") LOGGER.debug("Backup end notification")
try: try:

View file

@ -17,9 +17,46 @@ from homeassistant.components.media_player import (
class BangOlufsenSource: class BangOlufsenSource:
"""Class used for associating device source ids with friendly names. May not include all sources.""" """Class used for associating device source ids with friendly names. May not include all sources."""
LINE_IN: Final[Source] = Source(name="Line-In", id="lineIn") URI_STREAMER: Final[Source] = Source(
SPDIF: Final[Source] = Source(name="Optical", id="spdif") name="Audio Streamer",
URI_STREAMER: Final[Source] = Source(name="Audio Streamer", id="uriStreamer") id="uriStreamer",
is_seekable=False,
)
BLUETOOTH: Final[Source] = Source(
name="Bluetooth",
id="bluetooth",
is_seekable=False,
)
CHROMECAST: Final[Source] = Source(
name="Chromecast built-in",
id="chromeCast",
is_seekable=False,
)
LINE_IN: Final[Source] = Source(
name="Line-In",
id="lineIn",
is_seekable=False,
)
SPDIF: Final[Source] = Source(
name="Optical",
id="spdif",
is_seekable=False,
)
NET_RADIO: Final[Source] = Source(
name="B&O Radio",
id="netRadio",
is_seekable=False,
)
DEEZER: Final[Source] = Source(
name="Deezer",
id="deezer",
is_seekable=True,
)
TIDAL: Final[Source] = Source(
name="Tidal",
id="tidal",
is_seekable=True,
)
BANG_OLUFSEN_STATES: dict[str, MediaPlayerState] = { BANG_OLUFSEN_STATES: dict[str, MediaPlayerState] = {
@ -133,6 +170,20 @@ VALID_MEDIA_TYPES: Final[tuple] = (
MediaType.CHANNEL, MediaType.CHANNEL,
) )
# Sources on the device that should not be selectable by the user
HIDDEN_SOURCE_IDS: Final[tuple] = (
"airPlay",
"bluetooth",
"chromeCast",
"generator",
"local",
"dlna",
"qplay",
"wpl",
"pl",
"beolink",
"usbIn",
)
# Fallback sources to use in case of API failure. # Fallback sources to use in case of API failure.
FALLBACK_SOURCES: Final[SourceArray] = SourceArray( FALLBACK_SOURCES: Final[SourceArray] = SourceArray(
@ -140,7 +191,7 @@ FALLBACK_SOURCES: Final[SourceArray] = SourceArray(
Source( Source(
id="uriStreamer", id="uriStreamer",
is_enabled=True, is_enabled=True,
is_playable=True, is_playable=False,
name="Audio Streamer", name="Audio Streamer",
type=SourceTypeEnum(value="uriStreamer"), type=SourceTypeEnum(value="uriStreamer"),
is_seekable=False, is_seekable=False,
@ -148,7 +199,7 @@ FALLBACK_SOURCES: Final[SourceArray] = SourceArray(
Source( Source(
id="bluetooth", id="bluetooth",
is_enabled=True, is_enabled=True,
is_playable=True, is_playable=False,
name="Bluetooth", name="Bluetooth",
type=SourceTypeEnum(value="bluetooth"), type=SourceTypeEnum(value="bluetooth"),
is_seekable=False, is_seekable=False,
@ -156,7 +207,7 @@ FALLBACK_SOURCES: Final[SourceArray] = SourceArray(
Source( Source(
id="spotify", id="spotify",
is_enabled=True, is_enabled=True,
is_playable=True, is_playable=False,
name="Spotify Connect", name="Spotify Connect",
type=SourceTypeEnum(value="spotify"), type=SourceTypeEnum(value="spotify"),
is_seekable=True, is_seekable=True,

View file

@ -1,9 +0,0 @@
{
"services": {
"beolink_join": { "service": "mdi:location-enter" },
"beolink_expand": { "service": "mdi:location-enter" },
"beolink_unexpand": { "service": "mdi:location-exit" },
"beolink_leave": { "service": "mdi:close-circle-outline" },
"beolink_allstandby": { "service": "mdi:close-circle-multiple-outline" }
}
}

View file

@ -11,7 +11,7 @@ from typing import TYPE_CHECKING, Any, cast
from aiohttp import ClientConnectorError from aiohttp import ClientConnectorError
from mozart_api import __version__ as MOZART_API_VERSION from mozart_api import __version__ as MOZART_API_VERSION
from mozart_api.exceptions import ApiException, NotFoundException from mozart_api.exceptions import ApiException
from mozart_api.models import ( from mozart_api.models import (
Action, Action,
Art, Art,
@ -38,7 +38,6 @@ from mozart_api.models import (
VolumeState, VolumeState,
) )
from mozart_api.mozart_client import MozartClient, get_highest_resolution_artwork from mozart_api.mozart_client import MozartClient, get_highest_resolution_artwork
import voluptuous as vol
from homeassistant.components import media_source from homeassistant.components import media_source
from homeassistant.components.media_player import ( from homeassistant.components.media_player import (
@ -56,17 +55,10 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_MODEL, Platform from homeassistant.const import CONF_MODEL, Platform
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import ( from homeassistant.helpers import entity_registry as er
config_validation as cv,
device_registry as dr,
entity_registry as er,
)
from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import ( from homeassistant.helpers.entity_platform import AddEntitiesCallback
AddEntitiesCallback,
async_get_current_platform,
)
from homeassistant.util.dt import utcnow from homeassistant.util.dt import utcnow
from . import BangOlufsenConfigEntry from . import BangOlufsenConfigEntry
@ -78,6 +70,7 @@ from .const import (
CONNECTION_STATUS, CONNECTION_STATUS,
DOMAIN, DOMAIN,
FALLBACK_SOURCES, FALLBACK_SOURCES,
HIDDEN_SOURCE_IDS,
VALID_MEDIA_TYPES, VALID_MEDIA_TYPES,
BangOlufsenMediaType, BangOlufsenMediaType,
BangOlufsenSource, BangOlufsenSource,
@ -124,58 +117,6 @@ async def async_setup_entry(
] ]
) )
# Register actions.
platform = async_get_current_platform()
jid_regex = vol.Match(
r"(^\d{4})[.](\d{7})[.](\d{8})(@products\.bang-olufsen\.com)$"
)
platform.async_register_entity_service(
name="beolink_join",
schema={vol.Optional("beolink_jid"): jid_regex},
func="async_beolink_join",
)
platform.async_register_entity_service(
name="beolink_expand",
schema={
vol.Exclusive("all_discovered", "devices", ""): cv.boolean,
vol.Exclusive(
"beolink_jids",
"devices",
"Define either specific Beolink JIDs or all discovered",
): vol.All(
cv.ensure_list,
[jid_regex],
),
},
func="async_beolink_expand",
)
platform.async_register_entity_service(
name="beolink_unexpand",
schema={
vol.Required("beolink_jids"): vol.All(
cv.ensure_list,
[jid_regex],
),
},
func="async_beolink_unexpand",
)
platform.async_register_entity_service(
name="beolink_leave",
schema=None,
func="async_beolink_leave",
)
platform.async_register_entity_service(
name="beolink_allstandby",
schema=None,
func="async_beolink_allstandby",
)
class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity): class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity):
"""Representation of a media player.""" """Representation of a media player."""
@ -216,8 +157,6 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity):
# Beolink compatible sources # Beolink compatible sources
self._beolink_sources: dict[str, bool] = {} self._beolink_sources: dict[str, bool] = {}
self._remote_leader: BeolinkLeader | None = None self._remote_leader: BeolinkLeader | None = None
# Extra state attributes for showing Beolink: peer(s), listener(s), leader and self
self._beolink_attributes: dict[str, dict[str, dict[str, str]]] = {}
async def async_added_to_hass(self) -> None: async def async_added_to_hass(self) -> None:
"""Turn on the dispatchers.""" """Turn on the dispatchers."""
@ -227,11 +166,9 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity):
CONNECTION_STATUS: self._async_update_connection_state, CONNECTION_STATUS: self._async_update_connection_state,
WebsocketNotification.ACTIVE_LISTENING_MODE: self._async_update_sound_modes, WebsocketNotification.ACTIVE_LISTENING_MODE: self._async_update_sound_modes,
WebsocketNotification.BEOLINK: self._async_update_beolink, WebsocketNotification.BEOLINK: self._async_update_beolink,
WebsocketNotification.CONFIGURATION: self._async_update_name_and_beolink,
WebsocketNotification.PLAYBACK_ERROR: self._async_update_playback_error, WebsocketNotification.PLAYBACK_ERROR: self._async_update_playback_error,
WebsocketNotification.PLAYBACK_METADATA: self._async_update_playback_metadata_and_beolink, WebsocketNotification.PLAYBACK_METADATA: self._async_update_playback_metadata_and_beolink,
WebsocketNotification.PLAYBACK_PROGRESS: self._async_update_playback_progress, WebsocketNotification.PLAYBACK_PROGRESS: self._async_update_playback_progress,
WebsocketNotification.PLAYBACK_SOURCE: self._async_update_sources,
WebsocketNotification.PLAYBACK_STATE: self._async_update_playback_state, WebsocketNotification.PLAYBACK_STATE: self._async_update_playback_state,
WebsocketNotification.REMOTE_MENU_CHANGED: self._async_update_sources, WebsocketNotification.REMOTE_MENU_CHANGED: self._async_update_sources,
WebsocketNotification.SOURCE_CHANGE: self._async_update_source_change, WebsocketNotification.SOURCE_CHANGE: self._async_update_source_change,
@ -293,9 +230,6 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity):
await self._async_update_sound_modes() await self._async_update_sound_modes()
# Update beolink attributes and device name.
await self._async_update_name_and_beolink()
async def async_update(self) -> None: async def async_update(self) -> None:
"""Update queue settings.""" """Update queue settings."""
# The WebSocket event listener is the main handler for connection state. # The WebSocket event listener is the main handler for connection state.
@ -309,7 +243,7 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity):
if queue_settings.shuffle is not None: if queue_settings.shuffle is not None:
self._attr_shuffle = queue_settings.shuffle self._attr_shuffle = queue_settings.shuffle
async def _async_update_sources(self, _: Source | None = None) -> None: async def _async_update_sources(self) -> None:
"""Get sources for the specific product.""" """Get sources for the specific product."""
# Audio sources # Audio sources
@ -336,7 +270,10 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity):
self._audio_sources = { self._audio_sources = {
source.id: source.name source.id: source.name
for source in cast(list[Source], sources.items) for source in cast(list[Source], sources.items)
if source.is_enabled and source.id and source.name and source.is_playable if source.is_enabled
and source.id
and source.name
and source.id not in HIDDEN_SOURCE_IDS
} }
# Some sources are not Beolink expandable, meaning that they can't be joined by # Some sources are not Beolink expandable, meaning that they can't be joined by
@ -438,44 +375,9 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity):
self.async_write_ha_state() self.async_write_ha_state()
async def _async_update_name_and_beolink(self) -> None:
"""Update the device friendly name."""
beolink_self = await self._client.get_beolink_self()
# Update device name
device_registry = dr.async_get(self.hass)
assert self.device_entry is not None
device_registry.async_update_device(
device_id=self.device_entry.id,
name=beolink_self.friendly_name,
)
await self._async_update_beolink()
async def _async_update_beolink(self) -> None: async def _async_update_beolink(self) -> None:
"""Update the current Beolink leader, listeners, peers and self.""" """Update the current Beolink leader, listeners, peers and self."""
self._beolink_attributes = {}
assert self.device_entry is not None
assert self.device_entry.name is not None
# Add Beolink self
self._beolink_attributes = {
"beolink": {"self": {self.device_entry.name: self._beolink_jid}}
}
# Add Beolink peers
peers = await self._client.get_beolink_peers()
if len(peers) > 0:
self._beolink_attributes["beolink"]["peers"] = {}
for peer in peers:
self._beolink_attributes["beolink"]["peers"][peer.friendly_name] = (
peer.jid
)
# Add Beolink listeners / leader # Add Beolink listeners / leader
self._remote_leader = self._playback_metadata.remote_leader self._remote_leader = self._playback_metadata.remote_leader
@ -495,14 +397,9 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity):
# Add self # Add self
group_members.append(self.entity_id) group_members.append(self.entity_id)
self._beolink_attributes["beolink"]["leader"] = {
self._remote_leader.friendly_name: self._remote_leader.jid,
}
# If not listener, check if leader. # If not listener, check if leader.
else: else:
beolink_listeners = await self._client.get_beolink_listeners() beolink_listeners = await self._client.get_beolink_listeners()
beolink_listeners_attribute = {}
# Check if the device is a leader. # Check if the device is a leader.
if len(beolink_listeners) > 0: if len(beolink_listeners) > 0:
@ -523,18 +420,6 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity):
for beolink_listener in beolink_listeners for beolink_listener in beolink_listeners
] ]
) )
# Update Beolink attributes
for beolink_listener in beolink_listeners:
for peer in peers:
if peer.jid == beolink_listener.jid:
# Get the friendly names for the listeners from the peers
beolink_listeners_attribute[peer.friendly_name] = (
beolink_listener.jid
)
break
self._beolink_attributes["beolink"]["listeners"] = (
beolink_listeners_attribute
)
self._attr_group_members = group_members self._attr_group_members = group_members
@ -688,19 +573,38 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity):
@property @property
def source(self) -> str | None: def source(self) -> str | None:
"""Return the current audio source.""" """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 return self._source_change.name
@property
def extra_state_attributes(self) -> dict[str, Any] | None:
"""Return information that is not returned anywhere else."""
attributes: dict[str, Any] = {}
# Add Beolink attributes
if self._beolink_attributes:
attributes.update(self._beolink_attributes)
return attributes
async def async_turn_off(self) -> None: async def async_turn_off(self) -> None:
"""Set the device to "networkStandby".""" """Set the device to "networkStandby"."""
await self._client.post_standby() await self._client.post_standby()
@ -972,30 +876,23 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity):
# Beolink compatible B&O device. # Beolink compatible B&O device.
# Repeated presses / calls will cycle between compatible playing devices. # Repeated presses / calls will cycle between compatible playing devices.
if len(group_members) == 0: if len(group_members) == 0:
await self.async_beolink_join() await self._async_beolink_join()
return return
# Get JID for each group member # Get JID for each group member
jids = [self._get_beolink_jid(group_member) for group_member in group_members] jids = [self._get_beolink_jid(group_member) for group_member in group_members]
await self.async_beolink_expand(jids) await self._async_beolink_expand(jids)
async def async_unjoin_player(self) -> None: async def async_unjoin_player(self) -> None:
"""Unjoin Beolink session. End session if leader.""" """Unjoin Beolink session. End session if leader."""
await self.async_beolink_leave() await self._async_beolink_leave()
# Custom actions: async def _async_beolink_join(self) -> None:
async def async_beolink_join(self, beolink_jid: str | None = None) -> None:
"""Join a Beolink multi-room experience.""" """Join a Beolink multi-room experience."""
if beolink_jid is None:
await self._client.join_latest_beolink_experience() await self._client.join_latest_beolink_experience()
else:
await self._client.join_beolink_peer(jid=beolink_jid)
async def async_beolink_expand( async def _async_beolink_expand(self, beolink_jids: list[str]) -> None:
self, beolink_jids: list[str] | None = None, all_discovered: bool = False
) -> None:
"""Expand a Beolink multi-room experience with a device or devices.""" """Expand a Beolink multi-room experience with a device or devices."""
# Ensure that the current source is expandable # Ensure that the current source is expandable
if not self._beolink_sources[cast(str, self._source_change.id)]: if not self._beolink_sources[cast(str, self._source_change.id)]:
raise ServiceValidationError( raise ServiceValidationError(
@ -1007,37 +904,10 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity):
}, },
) )
# Expand to all discovered devices
if all_discovered:
peers = await self._client.get_beolink_peers()
for peer in peers:
try:
await self._client.post_beolink_expand(jid=peer.jid)
except NotFoundException:
_LOGGER.warning("Unable to expand to %s", peer.jid)
# Try to expand to all defined devices # Try to expand to all defined devices
elif beolink_jids:
for beolink_jid in beolink_jids: for beolink_jid in beolink_jids:
try:
await self._client.post_beolink_expand(jid=beolink_jid) await self._client.post_beolink_expand(jid=beolink_jid)
except NotFoundException:
_LOGGER.warning(
"Unable to expand to %s. Is the device available on the network?",
beolink_jid,
)
async def async_beolink_unexpand(self, beolink_jids: list[str]) -> None: async def _async_beolink_leave(self) -> None:
"""Unexpand a Beolink multi-room experience with a device or devices."""
# Unexpand all defined devices
for beolink_jid in beolink_jids:
await self._client.post_beolink_unexpand(jid=beolink_jid)
async def async_beolink_leave(self) -> None:
"""Leave the current Beolink experience.""" """Leave the current Beolink experience."""
await self._client.post_beolink_leave() await self._client.post_beolink_leave()
async def async_beolink_allstandby(self) -> None:
"""Set all connected Beolink devices to standby."""
await self._client.post_beolink_allstandby()

View file

@ -1,79 +0,0 @@
beolink_allstandby:
target:
entity:
integration: bang_olufsen
domain: media_player
device:
integration: bang_olufsen
beolink_expand:
target:
entity:
integration: bang_olufsen
domain: media_player
device:
integration: bang_olufsen
fields:
all_discovered:
required: false
example: false
selector:
boolean:
jid_options:
collapsed: false
fields:
beolink_jids:
required: false
example: >-
[
1111.2222222.33333333@products.bang-olufsen.com,
4444.5555555.66666666@products.bang-olufsen.com
]
selector:
object:
beolink_join:
target:
entity:
integration: bang_olufsen
domain: media_player
device:
integration: bang_olufsen
fields:
jid_options:
collapsed: false
fields:
beolink_jid:
required: false
example: 1111.2222222.33333333@products.bang-olufsen.com
selector:
text:
beolink_leave:
target:
entity:
integration: bang_olufsen
domain: media_player
device:
integration: bang_olufsen
beolink_unexpand:
target:
entity:
integration: bang_olufsen
domain: media_player
device:
integration: bang_olufsen
fields:
jid_options:
collapsed: false
fields:
beolink_jids:
required: true
example: >-
[
1111.2222222.33333333@products.bang-olufsen.com,
4444.5555555.66666666@products.bang-olufsen.com
]
selector:
object:

View file

@ -1,8 +1,4 @@
{ {
"common": {
"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."
},
"config": { "config": {
"error": { "error": {
"api_exception": "[%key:common::config_flow::error::cannot_connect%]", "api_exception": "[%key:common::config_flow::error::cannot_connect%]",
@ -29,68 +25,6 @@
} }
} }
}, },
"services": {
"beolink_allstandby": {
"name": "Beolink all standby",
"description": "Set all Connected Beolink devices to standby."
},
"beolink_expand": {
"name": "Beolink expand",
"description": "Expand current Beolink experience.",
"fields": {
"all_discovered": {
"name": "All discovered",
"description": "Expand Beolink experience to all discovered devices."
},
"beolink_jids": {
"name": "Beolink JIDs",
"description": "Specify which Beolink JIDs will join current Beolink experience."
}
},
"sections": {
"jid_options": {
"name": "[%key:component::bang_olufsen::common::jid_options_name%]",
"description": "[%key:component::bang_olufsen::common::jid_options_description%]"
}
}
},
"beolink_join": {
"name": "Beolink join",
"description": "Join a Beolink experience.",
"fields": {
"beolink_jid": {
"name": "Beolink JID",
"description": "Manually specify Beolink JID to join."
}
},
"sections": {
"jid_options": {
"name": "[%key:component::bang_olufsen::common::jid_options_name%]",
"description": "[%key:component::bang_olufsen::common::jid_options_description%]"
}
}
},
"beolink_leave": {
"name": "Beolink leave",
"description": "Leave a Beolink experience."
},
"beolink_unexpand": {
"name": "Beolink unexpand",
"description": "Unexpand from current Beolink experience.",
"fields": {
"beolink_jids": {
"name": "Beolink JIDs",
"description": "Specify which Beolink JIDs will leave from current Beolink experience."
}
},
"sections": {
"jid_options": {
"name": "[%key:component::bang_olufsen::common::jid_options_name%]",
"description": "[%key:component::bang_olufsen::common::jid_options_description%]"
}
}
}
},
"exceptions": { "exceptions": {
"m3u_invalid_format": { "m3u_invalid_format": {
"message": "Media sources with the .m3u extension are not supported." "message": "Media sources with the .m3u extension are not supported."

View file

@ -63,9 +63,6 @@ class BangOlufsenWebsocket(BangOlufsenBase):
self._client.get_playback_progress_notifications( self._client.get_playback_progress_notifications(
self.on_playback_progress_notification self.on_playback_progress_notification
) )
self._client.get_playback_source_notifications(
self.on_playback_source_notification
)
self._client.get_playback_state_notifications( self._client.get_playback_state_notifications(
self.on_playback_state_notification self.on_playback_state_notification
) )
@ -120,11 +117,6 @@ class BangOlufsenWebsocket(BangOlufsenBase):
self.hass, self.hass,
f"{self._unique_id}_{WebsocketNotification.BEOLINK}", f"{self._unique_id}_{WebsocketNotification.BEOLINK}",
) )
elif notification_type is WebsocketNotification.CONFIGURATION:
async_dispatcher_send(
self.hass,
f"{self._unique_id}_{WebsocketNotification.CONFIGURATION}",
)
elif notification_type is WebsocketNotification.REMOTE_MENU_CHANGED: elif notification_type is WebsocketNotification.REMOTE_MENU_CHANGED:
async_dispatcher_send( async_dispatcher_send(
self.hass, self.hass,
@ -165,14 +157,6 @@ class BangOlufsenWebsocket(BangOlufsenBase):
notification, notification,
) )
def on_playback_source_notification(self, notification: Source) -> None:
"""Send playback_source dispatch."""
async_dispatcher_send(
self.hass,
f"{self._unique_id}_{WebsocketNotification.PLAYBACK_SOURCE}",
notification,
)
def on_source_change_notification(self, notification: Source) -> None: def on_source_change_notification(self, notification: Source) -> None:
"""Send source_change dispatch.""" """Send source_change dispatch."""
async_dispatcher_send( async_dispatcher_send(

View file

@ -10,11 +10,7 @@ from homeassistant.components.sensor import (
SensorEntityDescription, SensorEntityDescription,
SensorStateClass, SensorStateClass,
) )
from homeassistant.const import ( from homeassistant.const import EntityCategory, UnitOfTemperature
SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
EntityCategory,
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
@ -36,8 +32,6 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
SensorEntityDescription( SensorEntityDescription(
key=TYPE_WIFI_STRENGTH, key=TYPE_WIFI_STRENGTH,
translation_key="wifi_strength", translation_key="wifi_strength",
native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
device_class=SensorDeviceClass.SIGNAL_STRENGTH,
entity_category=EntityCategory.DIAGNOSTIC, entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
), ),

View file

@ -364,12 +364,11 @@ class BluesoundPlayer(MediaPlayerEntity):
if self.is_grouped and not self.is_master: if self.is_grouped and not self.is_master:
return MediaPlayerState.IDLE return MediaPlayerState.IDLE
match self._status.state: status = self._status.state
case "pause": if status in ("pause", "stop"):
return MediaPlayerState.PAUSED return MediaPlayerState.PAUSED
case "stream" | "play": if status in ("stream", "play"):
return MediaPlayerState.PLAYING return MediaPlayerState.PLAYING
case _:
return MediaPlayerState.IDLE return MediaPlayerState.IDLE
@property @property
@ -770,7 +769,7 @@ class BluesoundPlayer(MediaPlayerEntity):
async def async_set_volume_level(self, volume: float) -> None: async def async_set_volume_level(self, volume: float) -> None:
"""Send volume_up command to media player.""" """Send volume_up command to media player."""
volume = int(round(volume * 100)) volume = int(volume * 100)
volume = min(100, volume) volume = min(100, volume)
volume = max(0, volume) volume = max(0, volume)

View file

@ -7,11 +7,7 @@ from typing import Any
from bimmer_connected.api.authentication import MyBMWAuthentication from bimmer_connected.api.authentication import MyBMWAuthentication
from bimmer_connected.api.regions import get_region_from_name from bimmer_connected.api.regions import get_region_from_name
from bimmer_connected.models import ( from bimmer_connected.models import MyBMWAPIError, MyBMWAuthError
MyBMWAPIError,
MyBMWAuthError,
MyBMWCaptchaMissingError,
)
from httpx import RequestError from httpx import RequestError
import voluptuous as vol import voluptuous as vol
@ -21,7 +17,7 @@ from homeassistant.config_entries import (
ConfigEntry, ConfigEntry,
ConfigFlow, ConfigFlow,
ConfigFlowResult, ConfigFlowResult,
OptionsFlow, OptionsFlowWithConfigEntry,
) )
from homeassistant.const import CONF_PASSWORD, CONF_REGION, CONF_SOURCE, CONF_USERNAME from homeassistant.const import CONF_PASSWORD, CONF_REGION, CONF_SOURCE, CONF_USERNAME
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
@ -58,8 +54,6 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str,
try: try:
await auth.login() await auth.login()
except MyBMWCaptchaMissingError as ex:
raise MissingCaptcha from ex
except MyBMWAuthError as ex: except MyBMWAuthError as ex:
raise InvalidAuth from ex raise InvalidAuth from ex
except (MyBMWAPIError, RequestError) as ex: except (MyBMWAPIError, RequestError) as ex:
@ -104,8 +98,6 @@ class BMWConfigFlow(ConfigFlow, domain=DOMAIN):
CONF_REFRESH_TOKEN: info.get(CONF_REFRESH_TOKEN), CONF_REFRESH_TOKEN: info.get(CONF_REFRESH_TOKEN),
CONF_GCID: info.get(CONF_GCID), CONF_GCID: info.get(CONF_GCID),
} }
except MissingCaptcha:
errors["base"] = "missing_captcha"
except CannotConnect: except CannotConnect:
errors["base"] = "cannot_connect" errors["base"] = "cannot_connect"
except InvalidAuth: except InvalidAuth:
@ -153,10 +145,10 @@ class BMWConfigFlow(ConfigFlow, domain=DOMAIN):
config_entry: ConfigEntry, config_entry: ConfigEntry,
) -> BMWOptionsFlow: ) -> BMWOptionsFlow:
"""Return a MyBMW option flow.""" """Return a MyBMW option flow."""
return BMWOptionsFlow() return BMWOptionsFlow(config_entry)
class BMWOptionsFlow(OptionsFlow): class BMWOptionsFlow(OptionsFlowWithConfigEntry):
"""Handle a option flow for MyBMW.""" """Handle a option flow for MyBMW."""
async def async_step_init( async def async_step_init(
@ -200,7 +192,3 @@ class CannotConnect(HomeAssistantError):
class InvalidAuth(HomeAssistantError): class InvalidAuth(HomeAssistantError):
"""Error to indicate there is invalid auth.""" """Error to indicate there is invalid auth."""
class MissingCaptcha(HomeAssistantError):
"""Error to indicate the captcha token is missing."""

View file

@ -7,12 +7,7 @@ import logging
from bimmer_connected.account import MyBMWAccount from bimmer_connected.account import MyBMWAccount
from bimmer_connected.api.regions import get_region_from_name from bimmer_connected.api.regions import get_region_from_name
from bimmer_connected.models import ( from bimmer_connected.models import GPSPosition, MyBMWAPIError, MyBMWAuthError
GPSPosition,
MyBMWAPIError,
MyBMWAuthError,
MyBMWCaptchaMissingError,
)
from httpx import RequestError from httpx import RequestError
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
@ -66,12 +61,6 @@ class BMWDataUpdateCoordinator(DataUpdateCoordinator[None]):
try: try:
await self.account.get_vehicles() await self.account.get_vehicles()
except MyBMWCaptchaMissingError as err:
# If a captcha is required (user/password login flow), always trigger the reauth flow
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="missing_captcha",
) from err
except MyBMWAuthError as err: except MyBMWAuthError as err:
# Allow one retry interval before raising AuthFailed to avoid flaky API issues # Allow one retry interval before raising AuthFailed to avoid flaky API issues
if self.last_update_success: if self.last_update_success:

View file

@ -7,5 +7,5 @@
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"loggers": ["bimmer_connected"], "loggers": ["bimmer_connected"],
"quality_scale": "platinum", "quality_scale": "platinum",
"requirements": ["bimmer-connected[china]==0.16.4"] "requirements": ["bimmer-connected[china]==0.16.3"]
} }

View file

@ -11,8 +11,7 @@
}, },
"error": { "error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]"
"missing_captcha": "Captcha validation missing"
}, },
"abort": { "abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]", "already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
@ -201,9 +200,6 @@
"exceptions": { "exceptions": {
"invalid_poi": { "invalid_poi": {
"message": "Invalid data for point of interest: {poi_exception}" "message": "Invalid data for point of interest: {poi_exception}"
},
"missing_captcha": {
"message": "Login requires captcha validation"
} }
} }
} }

View file

@ -16,8 +16,7 @@
"list_access": { "list_access": {
"default": "mdi:account-lock", "default": "mdi:account-lock",
"state": { "state": {
"shared": "mdi:account-group", "shared": "mdi:account-group"
"invitation": "mdi:account-multiple-plus"
} }
} }
}, },

View file

@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/bring", "documentation": "https://www.home-assistant.io/integrations/bring",
"integration_type": "service", "integration_type": "service",
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"requirements": ["bring-api==0.9.1"] "requirements": ["bring-api==0.9.0"]
} }

View file

@ -79,7 +79,7 @@ SENSOR_DESCRIPTIONS: tuple[BringSensorEntityDescription, ...] = (
translation_key=BringSensor.LIST_ACCESS, translation_key=BringSensor.LIST_ACCESS,
value_fn=lambda lst, _: lst["status"].lower(), value_fn=lambda lst, _: lst["status"].lower(),
entity_category=EntityCategory.DIAGNOSTIC, entity_category=EntityCategory.DIAGNOSTIC,
options=["registered", "shared", "invitation"], options=["registered", "shared"],
device_class=SensorDeviceClass.ENUM, device_class=SensorDeviceClass.ENUM,
), ),
) )

View file

@ -66,8 +66,7 @@
"name": "List access", "name": "List access",
"state": { "state": {
"registered": "Private", "registered": "Private",
"shared": "Shared", "shared": "Shared"
"invitation": "Invitation pending"
} }
} }
} }

View file

@ -7,5 +7,5 @@
"integration_type": "device", "integration_type": "device",
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["bsblan"], "loggers": ["bsblan"],
"requirements": ["python-bsblan==1.2.1"] "requirements": ["python-bsblan==1.0.0"]
} }

View file

@ -109,7 +109,6 @@ async def async_setup_platform(
entity_id = async_generate_entity_id(ENTITY_ID_FORMAT, device_id, hass=hass) entity_id = async_generate_entity_id(ENTITY_ID_FORMAT, device_id, hass=hass)
coordinator = CalDavUpdateCoordinator( coordinator = CalDavUpdateCoordinator(
hass, hass,
None,
calendar=calendar, calendar=calendar,
days=days, days=days,
include_all_day=True, include_all_day=True,
@ -127,7 +126,6 @@ async def async_setup_platform(
entity_id = async_generate_entity_id(ENTITY_ID_FORMAT, device_id, hass=hass) entity_id = async_generate_entity_id(ENTITY_ID_FORMAT, device_id, hass=hass)
coordinator = CalDavUpdateCoordinator( coordinator = CalDavUpdateCoordinator(
hass, hass,
None,
calendar=calendar, calendar=calendar,
days=days, days=days,
include_all_day=False, include_all_day=False,
@ -154,7 +152,6 @@ async def async_setup_entry(
async_generate_entity_id(ENTITY_ID_FORMAT, calendar.name, hass=hass), async_generate_entity_id(ENTITY_ID_FORMAT, calendar.name, hass=hass),
CalDavUpdateCoordinator( CalDavUpdateCoordinator(
hass, hass,
entry,
calendar=calendar, calendar=calendar,
days=CONFIG_ENTRY_DEFAULT_DAYS, days=CONFIG_ENTRY_DEFAULT_DAYS,
include_all_day=True, include_all_day=True,
@ -207,8 +204,7 @@ class WebDavCalendarEntity(CoordinatorEntity[CalDavUpdateCoordinator], CalendarE
if self._supports_offset: if self._supports_offset:
self._attr_extra_state_attributes = { self._attr_extra_state_attributes = {
"offset_reached": is_offset_reached( "offset_reached": is_offset_reached(
self._event.start_datetime_local, self._event.start_datetime_local, self.coordinator.offset
self.coordinator.offset, # type: ignore[arg-type]
) )
if self._event if self._event
else False else False

View file

@ -6,9 +6,6 @@ from datetime import date, datetime, time, timedelta
from functools import partial from functools import partial
import logging import logging
import re import re
from typing import TYPE_CHECKING
import caldav
from homeassistant.components.calendar import CalendarEvent, extract_offset from homeassistant.components.calendar import CalendarEvent, extract_offset
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
@ -17,9 +14,6 @@ from homeassistant.util import dt as dt_util
from .api import get_attr_value from .api import get_attr_value
if TYPE_CHECKING:
from . import CalDavConfigEntry
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=15) MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=15)
@ -29,20 +23,11 @@ OFFSET = "!!"
class CalDavUpdateCoordinator(DataUpdateCoordinator[CalendarEvent | None]): class CalDavUpdateCoordinator(DataUpdateCoordinator[CalendarEvent | None]):
"""Class to utilize the calendar dav client object to get next event.""" """Class to utilize the calendar dav client object to get next event."""
def __init__( def __init__(self, hass, calendar, days, include_all_day, search):
self,
hass: HomeAssistant,
entry: CalDavConfigEntry | None,
calendar: caldav.Calendar,
days: int,
include_all_day: bool,
search: str | None,
) -> None:
"""Set up how we are going to search the WebDav calendar.""" """Set up how we are going to search the WebDav calendar."""
super().__init__( super().__init__(
hass, hass,
_LOGGER, _LOGGER,
config_entry=entry,
name=f"CalDAV {calendar.name}", name=f"CalDAV {calendar.name}",
update_interval=MIN_TIME_BETWEEN_UPDATES, update_interval=MIN_TIME_BETWEEN_UPDATES,
) )
@ -50,7 +35,7 @@ class CalDavUpdateCoordinator(DataUpdateCoordinator[CalendarEvent | None]):
self.days = days self.days = days
self.include_all_day = include_all_day self.include_all_day = include_all_day
self.search = search self.search = search
self.offset: timedelta | None = None self.offset = None
async def async_get_events( async def async_get_events(
self, hass: HomeAssistant, start_date: datetime, end_date: datetime self, hass: HomeAssistant, start_date: datetime, end_date: datetime
@ -124,7 +109,7 @@ class CalDavUpdateCoordinator(DataUpdateCoordinator[CalendarEvent | None]):
_start_of_tomorrow = start_of_tomorrow _start_of_tomorrow = start_of_tomorrow
if _start_of_today <= start_dt < _start_of_tomorrow: if _start_of_today <= start_dt < _start_of_tomorrow:
new_event = event.copy() new_event = event.copy()
new_vevent = new_event.instance.vevent # type: ignore[attr-defined] new_vevent = new_event.instance.vevent
if hasattr(new_vevent, "dtend"): if hasattr(new_vevent, "dtend"):
dur = new_vevent.dtend.value - new_vevent.dtstart.value dur = new_vevent.dtend.value - new_vevent.dtstart.value
new_vevent.dtend.value = start_dt + dur new_vevent.dtend.value = start_dt + dur

View file

@ -7,6 +7,6 @@
"integration_type": "device", "integration_type": "device",
"iot_class": "local_push", "iot_class": "local_push",
"loggers": ["aiostreammagic"], "loggers": ["aiostreammagic"],
"requirements": ["aiostreammagic==2.8.5"], "requirements": ["aiostreammagic==2.8.4"],
"zeroconf": ["_stream-magic._tcp.local.", "_smoip._tcp.local."] "zeroconf": ["_stream-magic._tcp.local.", "_smoip._tcp.local."]
} }

View file

@ -51,13 +51,8 @@ CONTROL_ENTITIES: tuple[CambridgeAudioSelectEntityDescription, ...] = (
CambridgeAudioSelectEntityDescription( CambridgeAudioSelectEntityDescription(
key="display_brightness", key="display_brightness",
translation_key="display_brightness", translation_key="display_brightness",
options=[ options=[x.value for x in DisplayBrightness],
DisplayBrightness.BRIGHT.value,
DisplayBrightness.DIM.value,
DisplayBrightness.OFF.value,
],
entity_category=EntityCategory.CONFIG, entity_category=EntityCategory.CONFIG,
load_fn=lambda client: client.display.brightness != DisplayBrightness.NONE,
value_fn=lambda client: client.display.brightness, value_fn=lambda client: client.display.brightness,
set_value_fn=lambda client, value: client.set_display_brightness( set_value_fn=lambda client, value: client.set_display_brightness(
DisplayBrightness(value) DisplayBrightness(value)

View file

@ -20,7 +20,7 @@ from aiohttp import hdrs, web
import attr import attr
from propcache import cached_property, under_cached_property from propcache import cached_property, under_cached_property
import voluptuous as vol import voluptuous as vol
from webrtc_models import RTCIceCandidate, RTCIceServer from webrtc_models import RTCIceServer
from homeassistant.components import websocket_api from homeassistant.components import websocket_api
from homeassistant.components.http import KEY_AUTHENTICATED, HomeAssistantView from homeassistant.components.http import KEY_AUTHENTICATED, HomeAssistantView
@ -421,12 +421,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
if hass.config.webrtc.ice_servers: if hass.config.webrtc.ice_servers:
return hass.config.webrtc.ice_servers return hass.config.webrtc.ice_servers
return [ return [
RTCIceServer( RTCIceServer(urls="stun:stun.home-assistant.io:80"),
urls=[ RTCIceServer(urls="stun:stun.home-assistant.io:3478"),
"stun:stun.home-assistant.io:80",
"stun:stun.home-assistant.io:3478",
]
),
] ]
async_register_ice_servers(hass, get_ice_servers) async_register_ice_servers(hass, get_ice_servers)
@ -476,8 +472,6 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
_attr_state: None = None # State is determined by is_on _attr_state: None = None # State is determined by is_on
_attr_supported_features: CameraEntityFeature = CameraEntityFeature(0) _attr_supported_features: CameraEntityFeature = CameraEntityFeature(0)
__supports_stream: CameraEntityFeature | None = None
def __init__(self) -> None: def __init__(self) -> None:
"""Initialize a camera.""" """Initialize a camera."""
self._cache: dict[str, Any] = {} self._cache: dict[str, Any] = {}
@ -490,13 +484,9 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
self._create_stream_lock: asyncio.Lock | None = None self._create_stream_lock: asyncio.Lock | None = None
self._webrtc_provider: CameraWebRTCProvider | None = None self._webrtc_provider: CameraWebRTCProvider | None = None
self._legacy_webrtc_provider: CameraWebRTCLegacyProvider | None = None self._legacy_webrtc_provider: CameraWebRTCLegacyProvider | None = None
self._supports_native_sync_webrtc = ( self._webrtc_sync_offer = (
type(self).async_handle_web_rtc_offer != Camera.async_handle_web_rtc_offer type(self).async_handle_web_rtc_offer != Camera.async_handle_web_rtc_offer
) )
self._supports_native_async_webrtc = (
type(self).async_handle_async_webrtc_offer
!= Camera.async_handle_async_webrtc_offer
)
@cached_property @cached_property
def entity_picture(self) -> str: def entity_picture(self) -> str:
@ -633,7 +623,7 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
Integrations can override with a native WebRTC implementation. Integrations can override with a native WebRTC implementation.
""" """
if self._supports_native_sync_webrtc: if self._webrtc_sync_offer:
try: try:
answer = await self.async_handle_web_rtc_offer(offer_sdp) answer = await self.async_handle_web_rtc_offer(offer_sdp)
except ValueError as ex: except ValueError as ex:
@ -789,9 +779,6 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
async def async_internal_added_to_hass(self) -> None: async def async_internal_added_to_hass(self) -> None:
"""Run when entity about to be added to hass.""" """Run when entity about to be added to hass."""
await super().async_internal_added_to_hass() await super().async_internal_added_to_hass()
self.__supports_stream = (
self.supported_features_compat & CameraEntityFeature.STREAM
)
await self.async_refresh_providers(write_state=False) await self.async_refresh_providers(write_state=False)
async def async_refresh_providers(self, *, write_state: bool = True) -> None: async def async_refresh_providers(self, *, write_state: bool = True) -> None:
@ -801,19 +788,12 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
providers or inputs to the state attributes change. providers or inputs to the state attributes change.
""" """
old_provider = self._webrtc_provider old_provider = self._webrtc_provider
old_legacy_provider = self._legacy_webrtc_provider
new_provider = None
new_legacy_provider = None
# Skip all providers if the camera has a native WebRTC implementation
if not (
self._supports_native_sync_webrtc or self._supports_native_async_webrtc
):
# Camera doesn't have a native WebRTC implementation
new_provider = await self._async_get_supported_webrtc_provider( new_provider = await self._async_get_supported_webrtc_provider(
async_get_supported_provider async_get_supported_provider
) )
old_legacy_provider = self._legacy_webrtc_provider
new_legacy_provider = None
if new_provider is None: if new_provider is None:
# Only add the legacy provider if the new provider is not available # Only add the legacy provider if the new provider is not available
new_legacy_provider = await self._async_get_supported_webrtc_provider( new_legacy_provider = await self._async_get_supported_webrtc_provider(
@ -847,9 +827,6 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
"""Return the WebRTC client configuration and extend it with the registered ice servers.""" """Return the WebRTC client configuration and extend it with the registered ice servers."""
config = self._async_get_webrtc_client_configuration() config = self._async_get_webrtc_client_configuration()
if not self._supports_native_sync_webrtc:
# Until 2024.11, the frontend was not resolving any ice servers
# The async approach was added 2024.11 and new integrations need to use it
ice_servers = [ ice_servers = [
server server
for servers in self.hass.data.get(DATA_ICE_SERVERS, []) for servers in self.hass.data.get(DATA_ICE_SERVERS, [])
@ -858,15 +835,12 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
config.configuration.ice_servers.extend(ice_servers) config.configuration.ice_servers.extend(ice_servers)
config.get_candidates_upfront = ( config.get_candidates_upfront = (
self._supports_native_sync_webrtc self._webrtc_sync_offer or self._legacy_webrtc_provider is not None
or self._legacy_webrtc_provider is not None
) )
return config return config
async def async_on_webrtc_candidate( async def async_on_webrtc_candidate(self, session_id: str, candidate: str) -> None:
self, session_id: str, candidate: RTCIceCandidate
) -> None:
"""Handle a WebRTC candidate.""" """Handle a WebRTC candidate."""
if self._webrtc_provider: if self._webrtc_provider:
await self._webrtc_provider.async_on_webrtc_candidate(session_id, candidate) await self._webrtc_provider.async_on_webrtc_candidate(session_id, candidate)
@ -890,7 +864,12 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
"""Return the camera capabilities.""" """Return the camera capabilities."""
frontend_stream_types = set() frontend_stream_types = set()
if CameraEntityFeature.STREAM in self.supported_features_compat: if CameraEntityFeature.STREAM in self.supported_features_compat:
if self._supports_native_sync_webrtc or self._supports_native_async_webrtc: if (
type(self).async_handle_web_rtc_offer
!= Camera.async_handle_web_rtc_offer
or type(self).async_handle_async_webrtc_offer
!= Camera.async_handle_async_webrtc_offer
):
# The camera has a native WebRTC implementation # The camera has a native WebRTC implementation
frontend_stream_types.add(StreamType.WEB_RTC) frontend_stream_types.add(StreamType.WEB_RTC)
else: else:
@ -901,21 +880,6 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
return CameraCapabilities(frontend_stream_types) return CameraCapabilities(frontend_stream_types)
@callback
def async_write_ha_state(self) -> None:
"""Write the state to the state machine.
Schedules async_refresh_providers if support of streams have changed.
"""
super().async_write_ha_state()
if self.__supports_stream != (
supports_stream := self.supported_features_compat
& CameraEntityFeature.STREAM
):
self.__supports_stream = supports_stream
self._invalidate_camera_capabilities_cache()
self.hass.async_create_task(self.async_refresh_providers())
class CameraView(HomeAssistantView): class CameraView(HomeAssistantView):
"""Base CameraView.""" """Base CameraView."""

View file

@ -6,12 +6,12 @@ from abc import ABC, abstractmethod
import asyncio import asyncio
from collections.abc import Awaitable, Callable, Iterable from collections.abc import Awaitable, Callable, Iterable
from dataclasses import asdict, dataclass, field from dataclasses import asdict, dataclass, field
from functools import cache, partial, wraps from functools import cache, partial
import logging import logging
from typing import TYPE_CHECKING, Any, Protocol from typing import TYPE_CHECKING, Any, Protocol
import voluptuous as vol import voluptuous as vol
from webrtc_models import RTCConfiguration, RTCIceCandidate, RTCIceServer from webrtc_models import RTCConfiguration, RTCIceServer
from homeassistant.components import websocket_api from homeassistant.components import websocket_api
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
@ -78,14 +78,7 @@ class WebRTCAnswer(WebRTCMessage):
class WebRTCCandidate(WebRTCMessage): class WebRTCCandidate(WebRTCMessage):
"""WebRTC candidate.""" """WebRTC candidate."""
candidate: RTCIceCandidate candidate: str
def as_dict(self) -> dict[str, Any]:
"""Return a dict representation of the message."""
return {
"type": self._get_type(),
"candidate": self.candidate.candidate,
}
@dataclass(frozen=True) @dataclass(frozen=True)
@ -145,9 +138,7 @@ class CameraWebRTCProvider(ABC):
"""Handle the WebRTC offer and return the answer via the provided callback.""" """Handle the WebRTC offer and return the answer via the provided callback."""
@abstractmethod @abstractmethod
async def async_on_webrtc_candidate( async def async_on_webrtc_candidate(self, session_id: str, candidate: str) -> None:
self, session_id: str, candidate: RTCIceCandidate
) -> None:
"""Handle the WebRTC candidate.""" """Handle the WebRTC candidate."""
@callback @callback
@ -205,49 +196,6 @@ 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 camera.frontend_stream_type != StreamType.WEB_RTC:
connection.send_error(
msg["id"],
error_code,
(
"Camera does not support WebRTC,"
f" frontend_stream_type={camera.frontend_stream_type}"
),
)
return
await func(connection, msg, camera)
return validate
return decorate
@websocket_api.websocket_command( @websocket_api.websocket_command(
{ {
vol.Required("type"): "camera/webrtc/offer", vol.Required("type"): "camera/webrtc/offer",
@ -256,9 +204,8 @@ def require_webrtc_support(
} }
) )
@websocket_api.async_response @websocket_api.async_response
@require_webrtc_support("webrtc_offer_failed")
async def ws_webrtc_offer( async def ws_webrtc_offer(
connection: websocket_api.ActiveConnection, msg: dict[str, Any], camera: Camera hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
) -> None: ) -> None:
"""Handle the signal path for a WebRTC stream. """Handle the signal path for a WebRTC stream.
@ -270,7 +217,20 @@ async def ws_webrtc_offer(
Async friendly. Async friendly.
""" """
entity_id = msg["entity_id"]
offer = msg["offer"] 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() session_id = ulid()
connection.subscriptions[msg["id"]] = partial( connection.subscriptions[msg["id"]] = partial(
camera.close_webrtc_session, session_id camera.close_webrtc_session, session_id
@ -309,11 +269,23 @@ async def ws_webrtc_offer(
} }
) )
@websocket_api.async_response @websocket_api.async_response
@require_webrtc_support("webrtc_get_client_config_failed")
async def ws_get_client_config( async def ws_get_client_config(
connection: websocket_api.ActiveConnection, msg: dict[str, Any], camera: Camera hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
) -> None: ) -> None:
"""Handle get WebRTC client config websocket command.""" """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() config = camera.async_get_webrtc_client_configuration().to_frontend_dict()
connection.send_result( connection.send_result(
msg["id"], msg["id"],
@ -330,14 +302,24 @@ async def ws_get_client_config(
} }
) )
@websocket_api.async_response @websocket_api.async_response
@require_webrtc_support("webrtc_candidate_failed")
async def ws_candidate( async def ws_candidate(
connection: websocket_api.ActiveConnection, msg: dict[str, Any], camera: Camera hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
) -> None: ) -> None:
"""Handle WebRTC candidate websocket command.""" """Handle WebRTC candidate websocket command."""
await camera.async_on_webrtc_candidate( entity_id = msg["entity_id"]
msg["session_id"], RTCIceCandidate(msg["candidate"]) 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"], msg["candidate"])
connection.send_message(websocket_api.result_message(msg["id"])) connection.send_message(websocket_api.result_message(msg["id"]))

View file

@ -41,7 +41,7 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
config_entry: ConfigEntry, config_entry: ConfigEntry,
) -> CastOptionsFlowHandler: ) -> CastOptionsFlowHandler:
"""Get the options flow for this handler.""" """Get the options flow for this handler."""
return CastOptionsFlowHandler() return CastOptionsFlowHandler(config_entry)
async def async_step_user( async def async_step_user(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
@ -109,8 +109,9 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
class CastOptionsFlowHandler(OptionsFlow): class CastOptionsFlowHandler(OptionsFlow):
"""Handle Google Cast options.""" """Handle Google Cast options."""
def __init__(self) -> None: def __init__(self, config_entry: ConfigEntry) -> None:
"""Initialize Google Cast options flow.""" """Initialize Google Cast options flow."""
self.config_entry = config_entry
self.updated_config: dict[str, Any] = {} self.updated_config: dict[str, Any] = {}
async def async_step_init(self, user_input: None = None) -> ConfigFlowResult: async def async_step_init(self, user_input: None = None) -> ConfigFlowResult:

View file

@ -440,16 +440,16 @@ def validate_language_voice(value: tuple[str, str]) -> tuple[str, str]:
@websocket_api.websocket_command( @websocket_api.websocket_command(
{ {
vol.Required("type"): "cloud/update_prefs", vol.Required("type"): "cloud/update_prefs",
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_ENABLE_GOOGLE): bool,
vol.Optional(PREF_ENABLE_ALEXA): bool,
vol.Optional(PREF_ALEXA_REPORT_STATE): bool,
vol.Optional(PREF_GOOGLE_REPORT_STATE): bool, vol.Optional(PREF_GOOGLE_REPORT_STATE): bool,
vol.Optional(PREF_GOOGLE_SECURE_DEVICES_PIN): vol.Any(None, str), 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.Optional(PREF_TTS_DEFAULT_VOICE): vol.All(
vol.Coerce(tuple), validate_language_voice 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 @websocket_api.async_response

View file

@ -8,6 +8,6 @@
"integration_type": "system", "integration_type": "system",
"iot_class": "cloud_push", "iot_class": "cloud_push",
"loggers": ["hass_nabucasa"], "loggers": ["hass_nabucasa"],
"requirements": ["hass-nabucasa==0.84.0"], "requirements": ["hass-nabucasa==0.83.0"],
"single_config_entry": true "single_config_entry": true
} }

View file

@ -163,21 +163,21 @@ class CloudPreferences:
async def async_update( async def async_update(
self, self,
*, *,
alexa_enabled: bool | UndefinedType = UNDEFINED,
alexa_report_state: bool | UndefinedType = UNDEFINED,
alexa_settings_version: int | 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_enabled: bool | UndefinedType = UNDEFINED,
google_report_state: bool | UndefinedType = UNDEFINED, alexa_enabled: 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, 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, 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,
) -> None: ) -> None:
"""Update user preferences.""" """Update user preferences."""
prefs = {**self._prefs} prefs = {**self._prefs}
@ -186,21 +186,21 @@ class CloudPreferences:
{ {
key: value key: value
for key, value in ( for key, value in (
(PREF_ALEXA_REPORT_STATE, alexa_report_state),
(PREF_ALEXA_SETTINGS_VERSION, alexa_settings_version),
(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_GOOGLE, google_enabled),
(PREF_ENABLE_ALEXA, alexa_enabled),
(PREF_ENABLE_REMOTE, remote_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_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_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), (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_ENABLE_CLOUD_ICE_SERVERS, cloud_ice_servers_enabled),
) )
if value is not UNDEFINED if value is not UNDEFINED
} }
@ -242,7 +242,6 @@ class CloudPreferences:
PREF_ALEXA_REPORT_STATE: self.alexa_report_state, PREF_ALEXA_REPORT_STATE: self.alexa_report_state,
PREF_CLOUDHOOKS: self.cloudhooks, PREF_CLOUDHOOKS: self.cloudhooks,
PREF_ENABLE_ALEXA: self.alexa_enabled, PREF_ENABLE_ALEXA: self.alexa_enabled,
PREF_ENABLE_CLOUD_ICE_SERVERS: self.cloud_ice_servers_enabled,
PREF_ENABLE_GOOGLE: self.google_enabled, PREF_ENABLE_GOOGLE: self.google_enabled,
PREF_ENABLE_REMOTE: self.remote_enabled, PREF_ENABLE_REMOTE: self.remote_enabled,
PREF_GOOGLE_DEFAULT_EXPOSE: self.google_default_expose, PREF_GOOGLE_DEFAULT_EXPOSE: self.google_default_expose,
@ -250,6 +249,7 @@ class CloudPreferences:
PREF_GOOGLE_SECURE_DEVICES_PIN: self.google_secure_devices_pin, PREF_GOOGLE_SECURE_DEVICES_PIN: self.google_secure_devices_pin,
PREF_REMOTE_ALLOW_REMOTE_ENABLE: self.remote_allow_remote_enable, PREF_REMOTE_ALLOW_REMOTE_ENABLE: self.remote_allow_remote_enable,
PREF_TTS_DEFAULT_VOICE: self.tts_default_voice, PREF_TTS_DEFAULT_VOICE: self.tts_default_voice,
PREF_ENABLE_CLOUD_ICE_SERVERS: self.cloud_ice_servers_enabled,
} }
@property @property

View file

@ -168,7 +168,7 @@ class ElectricityMapsConfigFlow(ConfigFlow, domain=DOMAIN):
) )
return self.async_create_entry( return self.async_create_entry(
title=get_extra_name(data) or "Electricity Maps", title=get_extra_name(data) or "CO2 Signal",
data=data, data=data,
) )

View file

@ -4,5 +4,5 @@
"codeowners": ["@Petro31"], "codeowners": ["@Petro31"],
"documentation": "https://www.home-assistant.io/integrations/compensation", "documentation": "https://www.home-assistant.io/integrations/compensation",
"iot_class": "calculated", "iot_class": "calculated",
"requirements": ["numpy==2.1.3"] "requirements": ["numpy==1.26.4"]
} }

View file

@ -16,11 +16,11 @@ from hassil.expression import Expression, ListReference, Sequence
from hassil.intents import Intents, SlotList, TextSlotList, WildcardSlotList from hassil.intents import Intents, SlotList, TextSlotList, WildcardSlotList
from hassil.recognize import ( from hassil.recognize import (
MISSING_ENTITY, MISSING_ENTITY,
MatchEntity,
RecognizeResult, RecognizeResult,
UnmatchedTextEntity,
recognize_all, recognize_all,
recognize_best,
) )
from hassil.string_matcher import UnmatchedRangeEntity, UnmatchedTextEntity
from hassil.util import merge_dict from hassil.util import merge_dict
from home_assistant_intents import ErrorKey, get_intents, get_languages from home_assistant_intents import ErrorKey, get_intents, get_languages
import yaml import yaml
@ -294,7 +294,7 @@ class DefaultAgent(ConversationEntity):
self.hass, language, DOMAIN, [DOMAIN] self.hass, language, DOMAIN, [DOMAIN]
) )
response_text = translations.get( response_text = translations.get(
f"component.{DOMAIN}.conversation.agent.done", "Done" f"component.{DOMAIN}.agent.done", "Done"
) )
response.async_set_speech(response_text) response.async_set_speech(response_text)
@ -499,7 +499,6 @@ class DefaultAgent(ConversationEntity):
maybe_result: RecognizeResult | None = None maybe_result: RecognizeResult | None = None
best_num_matched_entities = 0 best_num_matched_entities = 0
best_num_unmatched_entities = 0 best_num_unmatched_entities = 0
best_num_unmatched_ranges = 0
for result in recognize_all( for result in recognize_all(
user_input.text, user_input.text,
lang_intents.intents, lang_intents.intents,
@ -518,14 +517,10 @@ class DefaultAgent(ConversationEntity):
num_matched_entities += 1 num_matched_entities += 1
num_unmatched_entities = 0 num_unmatched_entities = 0
num_unmatched_ranges = 0
for unmatched_entity in result.unmatched_entities_list: for unmatched_entity in result.unmatched_entities_list:
if isinstance(unmatched_entity, UnmatchedTextEntity): if isinstance(unmatched_entity, UnmatchedTextEntity):
if unmatched_entity.text != MISSING_ENTITY: if unmatched_entity.text != MISSING_ENTITY:
num_unmatched_entities += 1 num_unmatched_entities += 1
elif isinstance(unmatched_entity, UnmatchedRangeEntity):
num_unmatched_ranges += 1
num_unmatched_entities += 1
else: else:
num_unmatched_entities += 1 num_unmatched_entities += 1
@ -537,24 +532,15 @@ class DefaultAgent(ConversationEntity):
(num_matched_entities == best_num_matched_entities) (num_matched_entities == best_num_matched_entities)
and (num_unmatched_entities < best_num_unmatched_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 ( or (
# More literal text matched # More literal text matched
(num_matched_entities == best_num_matched_entities) (num_matched_entities == best_num_matched_entities)
and (num_unmatched_entities == best_num_unmatched_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) and (result.text_chunks_matched > maybe_result.text_chunks_matched)
) )
or ( or (
# Prefer match failures with entities # Prefer match failures with entities
(result.text_chunks_matched == maybe_result.text_chunks_matched) (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 ( and (
("name" in result.entities) ("name" in result.entities)
or ("name" in result.unmatched_entities) or ("name" in result.unmatched_entities)
@ -564,7 +550,6 @@ class DefaultAgent(ConversationEntity):
maybe_result = result maybe_result = result
best_num_matched_entities = num_matched_entities best_num_matched_entities = num_matched_entities
best_num_unmatched_entities = num_unmatched_entities best_num_unmatched_entities = num_unmatched_entities
best_num_unmatched_ranges = num_unmatched_ranges
return maybe_result return maybe_result
@ -577,16 +562,77 @@ class DefaultAgent(ConversationEntity):
language: str, language: str,
) -> RecognizeResult | None: ) -> RecognizeResult | None:
"""Search intents for a strict match to user input.""" """Search intents for a strict match to user input."""
return recognize_best( 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(
user_input.text, user_input.text,
lang_intents.intents, lang_intents.intents,
slot_lists=slot_lists, slot_lists=slot_lists,
intent_context=intent_context, intent_context=intent_context,
language=language, language=language,
best_metadata_key=METADATA_CUSTOM_SENTENCE, ):
best_slot_name="name", # 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
async def _build_speech( async def _build_speech(
self, self,
language: str, language: str,

View file

@ -6,8 +6,12 @@ from collections.abc import Iterable
from typing import Any from typing import Any
from aiohttp import web from aiohttp import web
from hassil.recognize import MISSING_ENTITY, RecognizeResult from hassil.recognize import (
from hassil.string_matcher import UnmatchedRangeEntity, UnmatchedTextEntity MISSING_ENTITY,
RecognizeResult,
UnmatchedRangeEntity,
UnmatchedTextEntity,
)
import voluptuous as vol import voluptuous as vol
from homeassistant.components import http, websocket_api from homeassistant.components import http, websocket_api

View file

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

View file

@ -4,8 +4,7 @@ from __future__ import annotations
from typing import Any from typing import Any
from hassil.recognize import RecognizeResult from hassil.recognize import PUNCTUATION, RecognizeResult
from hassil.util import PUNCTUATION_ALL
import voluptuous as vol import voluptuous as vol
from homeassistant.const import CONF_COMMAND, CONF_PLATFORM from homeassistant.const import CONF_COMMAND, CONF_PLATFORM
@ -21,7 +20,7 @@ from .const import DATA_DEFAULT_ENTITY, DOMAIN
def has_no_punctuation(value: list[str]) -> list[str]: def has_no_punctuation(value: list[str]) -> list[str]:
"""Validate result does not contain punctuation.""" """Validate result does not contain punctuation."""
for sentence in value: for sentence in value:
if PUNCTUATION_ALL.search(sentence): if PUNCTUATION.search(sentence):
raise vol.Invalid("sentence should not contain punctuation") raise vol.Invalid("sentence should not contain punctuation")
return value return value

View file

@ -213,19 +213,18 @@ class CrownstoneOptionsFlowHandler(BaseCrownstoneFlowHandler, OptionsFlow):
def __init__(self, config_entry: ConfigEntry) -> None: def __init__(self, config_entry: ConfigEntry) -> None:
"""Initialize Crownstone options.""" """Initialize Crownstone options."""
super().__init__(OPTIONS_FLOW, self.async_create_new_entry) super().__init__(OPTIONS_FLOW, self.async_create_new_entry)
self.options = config_entry.options.copy() self.entry = config_entry
self.updated_options = config_entry.options.copy()
async def async_step_init( async def async_step_init(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult: ) -> ConfigFlowResult:
"""Manage Crownstone options.""" """Manage Crownstone options."""
self.cloud: CrownstoneCloud = self.hass.data[DOMAIN][ self.cloud: CrownstoneCloud = self.hass.data[DOMAIN][self.entry.entry_id].cloud
self.config_entry.entry_id
].cloud
spheres = {sphere.name: sphere.cloud_id for sphere in self.cloud.cloud_data} spheres = {sphere.name: sphere.cloud_id for sphere in self.cloud.cloud_data}
usb_path = self.config_entry.options.get(CONF_USB_PATH) usb_path = self.entry.options.get(CONF_USB_PATH)
usb_sphere = self.config_entry.options.get(CONF_USB_SPHERE) usb_sphere = self.entry.options.get(CONF_USB_SPHERE)
options_schema = vol.Schema( options_schema = vol.Schema(
{vol.Optional(CONF_USE_USB_OPTION, default=usb_path is not None): bool} {vol.Optional(CONF_USE_USB_OPTION, default=usb_path is not None): bool}
@ -244,14 +243,14 @@ class CrownstoneOptionsFlowHandler(BaseCrownstoneFlowHandler, OptionsFlow):
if user_input[CONF_USE_USB_OPTION] and usb_path is None: if user_input[CONF_USE_USB_OPTION] and usb_path is None:
return await self.async_step_usb_config() return await self.async_step_usb_config()
if not user_input[CONF_USE_USB_OPTION] and usb_path is not None: if not user_input[CONF_USE_USB_OPTION] and usb_path is not None:
self.options[CONF_USB_PATH] = None self.updated_options[CONF_USB_PATH] = None
self.options[CONF_USB_SPHERE] = None self.updated_options[CONF_USB_SPHERE] = None
elif ( elif (
CONF_USB_SPHERE_OPTION in user_input CONF_USB_SPHERE_OPTION in user_input
and spheres[user_input[CONF_USB_SPHERE_OPTION]] != usb_sphere and spheres[user_input[CONF_USB_SPHERE_OPTION]] != usb_sphere
): ):
sphere_id = spheres[user_input[CONF_USB_SPHERE_OPTION]] sphere_id = spheres[user_input[CONF_USB_SPHERE_OPTION]]
self.options[CONF_USB_SPHERE] = sphere_id self.updated_options[CONF_USB_SPHERE] = sphere_id
return self.async_create_new_entry() return self.async_create_new_entry()
@ -261,7 +260,7 @@ class CrownstoneOptionsFlowHandler(BaseCrownstoneFlowHandler, OptionsFlow):
"""Create a new entry.""" """Create a new entry."""
# these attributes will only change when a usb was configured # these attributes will only change when a usb was configured
if self.usb_path is not None and self.usb_sphere_id is not None: if self.usb_path is not None and self.usb_sphere_id is not None:
self.options[CONF_USB_PATH] = self.usb_path self.updated_options[CONF_USB_PATH] = self.usb_path
self.options[CONF_USB_SPHERE] = self.usb_sphere_id self.updated_options[CONF_USB_SPHERE] = self.usb_sphere_id
return super().async_create_entry(title="", data=self.options) return super().async_create_entry(title="", data=self.updated_options)

View file

@ -74,11 +74,9 @@ class DeconzFlowHandler(ConfigFlow, domain=DOMAIN):
@staticmethod @staticmethod
@callback @callback
def async_get_options_flow( def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow:
config_entry: ConfigEntry,
) -> DeconzOptionsFlowHandler:
"""Get the options flow for this handler.""" """Get the options flow for this handler."""
return DeconzOptionsFlowHandler() return DeconzOptionsFlowHandler(config_entry)
def __init__(self) -> None: def __init__(self) -> None:
"""Initialize the deCONZ config flow.""" """Initialize the deCONZ config flow."""
@ -301,6 +299,11 @@ class DeconzOptionsFlowHandler(OptionsFlow):
gateway: DeconzHub gateway: DeconzHub
def __init__(self, config_entry: ConfigEntry) -> None:
"""Initialize deCONZ options flow."""
self.config_entry = config_entry
self.options = dict(config_entry.options)
async def async_step_init( async def async_step_init(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult: ) -> ConfigFlowResult:
@ -312,7 +315,8 @@ class DeconzOptionsFlowHandler(OptionsFlow):
) -> ConfigFlowResult: ) -> ConfigFlowResult:
"""Manage the deconz devices options.""" """Manage the deconz devices options."""
if user_input is not None: if user_input is not None:
return self.async_create_entry(data=self.config_entry.options | user_input) self.options.update(user_input)
return self.async_create_entry(title="", data=self.options)
schema_options = {} schema_options = {}
for option, default in ( for option, default in (

View file

@ -47,6 +47,7 @@ class OptionsFlowHandler(OptionsFlow):
def __init__(self, config_entry: ConfigEntry) -> None: def __init__(self, config_entry: ConfigEntry) -> None:
"""Initialize options flow.""" """Initialize options flow."""
self.config_entry = config_entry
self.options = dict(config_entry.options) self.options = dict(config_entry.options)
async def async_step_init( async def async_step_init(

View file

@ -14,7 +14,7 @@ from homeassistant.config_entries import (
ConfigEntry, ConfigEntry,
ConfigFlow, ConfigFlow,
ConfigFlowResult, ConfigFlowResult,
OptionsFlow, OptionsFlowWithConfigEntry,
) )
from homeassistant.const import CONF_NAME, CONF_PORT from homeassistant.const import CONF_NAME, CONF_PORT
from homeassistant.core import callback from homeassistant.core import callback
@ -101,7 +101,7 @@ class DnsIPConfigFlow(ConfigFlow, domain=DOMAIN):
config_entry: ConfigEntry, config_entry: ConfigEntry,
) -> DnsIPOptionsFlowHandler: ) -> DnsIPOptionsFlowHandler:
"""Return Option handler.""" """Return Option handler."""
return DnsIPOptionsFlowHandler() return DnsIPOptionsFlowHandler(config_entry)
async def async_step_user( async def async_step_user(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
@ -165,7 +165,7 @@ class DnsIPConfigFlow(ConfigFlow, domain=DOMAIN):
) )
class DnsIPOptionsFlowHandler(OptionsFlow): class DnsIPOptionsFlowHandler(OptionsFlowWithConfigEntry):
"""Handle a option config flow for dnsip integration.""" """Handle a option config flow for dnsip integration."""
async def async_step_init( async def async_step_init(

View file

@ -5,5 +5,5 @@
"documentation": "https://www.home-assistant.io/integrations/doods", "documentation": "https://www.home-assistant.io/integrations/doods",
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["pydoods"], "loggers": ["pydoods"],
"requirements": ["pydoods==1.0.2", "Pillow==11.0.0"] "requirements": ["pydoods==1.0.2", "Pillow==10.4.0"]
} }

View file

@ -7,7 +7,7 @@
"documentation": "https://www.home-assistant.io/integrations/doorbird", "documentation": "https://www.home-assistant.io/integrations/doorbird",
"iot_class": "local_push", "iot_class": "local_push",
"loggers": ["doorbirdpy"], "loggers": ["doorbirdpy"],
"requirements": ["DoorBirdPy==3.0.8"], "requirements": ["DoorBirdPy==3.0.7"],
"zeroconf": [ "zeroconf": [
{ {
"type": "_axis-video._tcp.local.", "type": "_axis-video._tcp.local.",

View file

@ -171,11 +171,9 @@ class DSMRFlowHandler(ConfigFlow, domain=DOMAIN):
@staticmethod @staticmethod
@callback @callback
def async_get_options_flow( def async_get_options_flow(config_entry: ConfigEntry) -> DSMROptionFlowHandler:
config_entry: ConfigEntry,
) -> DSMROptionFlowHandler:
"""Get the options flow for this handler.""" """Get the options flow for this handler."""
return DSMROptionFlowHandler() return DSMROptionFlowHandler(config_entry)
async def async_step_user( async def async_step_user(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
@ -313,6 +311,10 @@ class DSMRFlowHandler(ConfigFlow, domain=DOMAIN):
class DSMROptionFlowHandler(OptionsFlow): class DSMROptionFlowHandler(OptionsFlow):
"""Handle options.""" """Handle options."""
def __init__(self, entry: ConfigEntry) -> None:
"""Initialize options flow."""
self.entry = entry
async def async_step_init( async def async_step_init(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult: ) -> ConfigFlowResult:
@ -326,7 +328,7 @@ class DSMROptionFlowHandler(OptionsFlow):
{ {
vol.Optional( vol.Optional(
CONF_TIME_BETWEEN_UPDATE, CONF_TIME_BETWEEN_UPDATE,
default=self.config_entry.options.get( default=self.entry.options.get(
CONF_TIME_BETWEEN_UPDATE, DEFAULT_TIME_BETWEEN_UPDATE CONF_TIME_BETWEEN_UPDATE, DEFAULT_TIME_BETWEEN_UPDATE
), ),
): vol.All(vol.Coerce(int), vol.Range(min=0)), ): vol.All(vol.Coerce(int), vol.Range(min=0)),

View file

@ -6,14 +6,9 @@ from collections.abc import Awaitable, Callable
from dataclasses import dataclass from dataclasses import dataclass
import logging import logging
from homeassistant.components.number import ( from homeassistant.components.number import NumberEntity, NumberEntityDescription
NumberDeviceClass,
NumberEntity,
NumberEntityDescription,
NumberMode,
)
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import UnitOfTemperature, UnitOfTime from homeassistant.const import UnitOfTime
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
@ -59,26 +54,17 @@ async def async_setup_entry(
) -> None: ) -> None:
"""Set up the ecobee thermostat number entity.""" """Set up the ecobee thermostat number entity."""
data: EcobeeData = hass.data[DOMAIN] data: EcobeeData = hass.data[DOMAIN]
_LOGGER.debug("Adding min time ventilators numbers (if present)")
assert data is not None async_add_entities(
(
entities: list[NumberEntity] = [
EcobeeVentilatorMinTime(data, index, numbers) EcobeeVentilatorMinTime(data, index, numbers)
for index, thermostat in enumerate(data.ecobee.thermostats) for index, thermostat in enumerate(data.ecobee.thermostats)
if thermostat["settings"]["ventilatorType"] != "none" if thermostat["settings"]["ventilatorType"] != "none"
for numbers in VENTILATOR_NUMBERS for numbers in VENTILATOR_NUMBERS
] ),
True,
_LOGGER.debug("Adding compressor min temp number (if present)")
entities.extend(
(
EcobeeCompressorMinTemp(data, index)
for index, thermostat in enumerate(data.ecobee.thermostats)
if thermostat["settings"]["hasHeatPump"]
) )
)
async_add_entities(entities, True)
class EcobeeVentilatorMinTime(EcobeeBaseEntity, NumberEntity): class EcobeeVentilatorMinTime(EcobeeBaseEntity, NumberEntity):
@ -119,53 +105,3 @@ class EcobeeVentilatorMinTime(EcobeeBaseEntity, NumberEntity):
"""Set new ventilator Min On Time value.""" """Set new ventilator Min On Time value."""
self.entity_description.set_fn(self.data, self.thermostat_index, int(value)) self.entity_description.set_fn(self.data, self.thermostat_index, int(value))
self.update_without_throttle = True 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

View file

@ -33,18 +33,15 @@
}, },
"number": { "number": {
"ventilator_min_type_home": { "ventilator_min_type_home": {
"name": "Ventilator minimum time home" "name": "Ventilator min time home"
}, },
"ventilator_min_type_away": { "ventilator_min_type_away": {
"name": "Ventilator minimum time away" "name": "Ventilator min time away"
},
"compressor_protection_min_temp": {
"name": "Compressor minimum temperature"
} }
}, },
"switch": { "switch": {
"aux_heat_only": { "aux_heat_only": {
"name": "Auxiliary heat only" "name": "Aux heat only"
} }
} }
}, },

View file

@ -31,7 +31,8 @@ async def async_setup_entry(
"""Set up the ecobee thermostat switch entity.""" """Set up the ecobee thermostat switch entity."""
data: EcobeeData = hass.data[DOMAIN] data: EcobeeData = hass.data[DOMAIN]
entities: list[SwitchEntity] = [ async_add_entities(
[
EcobeeVentilator20MinSwitch( EcobeeVentilator20MinSwitch(
data, data,
index, index,
@ -40,17 +41,15 @@ async def async_setup_entry(
) )
for index, thermostat in enumerate(data.ecobee.thermostats) for index, thermostat in enumerate(data.ecobee.thermostats)
if thermostat["settings"]["ventilatorType"] != "none" if thermostat["settings"]["ventilatorType"] != "none"
] ],
update_before_add=True,
)
entities.extend( async_add_entities(
(
EcobeeSwitchAuxHeatOnly(data, index) EcobeeSwitchAuxHeatOnly(data, index)
for index, thermostat in enumerate(data.ecobee.thermostats) for index, thermostat in enumerate(data.ecobee.thermostats)
if thermostat["settings"]["hasHeatPump"] if thermostat["settings"]["hasHeatPump"]
) )
)
async_add_entities(entities, update_before_add=True)
class EcobeeVentilator20MinSwitch(EcobeeBaseEntity, SwitchEntity): class EcobeeVentilator20MinSwitch(EcobeeBaseEntity, SwitchEntity):

View file

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

View file

@ -14,6 +14,7 @@ from homeassistant.config_entries import (
ConfigFlow, ConfigFlow,
ConfigFlowResult, ConfigFlowResult,
OptionsFlow, OptionsFlow,
OptionsFlowWithConfigEntry,
) )
from homeassistant.const import CONF_API_KEY from homeassistant.const import CONF_API_KEY
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
@ -102,12 +103,13 @@ class ElevenLabsConfigFlow(ConfigFlow, domain=DOMAIN):
return ElevenLabsOptionsFlow(config_entry) return ElevenLabsOptionsFlow(config_entry)
class ElevenLabsOptionsFlow(OptionsFlow): class ElevenLabsOptionsFlow(OptionsFlowWithConfigEntry):
"""ElevenLabs options flow.""" """ElevenLabs options flow."""
def __init__(self, config_entry: ConfigEntry) -> None: def __init__(self, config_entry: ConfigEntry) -> None:
"""Initialize options flow.""" """Initialize options flow."""
self.api_key: str = config_entry.data[CONF_API_KEY] super().__init__(config_entry)
self.api_key: str = self.config_entry.data[CONF_API_KEY]
# id -> name # id -> name
self.voices: dict[str, str] = {} self.voices: dict[str, str] = {}
self.models: dict[str, str] = {} self.models: dict[str, str] = {}
@ -168,7 +170,7 @@ class ElevenLabsOptionsFlow(OptionsFlow):
vol.Required(CONF_CONFIGURE_VOICE, default=False): bool, vol.Required(CONF_CONFIGURE_VOICE, default=False): bool,
} }
), ),
self.config_entry.options, self.options,
) )
async def async_step_voice_settings( async def async_step_voice_settings(

View file

@ -5,11 +5,8 @@ from pyemoncms import EmoncmsClient
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_KEY, CONF_URL, Platform from homeassistant.const import CONF_API_KEY, CONF_URL, Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from .const import DOMAIN, EMONCMS_UUID_DOC_URL, LOGGER
from .coordinator import EmoncmsCoordinator from .coordinator import EmoncmsCoordinator
PLATFORMS: list[Platform] = [Platform.SENSOR] PLATFORMS: list[Platform] = [Platform.SENSOR]
@ -17,49 +14,6 @@ PLATFORMS: list[Platform] = [Platform.SENSOR]
type EmonCMSConfigEntry = ConfigEntry[EmoncmsCoordinator] type EmonCMSConfigEntry = ConfigEntry[EmoncmsCoordinator]
def _migrate_unique_id(
hass: HomeAssistant, entry: EmonCMSConfigEntry, emoncms_unique_id: str
) -> None:
"""Migrate to emoncms unique id if needed."""
ent_reg = er.async_get(hass)
entry_entities = ent_reg.entities.get_entries_for_config_entry_id(entry.entry_id)
for entity in entry_entities:
if entity.unique_id.split("-")[0] == entry.entry_id:
feed_id = entity.unique_id.split("-")[-1]
LOGGER.debug(f"moving feed {feed_id} to hardware uuid")
ent_reg.async_update_entity(
entity.entity_id, new_unique_id=f"{emoncms_unique_id}-{feed_id}"
)
hass.config_entries.async_update_entry(
entry,
unique_id=emoncms_unique_id,
)
async def _check_unique_id_migration(
hass: HomeAssistant, entry: EmonCMSConfigEntry, emoncms_client: EmoncmsClient
) -> None:
"""Check if we can migrate to the emoncms uuid."""
emoncms_unique_id = await emoncms_client.async_get_uuid()
if emoncms_unique_id:
if entry.unique_id != emoncms_unique_id:
_migrate_unique_id(hass, entry, emoncms_unique_id)
else:
async_create_issue(
hass,
DOMAIN,
"migrate database",
is_fixable=False,
issue_domain=DOMAIN,
severity=IssueSeverity.WARNING,
translation_key="migrate_database",
translation_placeholders={
"url": entry.data[CONF_URL],
"doc_url": EMONCMS_UUID_DOC_URL,
},
)
async def async_setup_entry(hass: HomeAssistant, entry: EmonCMSConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: EmonCMSConfigEntry) -> bool:
"""Load a config entry.""" """Load a config entry."""
emoncms_client = EmoncmsClient( emoncms_client = EmoncmsClient(
@ -67,7 +21,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: EmonCMSConfigEntry) -> b
entry.data[CONF_API_KEY], entry.data[CONF_API_KEY],
session=async_get_clientsession(hass), session=async_get_clientsession(hass),
) )
await _check_unique_id_migration(hass, entry, emoncms_client)
coordinator = EmoncmsCoordinator(hass, emoncms_client) coordinator = EmoncmsCoordinator(hass, emoncms_client)
await coordinator.async_config_entry_first_refresh() await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator entry.runtime_data = coordinator

View file

@ -1,7 +1,5 @@
"""Configflow for the emoncms integration.""" """Configflow for the emoncms integration."""
from __future__ import annotations
from typing import Any from typing import Any
from pyemoncms import EmoncmsClient from pyemoncms import EmoncmsClient
@ -11,10 +9,10 @@ from homeassistant.config_entries import (
ConfigEntry, ConfigEntry,
ConfigFlow, ConfigFlow,
ConfigFlowResult, ConfigFlowResult,
OptionsFlow, OptionsFlowWithConfigEntry,
) )
from homeassistant.const import CONF_API_KEY, CONF_URL from homeassistant.const import CONF_API_KEY, CONF_URL
from homeassistant.core import callback from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.selector import selector from homeassistant.helpers.selector import selector
from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.typing import ConfigType
@ -48,10 +46,13 @@ def sensor_name(url: str) -> str:
return f"emoncms@{sensorip}" return f"emoncms@{sensorip}"
async def get_feed_list( async def get_feed_list(hass: HomeAssistant, url: str, api_key: str) -> dict[str, Any]:
emoncms_client: EmoncmsClient,
) -> dict[str, Any]:
"""Check connection to emoncms and return feed list if successful.""" """Check connection to emoncms and return feed list if successful."""
emoncms_client = EmoncmsClient(
url,
api_key,
session=async_get_clientsession(hass),
)
return await emoncms_client.async_request("/feed/list.json") return await emoncms_client.async_request("/feed/list.json")
@ -67,7 +68,7 @@ class EmoncmsConfigFlow(ConfigFlow, domain=DOMAIN):
@callback @callback
def async_get_options_flow( def async_get_options_flow(
config_entry: ConfigEntry, config_entry: ConfigEntry,
) -> EmoncmsOptionsFlow: ) -> OptionsFlowWithConfigEntry:
"""Get the options flow for this handler.""" """Get the options flow for this handler."""
return EmoncmsOptionsFlow(config_entry) return EmoncmsOptionsFlow(config_entry)
@ -76,28 +77,23 @@ class EmoncmsConfigFlow(ConfigFlow, domain=DOMAIN):
) -> ConfigFlowResult: ) -> ConfigFlowResult:
"""Initiate a flow via the UI.""" """Initiate a flow via the UI."""
errors: dict[str, str] = {} errors: dict[str, str] = {}
description_placeholders = {}
if user_input is not None: if user_input is not None:
self.url = user_input[CONF_URL]
self.api_key = user_input[CONF_API_KEY]
self._async_abort_entries_match( self._async_abort_entries_match(
{ {
CONF_API_KEY: self.api_key, CONF_API_KEY: user_input[CONF_API_KEY],
CONF_URL: self.url, CONF_URL: user_input[CONF_URL],
} }
) )
emoncms_client = EmoncmsClient( result = await get_feed_list(
self.url, self.api_key, session=async_get_clientsession(self.hass) self.hass, user_input[CONF_URL], user_input[CONF_API_KEY]
) )
result = await get_feed_list(emoncms_client)
if not result[CONF_SUCCESS]: if not result[CONF_SUCCESS]:
errors["base"] = "api_error" errors["base"] = result[CONF_MESSAGE]
description_placeholders = {"details": result[CONF_MESSAGE]}
else: else:
self.include_only_feeds = user_input.get(CONF_ONLY_INCLUDE_FEEDID) self.include_only_feeds = user_input.get(CONF_ONLY_INCLUDE_FEEDID)
await self.async_set_unique_id(await emoncms_client.async_get_uuid()) self.url = user_input[CONF_URL]
self._abort_if_unique_id_configured() self.api_key = user_input[CONF_API_KEY]
options = get_options(result[CONF_MESSAGE]) options = get_options(result[CONF_MESSAGE])
self.dropdown = { self.dropdown = {
"options": options, "options": options,
@ -117,7 +113,6 @@ class EmoncmsConfigFlow(ConfigFlow, domain=DOMAIN):
user_input, user_input,
), ),
errors=errors, errors=errors,
description_placeholders=description_placeholders,
) )
async def async_step_choose_feeds( async def async_step_choose_feeds(
@ -172,41 +167,32 @@ class EmoncmsConfigFlow(ConfigFlow, domain=DOMAIN):
return result return result
class EmoncmsOptionsFlow(OptionsFlow): class EmoncmsOptionsFlow(OptionsFlowWithConfigEntry):
"""Emoncms Options flow handler.""" """Emoncms Options flow handler."""
def __init__(self, config_entry: ConfigEntry) -> None:
"""Initialize emoncms options flow."""
self._url = config_entry.data[CONF_URL]
self._api_key = config_entry.data[CONF_API_KEY]
async def async_step_init( async def async_step_init(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult: ) -> ConfigFlowResult:
"""Manage the options.""" """Manage the options."""
errors: dict[str, str] = {} errors: dict[str, str] = {}
description_placeholders = {} data = self.options if self.options else self._config_entry.data
include_only_feeds = self.config_entry.options.get( url = data[CONF_URL]
CONF_ONLY_INCLUDE_FEEDID, api_key = data[CONF_API_KEY]
self.config_entry.data.get(CONF_ONLY_INCLUDE_FEEDID, []), include_only_feeds = data.get(CONF_ONLY_INCLUDE_FEEDID, [])
)
options: list = include_only_feeds options: list = include_only_feeds
emoncms_client = EmoncmsClient( result = await get_feed_list(self.hass, url, api_key)
self._url,
self._api_key,
session=async_get_clientsession(self.hass),
)
result = await get_feed_list(emoncms_client)
if not result[CONF_SUCCESS]: if not result[CONF_SUCCESS]:
errors["base"] = "api_error" errors["base"] = result[CONF_MESSAGE]
description_placeholders = {"details": result[CONF_MESSAGE]}
else: else:
options = get_options(result[CONF_MESSAGE]) options = get_options(result[CONF_MESSAGE])
dropdown = {"options": options, "mode": "dropdown", "multiple": True} dropdown = {"options": options, "mode": "dropdown", "multiple": True}
if user_input: if user_input:
include_only_feeds = user_input[CONF_ONLY_INCLUDE_FEEDID] include_only_feeds = user_input[CONF_ONLY_INCLUDE_FEEDID]
return self.async_create_entry( return self.async_create_entry(
title=sensor_name(url),
data={ data={
CONF_URL: url,
CONF_API_KEY: api_key,
CONF_ONLY_INCLUDE_FEEDID: include_only_feeds, CONF_ONLY_INCLUDE_FEEDID: include_only_feeds,
}, },
) )
@ -221,5 +207,4 @@ class EmoncmsOptionsFlow(OptionsFlow):
} }
), ),
errors=errors, errors=errors,
description_placeholders=description_placeholders,
) )

View file

@ -7,10 +7,6 @@ CONF_ONLY_INCLUDE_FEEDID = "include_only_feed_id"
CONF_MESSAGE = "message" CONF_MESSAGE = "message"
CONF_SUCCESS = "success" CONF_SUCCESS = "success"
DOMAIN = "emoncms" DOMAIN = "emoncms"
EMONCMS_UUID_DOC_URL = (
"https://docs.openenergymonitor.org/emoncms/update.html"
"#upgrading-to-a-version-producing-a-unique-identifier"
)
FEED_ID = "id" FEED_ID = "id"
FEED_NAME = "name" FEED_NAME = "name"
FEED_TAG = "tag" FEED_TAG = "tag"

View file

@ -138,30 +138,29 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback, async_add_entities: AddEntitiesCallback,
) -> None: ) -> None:
"""Set up the emoncms sensors.""" """Set up the emoncms sensors."""
name = sensor_name(entry.data[CONF_URL]) config = entry.options if entry.options else entry.data
exclude_feeds = entry.data.get(CONF_EXCLUDE_FEEDID) name = sensor_name(config[CONF_URL])
include_only_feeds = entry.options.get( exclude_feeds = config.get(CONF_EXCLUDE_FEEDID)
CONF_ONLY_INCLUDE_FEEDID, entry.data.get(CONF_ONLY_INCLUDE_FEEDID) include_only_feeds = config.get(CONF_ONLY_INCLUDE_FEEDID)
)
if exclude_feeds is None and include_only_feeds is None: if exclude_feeds is None and include_only_feeds is None:
return return
coordinator = entry.runtime_data coordinator = entry.runtime_data
# uuid was added in emoncms database 11.5.7
unique_id = entry.unique_id if entry.unique_id else entry.entry_id
elems = coordinator.data elems = coordinator.data
if not elems: if not elems:
return return
sensors: list[EmonCmsSensor] = [] sensors: list[EmonCmsSensor] = []
for idx, elem in enumerate(elems): for idx, elem in enumerate(elems):
if include_only_feeds is not None and elem[FEED_ID] not in include_only_feeds: if include_only_feeds is not None and elem[FEED_ID] not in include_only_feeds:
continue continue
sensors.append( sensors.append(
EmonCmsSensor( EmonCmsSensor(
coordinator, coordinator,
unique_id, entry.entry_id,
elem["unit"], elem["unit"],
name, name,
idx, idx,
@ -176,7 +175,7 @@ class EmonCmsSensor(CoordinatorEntity[EmoncmsCoordinator], SensorEntity):
def __init__( def __init__(
self, self,
coordinator: EmoncmsCoordinator, coordinator: EmoncmsCoordinator,
unique_id: str, entry_id: str,
unit_of_measurement: str | None, unit_of_measurement: str | None,
name: str, name: str,
idx: int, idx: int,
@ -189,7 +188,7 @@ class EmonCmsSensor(CoordinatorEntity[EmoncmsCoordinator], SensorEntity):
elem = self.coordinator.data[self.idx] elem = self.coordinator.data[self.idx]
self._attr_name = f"{name} {elem[FEED_NAME]}" self._attr_name = f"{name} {elem[FEED_NAME]}"
self._attr_native_unit_of_measurement = unit_of_measurement self._attr_native_unit_of_measurement = unit_of_measurement
self._attr_unique_id = f"{unique_id}-{elem[FEED_ID]}" self._attr_unique_id = f"{entry_id}-{elem[FEED_ID]}"
if unit_of_measurement in ("kWh", "Wh"): if unit_of_measurement in ("kWh", "Wh"):
self._attr_device_class = SensorDeviceClass.ENERGY self._attr_device_class = SensorDeviceClass.ENERGY
self._attr_state_class = SensorStateClass.TOTAL_INCREASING self._attr_state_class = SensorStateClass.TOTAL_INCREASING

View file

@ -1,8 +1,5 @@
{ {
"config": { "config": {
"error": {
"api_error": "An error occured in the pyemoncms API : {details}"
},
"step": { "step": {
"user": { "user": {
"data": { "data": {
@ -19,15 +16,9 @@
"include_only_feed_id": "Choose feeds to include" "include_only_feed_id": "Choose feeds to include"
} }
} }
},
"abort": {
"already_configured": "This server is already configured"
} }
}, },
"options": { "options": {
"error": {
"api_error": "[%key:component::emoncms::config::error::api_error%]"
},
"step": { "step": {
"init": { "init": {
"data": { "data": {
@ -44,10 +35,6 @@
"missing_include_only_feed_id": { "missing_include_only_feed_id": {
"title": "No feed synchronized with the {domain} sensor", "title": "No feed synchronized with the {domain} sensor",
"description": "Configuring {domain} using YAML is being removed.\n\nPlease add manually the feeds you want to synchronize with the `configure` button of the integration." "description": "Configuring {domain} using YAML is being removed.\n\nPlease add manually the feeds you want to synchronize with the `configure` button of the integration."
},
"migrate_database": {
"title": "Upgrade your emoncms version",
"description": "Your [emoncms]({url}) does not ship a unique identifier.\n\n Please upgrade to at least version 11.5.7 and migrate your emoncms database.\n\n More info on [emoncms documentation]({doc_url})"
} }
} }
} }

View file

@ -6,5 +6,5 @@
"iot_class": "local_push", "iot_class": "local_push",
"loggers": ["sense_energy"], "loggers": ["sense_energy"],
"quality_scale": "internal", "quality_scale": "internal",
"requirements": ["sense-energy==0.13.3"] "requirements": ["sense-energy==0.13.2"]
} }

View file

@ -16,7 +16,7 @@ from homeassistant.config_entries import (
ConfigEntry, ConfigEntry,
ConfigFlow, ConfigFlow,
ConfigFlowResult, ConfigFlowResult,
OptionsFlow, OptionsFlowWithConfigEntry,
) )
from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
@ -66,11 +66,9 @@ class EnphaseConfigFlow(ConfigFlow, domain=DOMAIN):
@staticmethod @staticmethod
@callback @callback
def async_get_options_flow( def async_get_options_flow(config_entry: ConfigEntry) -> EnvoyOptionsFlowHandler:
config_entry: ConfigEntry,
) -> EnvoyOptionsFlowHandler:
"""Options flow handler for Enphase_Envoy.""" """Options flow handler for Enphase_Envoy."""
return EnvoyOptionsFlowHandler() return EnvoyOptionsFlowHandler(config_entry)
@callback @callback
def _async_generate_schema(self) -> vol.Schema: def _async_generate_schema(self) -> vol.Schema:
@ -290,7 +288,7 @@ class EnphaseConfigFlow(ConfigFlow, domain=DOMAIN):
) )
class EnvoyOptionsFlowHandler(OptionsFlow): class EnvoyOptionsFlowHandler(OptionsFlowWithConfigEntry):
"""Envoy config flow options handler.""" """Envoy config flow options handler."""
async def async_step_init( async def async_step_init(

View file

@ -15,23 +15,17 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.dispatcher import async_dispatcher_send
from .const import SIGNAL_THERMOSTAT_CONNECTED, SIGNAL_THERMOSTAT_DISCONNECTED from .const import DOMAIN, SIGNAL_THERMOSTAT_CONNECTED, SIGNAL_THERMOSTAT_DISCONNECTED
from .models import Eq3Config, Eq3ConfigEntryData from .models import Eq3Config, Eq3ConfigEntryData
PLATFORMS = [ PLATFORMS = [
Platform.BINARY_SENSOR,
Platform.CLIMATE, Platform.CLIMATE,
Platform.NUMBER,
Platform.SWITCH,
] ]
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
type Eq3ConfigEntry = ConfigEntry[Eq3ConfigEntryData] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: Eq3ConfigEntry) -> bool:
"""Handle config entry setup.""" """Handle config entry setup."""
mac_address: str | None = entry.unique_id mac_address: str | None = entry.unique_id
@ -59,11 +53,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: Eq3ConfigEntry) -> bool:
ble_device=device, ble_device=device,
) )
entry.runtime_data = Eq3ConfigEntryData( eq3_config_entry = Eq3ConfigEntryData(eq3_config=eq3_config, thermostat=thermostat)
eq3_config=eq3_config, thermostat=thermostat hass.data.setdefault(DOMAIN, {})[entry.entry_id] = eq3_config_entry
)
entry.async_on_unload(entry.add_update_listener(update_listener)) entry.async_on_unload(entry.add_update_listener(update_listener))
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
entry.async_create_background_task( entry.async_create_background_task(
hass, _async_run_thermostat(hass, entry), entry.entry_id hass, _async_run_thermostat(hass, entry), entry.entry_id
) )
@ -71,27 +66,29 @@ async def async_setup_entry(hass: HomeAssistant, entry: Eq3ConfigEntry) -> bool:
return True return True
async def async_unload_entry(hass: HomeAssistant, entry: Eq3ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Handle config entry unload.""" """Handle config entry unload."""
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
await entry.runtime_data.thermostat.async_disconnect() eq3_config_entry: Eq3ConfigEntryData = hass.data[DOMAIN].pop(entry.entry_id)
await eq3_config_entry.thermostat.async_disconnect()
return unload_ok return unload_ok
async def update_listener(hass: HomeAssistant, entry: Eq3ConfigEntry) -> None: async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Handle config entry update.""" """Handle config entry update."""
await hass.config_entries.async_reload(entry.entry_id) await hass.config_entries.async_reload(entry.entry_id)
async def _async_run_thermostat(hass: HomeAssistant, entry: Eq3ConfigEntry) -> None: async def _async_run_thermostat(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Run the thermostat.""" """Run the thermostat."""
thermostat = entry.runtime_data.thermostat eq3_config_entry: Eq3ConfigEntryData = hass.data[DOMAIN][entry.entry_id]
mac_address = entry.runtime_data.eq3_config.mac_address thermostat = eq3_config_entry.thermostat
scan_interval = entry.runtime_data.eq3_config.scan_interval mac_address = eq3_config_entry.eq3_config.mac_address
scan_interval = eq3_config_entry.eq3_config.scan_interval
await _async_reconnect_thermostat(hass, entry) await _async_reconnect_thermostat(hass, entry)
@ -120,14 +117,13 @@ async def _async_run_thermostat(hass: HomeAssistant, entry: Eq3ConfigEntry) -> N
await asyncio.sleep(scan_interval) await asyncio.sleep(scan_interval)
async def _async_reconnect_thermostat( async def _async_reconnect_thermostat(hass: HomeAssistant, entry: ConfigEntry) -> None:
hass: HomeAssistant, entry: Eq3ConfigEntry
) -> None:
"""Reconnect the thermostat.""" """Reconnect the thermostat."""
thermostat = entry.runtime_data.thermostat eq3_config_entry: Eq3ConfigEntryData = hass.data[DOMAIN][entry.entry_id]
mac_address = entry.runtime_data.eq3_config.mac_address thermostat = eq3_config_entry.thermostat
scan_interval = entry.runtime_data.eq3_config.scan_interval mac_address = eq3_config_entry.eq3_config.mac_address
scan_interval = eq3_config_entry.eq3_config.scan_interval
while True: while True:
try: try:

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