Compare commits
3 commits
dev
...
analytics-
Author | SHA1 | Date | |
---|---|---|---|
|
98cff0bd74 | ||
|
b3a32b5f59 | ||
|
d7d2b7ad76 |
796 changed files with 6695 additions and 23089 deletions
|
@ -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/**
|
||||||
|
|
4
.github/workflows/builder.yml
vendored
4
.github/workflows/builder.yml
vendored
|
@ -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 }}
|
||||||
|
|
26
.github/workflows/ci.yaml
vendored
26
.github/workflows/ci.yaml
vendored
|
@ -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 \
|
||||||
|
|
4
.github/workflows/codeql.yml
vendored
4
.github/workflows/codeql.yml
vendored
|
@ -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"
|
||||||
|
|
30
.github/workflows/wheels.yml
vendored
30
.github/workflows/wheels.yml
vendored
|
@ -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"
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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.*
|
||||||
|
|
16
CODEOWNERS
16
CODEOWNERS
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
10
build.yaml
10
build.yaml
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -1,5 +0,0 @@
|
||||||
{
|
|
||||||
"domain": "sky",
|
|
||||||
"name": "Sky",
|
|
||||||
"integrations": ["sky_hub", "sky_remote"]
|
|
||||||
}
|
|
|
@ -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)
|
|
|
@ -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)
|
|
|
@ -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,
|
|
||||||
)
|
|
|
@ -1,4 +0,0 @@
|
||||||
"""Constants for component."""
|
|
||||||
|
|
||||||
DOMAIN = "acaia"
|
|
||||||
CONF_IS_NEW_STYLE_SCALE = "is_new_style_scale"
|
|
|
@ -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",
|
|
||||||
)
|
|
||||||
)
|
|
|
@ -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
|
|
|
@ -1,15 +0,0 @@
|
||||||
{
|
|
||||||
"entity": {
|
|
||||||
"button": {
|
|
||||||
"tare": {
|
|
||||||
"default": "mdi:scale-balance"
|
|
||||||
},
|
|
||||||
"reset_timer": {
|
|
||||||
"default": "mdi:timer-refresh"
|
|
||||||
},
|
|
||||||
"start_stop": {
|
|
||||||
"default": "mdi:timer-play"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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"]
|
|
||||||
}
|
|
|
@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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)
|
||||||
|
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -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"]
|
||||||
}
|
}
|
||||||
|
|
|
@ -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"]
|
||||||
}
|
}
|
||||||
|
|
|
@ -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"]
|
||||||
}
|
}
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
@ -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,
|
||||||
|
}
|
||||||
|
|
|
@ -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,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
|
@ -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(
|
||||||
{
|
{
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
)
|
)
|
||||||
|
|
|
@ -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,
|
|
||||||
)
|
)
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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 = {}
|
||||||
|
|
||||||
|
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
@ -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)
|
|
||||||
|
|
|
@ -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",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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" }
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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()
|
|
||||||
|
|
|
@ -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:
|
|
|
@ -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."
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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,
|
||||||
),
|
),
|
||||||
|
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
@ -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."""
|
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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"]
|
||||||
}
|
}
|
||||||
|
|
|
@ -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"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
@ -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"]
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
|
@ -66,8 +66,7 @@
|
||||||
"name": "List access",
|
"name": "List access",
|
||||||
"state": {
|
"state": {
|
||||||
"registered": "Private",
|
"registered": "Private",
|
||||||
"shared": "Shared",
|
"shared": "Shared"
|
||||||
"invitation": "Invitation pending"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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"]
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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."]
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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."""
|
||||||
|
|
|
@ -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"]))
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -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"]
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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"]
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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 (
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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"]
|
||||||
}
|
}
|
||||||
|
|
|
@ -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.",
|
||||||
|
|
|
@ -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)),
|
||||||
|
|
|
@ -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
|
|
||||||
|
|
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
@ -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):
|
||||||
|
|
|
@ -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"]
|
||||||
}
|
}
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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,
|
|
||||||
)
|
)
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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})"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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"]
|
||||||
}
|
}
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue