Compare commits
7 commits
dev
...
gj-2024102
Author | SHA1 | Date | |
---|---|---|---|
|
c7df6d531c | ||
|
88836d3318 | ||
|
b1540a6f02 | ||
|
70068499ac | ||
|
4f7f119e2f | ||
|
2427a52d46 | ||
|
0199697af5 |
1259 changed files with 11680 additions and 43742 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 }}
|
||||||
|
|
34
.github/workflows/ci.yaml
vendored
34
.github/workflows/ci.yaml
vendored
|
@ -40,9 +40,9 @@ env:
|
||||||
CACHE_VERSION: 11
|
CACHE_VERSION: 11
|
||||||
UV_CACHE_VERSION: 1
|
UV_CACHE_VERSION: 1
|
||||||
MYPY_CACHE_VERSION: 9
|
MYPY_CACHE_VERSION: 9
|
||||||
HA_SHORT_VERSION: "2024.12"
|
HA_SHORT_VERSION: "2024.11"
|
||||||
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 \
|
||||||
|
@ -1100,7 +1102,7 @@ jobs:
|
||||||
./script/check_dirty
|
./script/check_dirty
|
||||||
|
|
||||||
pytest-postgres:
|
pytest-postgres:
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-22.04
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
image: ${{ matrix.postgresql-group }}
|
image: ${{ matrix.postgresql-group }}
|
||||||
|
@ -1140,9 +1142,7 @@ jobs:
|
||||||
sudo apt-get -y install \
|
sudo apt-get -y install \
|
||||||
bluez \
|
bluez \
|
||||||
ffmpeg \
|
ffmpeg \
|
||||||
libturbojpeg
|
libturbojpeg \
|
||||||
sudo /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh -y
|
|
||||||
sudo apt-get -y install \
|
|
||||||
postgresql-server-dev-14
|
postgresql-server-dev-14
|
||||||
- name: Check out code from GitHub
|
- name: Check out code from GitHub
|
||||||
uses: actions/checkout@v4.2.2
|
uses: actions/checkout@v4.2.2
|
||||||
|
@ -1197,7 +1197,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 +1343,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.1
|
||||||
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
|
||||||
|
|
|
@ -209,7 +209,6 @@ homeassistant.components.geo_location.*
|
||||||
homeassistant.components.geocaching.*
|
homeassistant.components.geocaching.*
|
||||||
homeassistant.components.gios.*
|
homeassistant.components.gios.*
|
||||||
homeassistant.components.glances.*
|
homeassistant.components.glances.*
|
||||||
homeassistant.components.go2rtc.*
|
|
||||||
homeassistant.components.goalzero.*
|
homeassistant.components.goalzero.*
|
||||||
homeassistant.components.google.*
|
homeassistant.components.google.*
|
||||||
homeassistant.components.google_assistant_sdk.*
|
homeassistant.components.google_assistant_sdk.*
|
||||||
|
@ -324,13 +323,11 @@ homeassistant.components.moon.*
|
||||||
homeassistant.components.mopeka.*
|
homeassistant.components.mopeka.*
|
||||||
homeassistant.components.motionmount.*
|
homeassistant.components.motionmount.*
|
||||||
homeassistant.components.mqtt.*
|
homeassistant.components.mqtt.*
|
||||||
homeassistant.components.music_assistant.*
|
|
||||||
homeassistant.components.my.*
|
homeassistant.components.my.*
|
||||||
homeassistant.components.mysensors.*
|
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 +337,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.*
|
||||||
|
|
18
CODEOWNERS
18
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
|
||||||
|
@ -956,8 +954,6 @@ build.json @home-assistant/supervisor
|
||||||
/homeassistant/components/msteams/ @peroyvind
|
/homeassistant/components/msteams/ @peroyvind
|
||||||
/homeassistant/components/mullvad/ @meichthys
|
/homeassistant/components/mullvad/ @meichthys
|
||||||
/tests/components/mullvad/ @meichthys
|
/tests/components/mullvad/ @meichthys
|
||||||
/homeassistant/components/music_assistant/ @music-assistant
|
|
||||||
/tests/components/music_assistant/ @music-assistant
|
|
||||||
/homeassistant/components/mutesync/ @currentoor
|
/homeassistant/components/mutesync/ @currentoor
|
||||||
/tests/components/mutesync/ @currentoor
|
/tests/components/mutesync/ @currentoor
|
||||||
/homeassistant/components/my/ @home-assistant/core
|
/homeassistant/components/my/ @home-assistant/core
|
||||||
|
@ -972,8 +968,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 +1008,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 +1338,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 +1479,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.22
|
||||||
|
|
||||||
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.4/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
|
||||||
|
|
|
@ -9,7 +9,6 @@ import os
|
||||||
import sys
|
import sys
|
||||||
import threading
|
import threading
|
||||||
|
|
||||||
from .backup_restore import restore_backup
|
|
||||||
from .const import REQUIRED_PYTHON_VER, RESTART_EXIT_CODE, __version__
|
from .const import REQUIRED_PYTHON_VER, RESTART_EXIT_CODE, __version__
|
||||||
|
|
||||||
FAULT_LOG_FILENAME = "home-assistant.log.fault"
|
FAULT_LOG_FILENAME = "home-assistant.log.fault"
|
||||||
|
@ -183,9 +182,6 @@ def main() -> int:
|
||||||
return scripts.run(args.script)
|
return scripts.run(args.script)
|
||||||
|
|
||||||
config_dir = os.path.abspath(os.path.join(os.getcwd(), args.config))
|
config_dir = os.path.abspath(os.path.join(os.getcwd(), args.config))
|
||||||
if restore_backup(config_dir):
|
|
||||||
return RESTART_EXIT_CODE
|
|
||||||
|
|
||||||
ensure_config_path(config_dir)
|
ensure_config_path(config_dir)
|
||||||
|
|
||||||
# pylint: disable-next=import-outside-toplevel
|
# pylint: disable-next=import-outside-toplevel
|
||||||
|
|
|
@ -1,126 +0,0 @@
|
||||||
"""Home Assistant module to handle restoring backups."""
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
from pathlib import Path
|
|
||||||
import shutil
|
|
||||||
import sys
|
|
||||||
from tempfile import TemporaryDirectory
|
|
||||||
|
|
||||||
from awesomeversion import AwesomeVersion
|
|
||||||
import securetar
|
|
||||||
|
|
||||||
from .const import __version__ as HA_VERSION
|
|
||||||
|
|
||||||
RESTORE_BACKUP_FILE = ".HA_RESTORE"
|
|
||||||
KEEP_PATHS = ("backups",)
|
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class RestoreBackupFileContent:
|
|
||||||
"""Definition for restore backup file content."""
|
|
||||||
|
|
||||||
backup_file_path: Path
|
|
||||||
|
|
||||||
|
|
||||||
def restore_backup_file_content(config_dir: Path) -> RestoreBackupFileContent | None:
|
|
||||||
"""Return the contents of the restore backup file."""
|
|
||||||
instruction_path = config_dir.joinpath(RESTORE_BACKUP_FILE)
|
|
||||||
try:
|
|
||||||
instruction_content = json.loads(instruction_path.read_text(encoding="utf-8"))
|
|
||||||
return RestoreBackupFileContent(
|
|
||||||
backup_file_path=Path(instruction_content["path"])
|
|
||||||
)
|
|
||||||
except (FileNotFoundError, json.JSONDecodeError):
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _clear_configuration_directory(config_dir: Path) -> None:
|
|
||||||
"""Delete all files and directories in the config directory except for the backups directory."""
|
|
||||||
keep_paths = [config_dir.joinpath(path) for path in KEEP_PATHS]
|
|
||||||
config_contents = sorted(
|
|
||||||
[entry for entry in config_dir.iterdir() if entry not in keep_paths]
|
|
||||||
)
|
|
||||||
|
|
||||||
for entry in config_contents:
|
|
||||||
entrypath = config_dir.joinpath(entry)
|
|
||||||
|
|
||||||
if entrypath.is_file():
|
|
||||||
entrypath.unlink()
|
|
||||||
elif entrypath.is_dir():
|
|
||||||
shutil.rmtree(entrypath)
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_backup(config_dir: Path, backup_file_path: Path) -> None:
|
|
||||||
"""Extract the backup file to the config directory."""
|
|
||||||
with (
|
|
||||||
TemporaryDirectory() as tempdir,
|
|
||||||
securetar.SecureTarFile(
|
|
||||||
backup_file_path,
|
|
||||||
gzip=False,
|
|
||||||
mode="r",
|
|
||||||
) as ostf,
|
|
||||||
):
|
|
||||||
ostf.extractall(
|
|
||||||
path=Path(tempdir, "extracted"),
|
|
||||||
members=securetar.secure_path(ostf),
|
|
||||||
filter="fully_trusted",
|
|
||||||
)
|
|
||||||
backup_meta_file = Path(tempdir, "extracted", "backup.json")
|
|
||||||
backup_meta = json.loads(backup_meta_file.read_text(encoding="utf8"))
|
|
||||||
|
|
||||||
if (
|
|
||||||
backup_meta_version := AwesomeVersion(
|
|
||||||
backup_meta["homeassistant"]["version"]
|
|
||||||
)
|
|
||||||
) > HA_VERSION:
|
|
||||||
raise ValueError(
|
|
||||||
f"You need at least Home Assistant version {backup_meta_version} to restore this backup"
|
|
||||||
)
|
|
||||||
|
|
||||||
with securetar.SecureTarFile(
|
|
||||||
Path(
|
|
||||||
tempdir,
|
|
||||||
"extracted",
|
|
||||||
f"homeassistant.tar{'.gz' if backup_meta["compressed"] else ''}",
|
|
||||||
),
|
|
||||||
gzip=backup_meta["compressed"],
|
|
||||||
mode="r",
|
|
||||||
) as istf:
|
|
||||||
for member in istf.getmembers():
|
|
||||||
if member.name == "data":
|
|
||||||
continue
|
|
||||||
member.name = member.name.replace("data/", "")
|
|
||||||
_clear_configuration_directory(config_dir)
|
|
||||||
istf.extractall(
|
|
||||||
path=config_dir,
|
|
||||||
members=[
|
|
||||||
member
|
|
||||||
for member in securetar.secure_path(istf)
|
|
||||||
if member.name != "data"
|
|
||||||
],
|
|
||||||
filter="fully_trusted",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def restore_backup(config_dir_path: str) -> bool:
|
|
||||||
"""Restore the backup file if any.
|
|
||||||
|
|
||||||
Returns True if a restore backup file was found and restored, False otherwise.
|
|
||||||
"""
|
|
||||||
config_dir = Path(config_dir_path)
|
|
||||||
if not (restore_content := restore_backup_file_content(config_dir)):
|
|
||||||
return False
|
|
||||||
|
|
||||||
logging.basicConfig(stream=sys.stdout, level=logging.INFO)
|
|
||||||
backup_file_path = restore_content.backup_file_path
|
|
||||||
_LOGGER.info("Restoring %s", backup_file_path)
|
|
||||||
try:
|
|
||||||
_extract_backup(config_dir, backup_file_path)
|
|
||||||
except FileNotFoundError as err:
|
|
||||||
raise ValueError(f"Backup file {backup_file_path} does not exist") from err
|
|
||||||
_LOGGER.info("Restore complete, restarting")
|
|
||||||
return True
|
|
|
@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -7,6 +7,7 @@ from typing import Any
|
||||||
from adguardhome import AdGuardHome, AdGuardHomeConnectionError
|
from adguardhome import AdGuardHome, AdGuardHomeConnectionError
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
|
from homeassistant.components.hassio import HassioServiceInfo
|
||||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||||
from homeassistant.const import (
|
from homeassistant.const import (
|
||||||
CONF_HOST,
|
CONF_HOST,
|
||||||
|
@ -17,7 +18,6 @@ from homeassistant.const import (
|
||||||
CONF_VERIFY_SSL,
|
CONF_VERIFY_SSL,
|
||||||
)
|
)
|
||||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||||
from homeassistant.helpers.service_info.hassio import HassioServiceInfo
|
|
||||||
|
|
||||||
from .const import DOMAIN
|
from .const import DOMAIN
|
||||||
|
|
||||||
|
|
|
@ -55,7 +55,6 @@ async def async_setup_entry(
|
||||||
coordinator = DataUpdateCoordinator(
|
coordinator = DataUpdateCoordinator(
|
||||||
hass,
|
hass,
|
||||||
_LOGGER,
|
_LOGGER,
|
||||||
config_entry=entry,
|
|
||||||
name="Advantage Air",
|
name="Advantage Air",
|
||||||
update_method=async_get,
|
update_method=async_get,
|
||||||
update_interval=timedelta(seconds=ADVANTAGE_AIR_SYNC_INTERVAL),
|
update_interval=timedelta(seconds=ADVANTAGE_AIR_SYNC_INTERVAL),
|
||||||
|
|
|
@ -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"]
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +1,5 @@
|
||||||
"""Config flow for AirNow integration."""
|
"""Config flow for AirNow integration."""
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
@ -14,6 +12,7 @@ from homeassistant.config_entries import (
|
||||||
ConfigFlow,
|
ConfigFlow,
|
||||||
ConfigFlowResult,
|
ConfigFlowResult,
|
||||||
OptionsFlow,
|
OptionsFlow,
|
||||||
|
OptionsFlowWithConfigEntry,
|
||||||
)
|
)
|
||||||
from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_RADIUS
|
from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_RADIUS
|
||||||
from homeassistant.core import HomeAssistant, callback
|
from homeassistant.core import HomeAssistant, callback
|
||||||
|
@ -121,12 +120,12 @@ class AirNowConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||||
@callback
|
@callback
|
||||||
def async_get_options_flow(
|
def async_get_options_flow(
|
||||||
config_entry: ConfigEntry,
|
config_entry: ConfigEntry,
|
||||||
) -> AirNowOptionsFlowHandler:
|
) -> OptionsFlow:
|
||||||
"""Return the options flow."""
|
"""Return the options flow."""
|
||||||
return AirNowOptionsFlowHandler()
|
return AirNowOptionsFlowHandler(config_entry)
|
||||||
|
|
||||||
|
|
||||||
class AirNowOptionsFlowHandler(OptionsFlow):
|
class AirNowOptionsFlowHandler(OptionsFlowWithConfigEntry):
|
||||||
"""Handle an options flow for AirNow."""
|
"""Handle an options flow for AirNow."""
|
||||||
|
|
||||||
async def async_step_init(
|
async def async_step_init(
|
||||||
|
@ -137,7 +136,12 @@ class AirNowOptionsFlowHandler(OptionsFlow):
|
||||||
return self.async_create_entry(data=user_input)
|
return self.async_create_entry(data=user_input)
|
||||||
|
|
||||||
options_schema = vol.Schema(
|
options_schema = vol.Schema(
|
||||||
{vol.Optional(CONF_RADIUS): vol.All(int, vol.Range(min=5))}
|
{
|
||||||
|
vol.Optional(CONF_RADIUS): vol.All(
|
||||||
|
int,
|
||||||
|
vol.Range(min=5),
|
||||||
|
),
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
return self.async_show_form(
|
return self.async_show_form(
|
||||||
|
|
|
@ -42,7 +42,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: AirthingsConfigEntry) ->
|
||||||
coordinator = DataUpdateCoordinator(
|
coordinator = DataUpdateCoordinator(
|
||||||
hass,
|
hass,
|
||||||
_LOGGER,
|
_LOGGER,
|
||||||
config_entry=entry,
|
|
||||||
name=DOMAIN,
|
name=DOMAIN,
|
||||||
update_method=_update_method,
|
update_method=_update_method,
|
||||||
update_interval=SCAN_INTERVAL,
|
update_interval=SCAN_INTERVAL,
|
||||||
|
|
|
@ -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"]
|
||||||
}
|
}
|
||||||
|
|
|
@ -204,7 +204,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: AirVisualConfigEntry) ->
|
||||||
coordinator = DataUpdateCoordinator(
|
coordinator = DataUpdateCoordinator(
|
||||||
hass,
|
hass,
|
||||||
LOGGER,
|
LOGGER,
|
||||||
config_entry=entry,
|
|
||||||
name=async_get_geography_id(entry.data),
|
name=async_get_geography_id(entry.data),
|
||||||
# We give a placeholder update interval in order to create the coordinator;
|
# We give a placeholder update interval in order to create the coordinator;
|
||||||
# then, below, we use the coordinator's presence (along with any other
|
# then, below, we use the coordinator's presence (along with any other
|
||||||
|
|
|
@ -81,7 +81,6 @@ async def async_setup_entry(
|
||||||
coordinator = DataUpdateCoordinator(
|
coordinator = DataUpdateCoordinator(
|
||||||
hass,
|
hass,
|
||||||
LOGGER,
|
LOGGER,
|
||||||
config_entry=entry,
|
|
||||||
name="Node/Pro data",
|
name="Node/Pro data",
|
||||||
update_interval=UPDATE_INTERVAL,
|
update_interval=UPDATE_INTERVAL,
|
||||||
update_method=async_get_data,
|
update_method=async_get_data,
|
||||||
|
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
@ -29,7 +29,6 @@ from homeassistant.core import HomeAssistant, callback
|
||||||
from homeassistant.exceptions import HomeAssistantError
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
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
|
import homeassistant.helpers.entity_registry as er
|
||||||
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
|
||||||
from homeassistant.loader import (
|
from homeassistant.loader import (
|
||||||
|
@ -137,7 +136,7 @@ class Analytics:
|
||||||
@property
|
@property
|
||||||
def supervisor(self) -> bool:
|
def supervisor(self) -> bool:
|
||||||
"""Return bool if a supervisor is present."""
|
"""Return bool if a supervisor is present."""
|
||||||
return is_hassio(self.hass)
|
return hassio.is_hassio(self.hass)
|
||||||
|
|
||||||
async def load(self) -> None:
|
async def load(self) -> None:
|
||||||
"""Load preferences."""
|
"""Load preferences."""
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"domain": "analytics",
|
"domain": "analytics",
|
||||||
"name": "Analytics",
|
"name": "Analytics",
|
||||||
"after_dependencies": ["energy", "hassio", "recorder"],
|
"after_dependencies": ["energy", "recorder"],
|
||||||
"codeowners": ["@home-assistant/core", "@ludeeus"],
|
"codeowners": ["@home-assistant/core", "@ludeeus"],
|
||||||
"dependencies": ["api", "websocket_api"],
|
"dependencies": ["api", "websocket_api"],
|
||||||
"documentation": "https://www.home-assistant.io/integrations/analytics",
|
"documentation": "https://www.home-assistant.io/integrations/analytics",
|
||||||
|
|
|
@ -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
|
||||||
|
@ -26,7 +27,6 @@ from homeassistant.helpers.selector import (
|
||||||
)
|
)
|
||||||
|
|
||||||
from .const import (
|
from .const import (
|
||||||
CONF_TRACKED_ADDONS,
|
|
||||||
CONF_TRACKED_CUSTOM_INTEGRATIONS,
|
CONF_TRACKED_CUSTOM_INTEGRATIONS,
|
||||||
CONF_TRACKED_INTEGRATIONS,
|
CONF_TRACKED_INTEGRATIONS,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
|
@ -45,11 +45,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
|
||||||
|
@ -57,12 +55,8 @@ class HomeassistantAnalyticsConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||||
"""Handle the initial step."""
|
"""Handle the initial step."""
|
||||||
errors: dict[str, str] = {}
|
errors: dict[str, str] = {}
|
||||||
if user_input is not None:
|
if user_input is not None:
|
||||||
if all(
|
if not user_input.get(CONF_TRACKED_INTEGRATIONS) and not user_input.get(
|
||||||
[
|
CONF_TRACKED_CUSTOM_INTEGRATIONS
|
||||||
not user_input.get(CONF_TRACKED_ADDONS),
|
|
||||||
not user_input.get(CONF_TRACKED_INTEGRATIONS),
|
|
||||||
not user_input.get(CONF_TRACKED_CUSTOM_INTEGRATIONS),
|
|
||||||
]
|
|
||||||
):
|
):
|
||||||
errors["base"] = "no_integrations_selected"
|
errors["base"] = "no_integrations_selected"
|
||||||
else:
|
else:
|
||||||
|
@ -70,7 +64,6 @@ class HomeassistantAnalyticsConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||||
title="Home Assistant Analytics Insights",
|
title="Home Assistant Analytics Insights",
|
||||||
data={},
|
data={},
|
||||||
options={
|
options={
|
||||||
CONF_TRACKED_ADDONS: user_input.get(CONF_TRACKED_ADDONS, []),
|
|
||||||
CONF_TRACKED_INTEGRATIONS: user_input.get(
|
CONF_TRACKED_INTEGRATIONS: user_input.get(
|
||||||
CONF_TRACKED_INTEGRATIONS, []
|
CONF_TRACKED_INTEGRATIONS, []
|
||||||
),
|
),
|
||||||
|
@ -84,7 +77,6 @@ class HomeassistantAnalyticsConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||||
session=async_get_clientsession(self.hass)
|
session=async_get_clientsession(self.hass)
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
addons = await client.get_addons()
|
|
||||||
integrations = await client.get_integrations()
|
integrations = await client.get_integrations()
|
||||||
custom_integrations = await client.get_custom_integrations()
|
custom_integrations = await client.get_custom_integrations()
|
||||||
except HomeassistantAnalyticsConnectionError:
|
except HomeassistantAnalyticsConnectionError:
|
||||||
|
@ -107,13 +99,6 @@ class HomeassistantAnalyticsConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||||
errors=errors,
|
errors=errors,
|
||||||
data_schema=vol.Schema(
|
data_schema=vol.Schema(
|
||||||
{
|
{
|
||||||
vol.Optional(CONF_TRACKED_ADDONS): SelectSelector(
|
|
||||||
SelectSelectorConfig(
|
|
||||||
options=list(addons),
|
|
||||||
multiple=True,
|
|
||||||
sort=True,
|
|
||||||
)
|
|
||||||
),
|
|
||||||
vol.Optional(CONF_TRACKED_INTEGRATIONS): SelectSelector(
|
vol.Optional(CONF_TRACKED_INTEGRATIONS): SelectSelector(
|
||||||
SelectSelectorConfig(
|
SelectSelectorConfig(
|
||||||
options=options,
|
options=options,
|
||||||
|
@ -133,7 +118,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(
|
||||||
|
@ -142,19 +127,14 @@ class HomeassistantAnalyticsOptionsFlowHandler(OptionsFlow):
|
||||||
"""Manage the options."""
|
"""Manage the options."""
|
||||||
errors: dict[str, str] = {}
|
errors: dict[str, str] = {}
|
||||||
if user_input is not None:
|
if user_input is not None:
|
||||||
if all(
|
if not user_input.get(CONF_TRACKED_INTEGRATIONS) and not user_input.get(
|
||||||
[
|
CONF_TRACKED_CUSTOM_INTEGRATIONS
|
||||||
not user_input.get(CONF_TRACKED_ADDONS),
|
|
||||||
not user_input.get(CONF_TRACKED_INTEGRATIONS),
|
|
||||||
not user_input.get(CONF_TRACKED_CUSTOM_INTEGRATIONS),
|
|
||||||
]
|
|
||||||
):
|
):
|
||||||
errors["base"] = "no_integrations_selected"
|
errors["base"] = "no_integrations_selected"
|
||||||
else:
|
else:
|
||||||
return self.async_create_entry(
|
return self.async_create_entry(
|
||||||
title="",
|
title="",
|
||||||
data={
|
data={
|
||||||
CONF_TRACKED_ADDONS: user_input.get(CONF_TRACKED_ADDONS, []),
|
|
||||||
CONF_TRACKED_INTEGRATIONS: user_input.get(
|
CONF_TRACKED_INTEGRATIONS: user_input.get(
|
||||||
CONF_TRACKED_INTEGRATIONS, []
|
CONF_TRACKED_INTEGRATIONS, []
|
||||||
),
|
),
|
||||||
|
@ -168,7 +148,6 @@ class HomeassistantAnalyticsOptionsFlowHandler(OptionsFlow):
|
||||||
session=async_get_clientsession(self.hass)
|
session=async_get_clientsession(self.hass)
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
addons = await client.get_addons()
|
|
||||||
integrations = await client.get_integrations()
|
integrations = await client.get_integrations()
|
||||||
custom_integrations = await client.get_custom_integrations()
|
custom_integrations = await client.get_custom_integrations()
|
||||||
except HomeassistantAnalyticsConnectionError:
|
except HomeassistantAnalyticsConnectionError:
|
||||||
|
@ -189,13 +168,6 @@ class HomeassistantAnalyticsOptionsFlowHandler(OptionsFlow):
|
||||||
data_schema=self.add_suggested_values_to_schema(
|
data_schema=self.add_suggested_values_to_schema(
|
||||||
vol.Schema(
|
vol.Schema(
|
||||||
{
|
{
|
||||||
vol.Optional(CONF_TRACKED_ADDONS): SelectSelector(
|
|
||||||
SelectSelectorConfig(
|
|
||||||
options=list(addons),
|
|
||||||
multiple=True,
|
|
||||||
sort=True,
|
|
||||||
)
|
|
||||||
),
|
|
||||||
vol.Optional(CONF_TRACKED_INTEGRATIONS): SelectSelector(
|
vol.Optional(CONF_TRACKED_INTEGRATIONS): SelectSelector(
|
||||||
SelectSelectorConfig(
|
SelectSelectorConfig(
|
||||||
options=options,
|
options=options,
|
||||||
|
@ -212,6 +184,6 @@ class HomeassistantAnalyticsOptionsFlowHandler(OptionsFlow):
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
self.config_entry.options,
|
self.options,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
|
@ -4,7 +4,6 @@ import logging
|
||||||
|
|
||||||
DOMAIN = "analytics_insights"
|
DOMAIN = "analytics_insights"
|
||||||
|
|
||||||
CONF_TRACKED_ADDONS = "tracked_addons"
|
|
||||||
CONF_TRACKED_INTEGRATIONS = "tracked_integrations"
|
CONF_TRACKED_INTEGRATIONS = "tracked_integrations"
|
||||||
CONF_TRACKED_CUSTOM_INTEGRATIONS = "tracked_custom_integrations"
|
CONF_TRACKED_CUSTOM_INTEGRATIONS = "tracked_custom_integrations"
|
||||||
|
|
||||||
|
|
|
@ -12,13 +12,11 @@ from python_homeassistant_analytics import (
|
||||||
HomeassistantAnalyticsConnectionError,
|
HomeassistantAnalyticsConnectionError,
|
||||||
HomeassistantAnalyticsNotModifiedError,
|
HomeassistantAnalyticsNotModifiedError,
|
||||||
)
|
)
|
||||||
from python_homeassistant_analytics.models import Addon
|
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
from .const import (
|
from .const import (
|
||||||
CONF_TRACKED_ADDONS,
|
|
||||||
CONF_TRACKED_CUSTOM_INTEGRATIONS,
|
CONF_TRACKED_CUSTOM_INTEGRATIONS,
|
||||||
CONF_TRACKED_INTEGRATIONS,
|
CONF_TRACKED_INTEGRATIONS,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
|
@ -35,7 +33,6 @@ class AnalyticsData:
|
||||||
|
|
||||||
active_installations: int
|
active_installations: int
|
||||||
reports_integrations: int
|
reports_integrations: int
|
||||||
addons: dict[str, int]
|
|
||||||
core_integrations: dict[str, int]
|
core_integrations: dict[str, int]
|
||||||
custom_integrations: dict[str, int]
|
custom_integrations: dict[str, int]
|
||||||
|
|
||||||
|
@ -56,7 +53,6 @@ class HomeassistantAnalyticsDataUpdateCoordinator(DataUpdateCoordinator[Analytic
|
||||||
update_interval=timedelta(hours=12),
|
update_interval=timedelta(hours=12),
|
||||||
)
|
)
|
||||||
self._client = client
|
self._client = client
|
||||||
self._tracked_addons = self.config_entry.options.get(CONF_TRACKED_ADDONS, [])
|
|
||||||
self._tracked_integrations = self.config_entry.options[
|
self._tracked_integrations = self.config_entry.options[
|
||||||
CONF_TRACKED_INTEGRATIONS
|
CONF_TRACKED_INTEGRATIONS
|
||||||
]
|
]
|
||||||
|
@ -66,7 +62,6 @@ class HomeassistantAnalyticsDataUpdateCoordinator(DataUpdateCoordinator[Analytic
|
||||||
|
|
||||||
async def _async_update_data(self) -> AnalyticsData:
|
async def _async_update_data(self) -> AnalyticsData:
|
||||||
try:
|
try:
|
||||||
addons_data = await self._client.get_addons()
|
|
||||||
data = await self._client.get_current_analytics()
|
data = await self._client.get_current_analytics()
|
||||||
custom_data = await self._client.get_custom_integrations()
|
custom_data = await self._client.get_custom_integrations()
|
||||||
except HomeassistantAnalyticsConnectionError as err:
|
except HomeassistantAnalyticsConnectionError as err:
|
||||||
|
@ -75,9 +70,6 @@ class HomeassistantAnalyticsDataUpdateCoordinator(DataUpdateCoordinator[Analytic
|
||||||
) from err
|
) from err
|
||||||
except HomeassistantAnalyticsNotModifiedError:
|
except HomeassistantAnalyticsNotModifiedError:
|
||||||
return self.data
|
return self.data
|
||||||
addons = {
|
|
||||||
addon: get_addon_value(addons_data, addon) for addon in self._tracked_addons
|
|
||||||
}
|
|
||||||
core_integrations = {
|
core_integrations = {
|
||||||
integration: data.integrations.get(integration, 0)
|
integration: data.integrations.get(integration, 0)
|
||||||
for integration in self._tracked_integrations
|
for integration in self._tracked_integrations
|
||||||
|
@ -89,19 +81,11 @@ class HomeassistantAnalyticsDataUpdateCoordinator(DataUpdateCoordinator[Analytic
|
||||||
return AnalyticsData(
|
return AnalyticsData(
|
||||||
data.active_installations,
|
data.active_installations,
|
||||||
data.reports_integrations,
|
data.reports_integrations,
|
||||||
addons,
|
|
||||||
core_integrations,
|
core_integrations,
|
||||||
custom_integrations,
|
custom_integrations,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_addon_value(data: dict[str, Addon], name_slug: str) -> int:
|
|
||||||
"""Get addon value."""
|
|
||||||
if name_slug in data:
|
|
||||||
return data[name_slug].total
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
def get_custom_integration_value(
|
def get_custom_integration_value(
|
||||||
data: dict[str, CustomIntegration], domain: str
|
data: dict[str, CustomIntegration], domain: str
|
||||||
) -> int:
|
) -> int:
|
||||||
|
|
|
@ -29,20 +29,6 @@ class AnalyticsSensorEntityDescription(SensorEntityDescription):
|
||||||
value_fn: Callable[[AnalyticsData], StateType]
|
value_fn: Callable[[AnalyticsData], StateType]
|
||||||
|
|
||||||
|
|
||||||
def get_addon_entity_description(
|
|
||||||
name_slug: str,
|
|
||||||
) -> AnalyticsSensorEntityDescription:
|
|
||||||
"""Get addon entity description."""
|
|
||||||
return AnalyticsSensorEntityDescription(
|
|
||||||
key=f"addon_{name_slug}_active_installations",
|
|
||||||
translation_key="addons",
|
|
||||||
name=name_slug,
|
|
||||||
state_class=SensorStateClass.TOTAL,
|
|
||||||
native_unit_of_measurement="active installations",
|
|
||||||
value_fn=lambda data: data.addons.get(name_slug),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def get_core_integration_entity_description(
|
def get_core_integration_entity_description(
|
||||||
domain: str, name: str
|
domain: str, name: str
|
||||||
) -> AnalyticsSensorEntityDescription:
|
) -> AnalyticsSensorEntityDescription:
|
||||||
|
@ -103,13 +89,6 @@ async def async_setup_entry(
|
||||||
analytics_data.coordinator
|
analytics_data.coordinator
|
||||||
)
|
)
|
||||||
entities: list[HomeassistantAnalyticsSensor] = []
|
entities: list[HomeassistantAnalyticsSensor] = []
|
||||||
entities.extend(
|
|
||||||
HomeassistantAnalyticsSensor(
|
|
||||||
coordinator,
|
|
||||||
get_addon_entity_description(addon_name_slug),
|
|
||||||
)
|
|
||||||
for addon_name_slug in coordinator.data.addons
|
|
||||||
)
|
|
||||||
entities.extend(
|
entities.extend(
|
||||||
HomeassistantAnalyticsSensor(
|
HomeassistantAnalyticsSensor(
|
||||||
coordinator,
|
coordinator,
|
||||||
|
|
|
@ -3,12 +3,10 @@
|
||||||
"step": {
|
"step": {
|
||||||
"user": {
|
"user": {
|
||||||
"data": {
|
"data": {
|
||||||
"tracked_addons": "Addons",
|
|
||||||
"tracked_integrations": "Integrations",
|
"tracked_integrations": "Integrations",
|
||||||
"tracked_custom_integrations": "Custom integrations"
|
"tracked_custom_integrations": "Custom integrations"
|
||||||
},
|
},
|
||||||
"data_description": {
|
"data_description": {
|
||||||
"tracked_addons": "Select the addons you want to track",
|
|
||||||
"tracked_integrations": "Select the integrations you want to track",
|
"tracked_integrations": "Select the integrations you want to track",
|
||||||
"tracked_custom_integrations": "Select the custom integrations you want to track"
|
"tracked_custom_integrations": "Select the custom integrations you want to track"
|
||||||
}
|
}
|
||||||
|
@ -26,12 +24,10 @@
|
||||||
"step": {
|
"step": {
|
||||||
"init": {
|
"init": {
|
||||||
"data": {
|
"data": {
|
||||||
"tracked_addons": "[%key:component::analytics_insights::config::step::user::data::tracked_addons%]",
|
|
||||||
"tracked_integrations": "[%key:component::analytics_insights::config::step::user::data::tracked_integrations%]",
|
"tracked_integrations": "[%key:component::analytics_insights::config::step::user::data::tracked_integrations%]",
|
||||||
"tracked_custom_integrations": "[%key:component::analytics_insights::config::step::user::data::tracked_custom_integrations%]"
|
"tracked_custom_integrations": "[%key:component::analytics_insights::config::step::user::data::tracked_custom_integrations%]"
|
||||||
},
|
},
|
||||||
"data_description": {
|
"data_description": {
|
||||||
"tracked_addons": "[%key:component::analytics_insights::config::step::user::data_description::tracked_addons%]",
|
|
||||||
"tracked_integrations": "[%key:component::analytics_insights::config::step::user::data_description::tracked_integrations%]",
|
"tracked_integrations": "[%key:component::analytics_insights::config::step::user::data_description::tracked_integrations%]",
|
||||||
"tracked_custom_integrations": "[%key:component::analytics_insights::config::step::user::data_description::tracked_custom_integrations%]"
|
"tracked_custom_integrations": "[%key:component::analytics_insights::config::step::user::data_description::tracked_custom_integrations%]"
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,7 +4,6 @@ from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
import logging
|
|
||||||
import os
|
import os
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
@ -41,7 +40,6 @@ from .const import (
|
||||||
CONF_ADB_SERVER_IP,
|
CONF_ADB_SERVER_IP,
|
||||||
CONF_ADB_SERVER_PORT,
|
CONF_ADB_SERVER_PORT,
|
||||||
CONF_ADBKEY,
|
CONF_ADBKEY,
|
||||||
CONF_SCREENCAP_INTERVAL,
|
|
||||||
CONF_STATE_DETECTION_RULES,
|
CONF_STATE_DETECTION_RULES,
|
||||||
DEFAULT_ADB_SERVER_PORT,
|
DEFAULT_ADB_SERVER_PORT,
|
||||||
DEVICE_ANDROIDTV,
|
DEVICE_ANDROIDTV,
|
||||||
|
@ -68,8 +66,6 @@ RELOAD_OPTIONS = [CONF_STATE_DETECTION_RULES]
|
||||||
|
|
||||||
_INVALID_MACS = {"ff:ff:ff:ff:ff:ff"}
|
_INVALID_MACS = {"ff:ff:ff:ff:ff:ff"}
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class AndroidTVRuntimeData:
|
class AndroidTVRuntimeData:
|
||||||
|
@ -161,32 +157,6 @@ async def async_connect_androidtv(
|
||||||
return aftv, None
|
return aftv, None
|
||||||
|
|
||||||
|
|
||||||
async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|
||||||
"""Migrate old entry."""
|
|
||||||
_LOGGER.debug(
|
|
||||||
"Migrating configuration from version %s.%s", entry.version, entry.minor_version
|
|
||||||
)
|
|
||||||
|
|
||||||
if entry.version == 1:
|
|
||||||
new_options = {**entry.options}
|
|
||||||
|
|
||||||
# Migrate MinorVersion 1 -> MinorVersion 2: New option
|
|
||||||
if entry.minor_version < 2:
|
|
||||||
new_options = {**new_options, CONF_SCREENCAP_INTERVAL: 0}
|
|
||||||
|
|
||||||
hass.config_entries.async_update_entry(
|
|
||||||
entry, options=new_options, minor_version=2, version=1
|
|
||||||
)
|
|
||||||
|
|
||||||
_LOGGER.debug(
|
|
||||||
"Migration to configuration version %s.%s successful",
|
|
||||||
entry.version,
|
|
||||||
entry.minor_version,
|
|
||||||
)
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(hass: HomeAssistant, entry: AndroidTVConfigEntry) -> bool:
|
async def async_setup_entry(hass: HomeAssistant, entry: AndroidTVConfigEntry) -> bool:
|
||||||
"""Set up Android Debug Bridge platform."""
|
"""Set up Android Debug Bridge platform."""
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
@ -34,7 +34,7 @@ from .const import (
|
||||||
CONF_APPS,
|
CONF_APPS,
|
||||||
CONF_EXCLUDE_UNNAMED_APPS,
|
CONF_EXCLUDE_UNNAMED_APPS,
|
||||||
CONF_GET_SOURCES,
|
CONF_GET_SOURCES,
|
||||||
CONF_SCREENCAP_INTERVAL,
|
CONF_SCREENCAP,
|
||||||
CONF_STATE_DETECTION_RULES,
|
CONF_STATE_DETECTION_RULES,
|
||||||
CONF_TURN_OFF_COMMAND,
|
CONF_TURN_OFF_COMMAND,
|
||||||
CONF_TURN_ON_COMMAND,
|
CONF_TURN_ON_COMMAND,
|
||||||
|
@ -43,7 +43,7 @@ from .const import (
|
||||||
DEFAULT_EXCLUDE_UNNAMED_APPS,
|
DEFAULT_EXCLUDE_UNNAMED_APPS,
|
||||||
DEFAULT_GET_SOURCES,
|
DEFAULT_GET_SOURCES,
|
||||||
DEFAULT_PORT,
|
DEFAULT_PORT,
|
||||||
DEFAULT_SCREENCAP_INTERVAL,
|
DEFAULT_SCREENCAP,
|
||||||
DEVICE_CLASSES,
|
DEVICE_CLASSES,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
PROP_ETHMAC,
|
PROP_ETHMAC,
|
||||||
|
@ -76,7 +76,6 @@ class AndroidTVFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||||
"""Handle a config flow."""
|
"""Handle a config flow."""
|
||||||
|
|
||||||
VERSION = 1
|
VERSION = 1
|
||||||
MINOR_VERSION = 2
|
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def _show_setup_form(
|
def _show_setup_form(
|
||||||
|
@ -186,14 +185,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 +236,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(
|
||||||
{
|
{
|
||||||
|
@ -252,12 +253,10 @@ class OptionsFlowHandler(OptionsFlow):
|
||||||
CONF_EXCLUDE_UNNAMED_APPS, DEFAULT_EXCLUDE_UNNAMED_APPS
|
CONF_EXCLUDE_UNNAMED_APPS, DEFAULT_EXCLUDE_UNNAMED_APPS
|
||||||
),
|
),
|
||||||
): bool,
|
): bool,
|
||||||
vol.Required(
|
vol.Optional(
|
||||||
CONF_SCREENCAP_INTERVAL,
|
CONF_SCREENCAP,
|
||||||
default=options.get(
|
default=options.get(CONF_SCREENCAP, DEFAULT_SCREENCAP),
|
||||||
CONF_SCREENCAP_INTERVAL, DEFAULT_SCREENCAP_INTERVAL
|
): bool,
|
||||||
),
|
|
||||||
): vol.All(vol.Coerce(int), vol.Clamp(min=0, max=15)),
|
|
||||||
vol.Optional(
|
vol.Optional(
|
||||||
CONF_TURN_OFF_COMMAND,
|
CONF_TURN_OFF_COMMAND,
|
||||||
description={
|
description={
|
||||||
|
|
|
@ -9,7 +9,6 @@ CONF_APPS = "apps"
|
||||||
CONF_EXCLUDE_UNNAMED_APPS = "exclude_unnamed_apps"
|
CONF_EXCLUDE_UNNAMED_APPS = "exclude_unnamed_apps"
|
||||||
CONF_GET_SOURCES = "get_sources"
|
CONF_GET_SOURCES = "get_sources"
|
||||||
CONF_SCREENCAP = "screencap"
|
CONF_SCREENCAP = "screencap"
|
||||||
CONF_SCREENCAP_INTERVAL = "screencap_interval"
|
|
||||||
CONF_STATE_DETECTION_RULES = "state_detection_rules"
|
CONF_STATE_DETECTION_RULES = "state_detection_rules"
|
||||||
CONF_TURN_OFF_COMMAND = "turn_off_command"
|
CONF_TURN_OFF_COMMAND = "turn_off_command"
|
||||||
CONF_TURN_ON_COMMAND = "turn_on_command"
|
CONF_TURN_ON_COMMAND = "turn_on_command"
|
||||||
|
@ -19,7 +18,7 @@ DEFAULT_DEVICE_CLASS = "auto"
|
||||||
DEFAULT_EXCLUDE_UNNAMED_APPS = False
|
DEFAULT_EXCLUDE_UNNAMED_APPS = False
|
||||||
DEFAULT_GET_SOURCES = True
|
DEFAULT_GET_SOURCES = True
|
||||||
DEFAULT_PORT = 5555
|
DEFAULT_PORT = 5555
|
||||||
DEFAULT_SCREENCAP_INTERVAL = 5
|
DEFAULT_SCREENCAP = True
|
||||||
|
|
||||||
DEVICE_ANDROIDTV = "androidtv"
|
DEVICE_ANDROIDTV = "androidtv"
|
||||||
DEVICE_FIRETV = "firetv"
|
DEVICE_FIRETV = "firetv"
|
||||||
|
|
|
@ -2,9 +2,10 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime, timedelta
|
from datetime import timedelta
|
||||||
import hashlib
|
import hashlib
|
||||||
import logging
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from androidtv.constants import APPS, KEYS
|
from androidtv.constants import APPS, KEYS
|
||||||
from androidtv.setup_async import AndroidTVAsync, FireTVAsync
|
from androidtv.setup_async import AndroidTVAsync, FireTVAsync
|
||||||
|
@ -22,19 +23,19 @@ from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers import config_validation as cv, entity_platform
|
from homeassistant.helpers import config_validation as cv, entity_platform
|
||||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
from homeassistant.util.dt import utcnow
|
from homeassistant.util import Throttle
|
||||||
|
|
||||||
from . import AndroidTVConfigEntry
|
from . import AndroidTVConfigEntry
|
||||||
from .const import (
|
from .const import (
|
||||||
CONF_APPS,
|
CONF_APPS,
|
||||||
CONF_EXCLUDE_UNNAMED_APPS,
|
CONF_EXCLUDE_UNNAMED_APPS,
|
||||||
CONF_GET_SOURCES,
|
CONF_GET_SOURCES,
|
||||||
CONF_SCREENCAP_INTERVAL,
|
CONF_SCREENCAP,
|
||||||
CONF_TURN_OFF_COMMAND,
|
CONF_TURN_OFF_COMMAND,
|
||||||
CONF_TURN_ON_COMMAND,
|
CONF_TURN_ON_COMMAND,
|
||||||
DEFAULT_EXCLUDE_UNNAMED_APPS,
|
DEFAULT_EXCLUDE_UNNAMED_APPS,
|
||||||
DEFAULT_GET_SOURCES,
|
DEFAULT_GET_SOURCES,
|
||||||
DEFAULT_SCREENCAP_INTERVAL,
|
DEFAULT_SCREENCAP,
|
||||||
DEVICE_ANDROIDTV,
|
DEVICE_ANDROIDTV,
|
||||||
SIGNAL_CONFIG_ENTITY,
|
SIGNAL_CONFIG_ENTITY,
|
||||||
)
|
)
|
||||||
|
@ -47,6 +48,8 @@ ATTR_DEVICE_PATH = "device_path"
|
||||||
ATTR_HDMI_INPUT = "hdmi_input"
|
ATTR_HDMI_INPUT = "hdmi_input"
|
||||||
ATTR_LOCAL_PATH = "local_path"
|
ATTR_LOCAL_PATH = "local_path"
|
||||||
|
|
||||||
|
MIN_TIME_BETWEEN_SCREENCAPS = timedelta(seconds=60)
|
||||||
|
|
||||||
SERVICE_ADB_COMMAND = "adb_command"
|
SERVICE_ADB_COMMAND = "adb_command"
|
||||||
SERVICE_DOWNLOAD = "download"
|
SERVICE_DOWNLOAD = "download"
|
||||||
SERVICE_LEARN_SENDEVENT = "learn_sendevent"
|
SERVICE_LEARN_SENDEVENT = "learn_sendevent"
|
||||||
|
@ -122,8 +125,7 @@ class ADBDevice(AndroidTVEntity, MediaPlayerEntity):
|
||||||
self._app_name_to_id: dict[str, str] = {}
|
self._app_name_to_id: dict[str, str] = {}
|
||||||
self._get_sources = DEFAULT_GET_SOURCES
|
self._get_sources = DEFAULT_GET_SOURCES
|
||||||
self._exclude_unnamed_apps = DEFAULT_EXCLUDE_UNNAMED_APPS
|
self._exclude_unnamed_apps = DEFAULT_EXCLUDE_UNNAMED_APPS
|
||||||
self._screencap_delta: timedelta | None = None
|
self._screencap = DEFAULT_SCREENCAP
|
||||||
self._last_screencap: datetime | None = None
|
|
||||||
self.turn_on_command: str | None = None
|
self.turn_on_command: str | None = None
|
||||||
self.turn_off_command: str | None = None
|
self.turn_off_command: str | None = None
|
||||||
|
|
||||||
|
@ -157,13 +159,7 @@ class ADBDevice(AndroidTVEntity, MediaPlayerEntity):
|
||||||
self._exclude_unnamed_apps = options.get(
|
self._exclude_unnamed_apps = options.get(
|
||||||
CONF_EXCLUDE_UNNAMED_APPS, DEFAULT_EXCLUDE_UNNAMED_APPS
|
CONF_EXCLUDE_UNNAMED_APPS, DEFAULT_EXCLUDE_UNNAMED_APPS
|
||||||
)
|
)
|
||||||
screencap_interval: int = options.get(
|
self._screencap = options.get(CONF_SCREENCAP, DEFAULT_SCREENCAP)
|
||||||
CONF_SCREENCAP_INTERVAL, DEFAULT_SCREENCAP_INTERVAL
|
|
||||||
)
|
|
||||||
if screencap_interval > 0:
|
|
||||||
self._screencap_delta = timedelta(minutes=screencap_interval)
|
|
||||||
else:
|
|
||||||
self._screencap_delta = None
|
|
||||||
self.turn_off_command = options.get(CONF_TURN_OFF_COMMAND)
|
self.turn_off_command = options.get(CONF_TURN_OFF_COMMAND)
|
||||||
self.turn_on_command = options.get(CONF_TURN_ON_COMMAND)
|
self.turn_on_command = options.get(CONF_TURN_ON_COMMAND)
|
||||||
|
|
||||||
|
@ -187,7 +183,7 @@ class ADBDevice(AndroidTVEntity, MediaPlayerEntity):
|
||||||
async def _async_get_screencap(self, prev_app_id: str | None = None) -> None:
|
async def _async_get_screencap(self, prev_app_id: str | None = None) -> None:
|
||||||
"""Take a screen capture from the device when enabled."""
|
"""Take a screen capture from the device when enabled."""
|
||||||
if (
|
if (
|
||||||
not self._screencap_delta
|
not self._screencap
|
||||||
or self.state in {MediaPlayerState.OFF, None}
|
or self.state in {MediaPlayerState.OFF, None}
|
||||||
or not self.available
|
or not self.available
|
||||||
):
|
):
|
||||||
|
@ -197,18 +193,11 @@ class ADBDevice(AndroidTVEntity, MediaPlayerEntity):
|
||||||
force: bool = prev_app_id is not None
|
force: bool = prev_app_id is not None
|
||||||
if force:
|
if force:
|
||||||
force = prev_app_id != self._attr_app_id
|
force = prev_app_id != self._attr_app_id
|
||||||
await self._adb_get_screencap(force)
|
await self._adb_get_screencap(no_throttle=force)
|
||||||
|
|
||||||
async def _adb_get_screencap(self, force: bool = False) -> None:
|
@Throttle(MIN_TIME_BETWEEN_SCREENCAPS)
|
||||||
"""Take a screen capture from the device every configured minutes."""
|
async def _adb_get_screencap(self, **kwargs: Any) -> None:
|
||||||
time_elapsed = self._screencap_delta is not None and (
|
"""Take a screen capture from the device every 60 seconds."""
|
||||||
self._last_screencap is None
|
|
||||||
or (utcnow() - self._last_screencap) >= self._screencap_delta
|
|
||||||
)
|
|
||||||
if not (force or time_elapsed):
|
|
||||||
return
|
|
||||||
|
|
||||||
self._last_screencap = utcnow()
|
|
||||||
if media_data := await self._adb_screencap():
|
if media_data := await self._adb_screencap():
|
||||||
self._media_image = media_data, "image/png"
|
self._media_image = media_data, "image/png"
|
||||||
self._attr_media_image_hash = hashlib.sha256(media_data).hexdigest()[:16]
|
self._attr_media_image_hash = hashlib.sha256(media_data).hexdigest()[:16]
|
||||||
|
|
|
@ -31,7 +31,7 @@
|
||||||
"apps": "Configure applications list",
|
"apps": "Configure applications list",
|
||||||
"get_sources": "Retrieve the running apps as the list of sources",
|
"get_sources": "Retrieve the running apps as the list of sources",
|
||||||
"exclude_unnamed_apps": "Exclude apps with unknown name from the sources list",
|
"exclude_unnamed_apps": "Exclude apps with unknown name from the sources list",
|
||||||
"screencap_interval": "Interval in minutes between screen capture for album art (set 0 to disable)",
|
"screencap": "Use screen capture for album art",
|
||||||
"state_detection_rules": "Configure state detection rules",
|
"state_detection_rules": "Configure state detection rules",
|
||||||
"turn_off_command": "ADB shell turn off command (leave empty for default)",
|
"turn_off_command": "ADB shell turn off command (leave empty for default)",
|
||||||
"turn_on_command": "ADB shell turn on command (leave empty for default)"
|
"turn_on_command": "ADB shell turn on command (leave empty for default)"
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -5,5 +5,5 @@
|
||||||
"config_flow": true,
|
"config_flow": true,
|
||||||
"documentation": "https://www.home-assistant.io/integrations/autarco",
|
"documentation": "https://www.home-assistant.io/integrations/autarco",
|
||||||
"iot_class": "cloud_polling",
|
"iot_class": "cloud_polling",
|
||||||
"requirements": ["autarco==3.1.0"]
|
"requirements": ["autarco==3.0.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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
"""The Backup integration."""
|
"""The Backup integration."""
|
||||||
|
|
||||||
|
from homeassistant.components.hassio import is_hassio
|
||||||
from homeassistant.core import HomeAssistant, ServiceCall
|
from homeassistant.core import HomeAssistant, ServiceCall
|
||||||
from homeassistant.helpers import config_validation as cv
|
from homeassistant.helpers import config_validation as cv
|
||||||
from homeassistant.helpers.hassio import is_hassio
|
|
||||||
from homeassistant.helpers.typing import ConfigType
|
from homeassistant.helpers.typing import ConfigType
|
||||||
|
|
||||||
from .const import DATA_MANAGER, DOMAIN, LOGGER
|
from .const import DATA_MANAGER, DOMAIN, LOGGER
|
||||||
|
@ -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)
|
||||||
|
|
||||||
|
|
|
@ -17,7 +17,6 @@ LOGGER = getLogger(__package__)
|
||||||
EXCLUDE_FROM_BACKUP = [
|
EXCLUDE_FROM_BACKUP = [
|
||||||
"__pycache__/*",
|
"__pycache__/*",
|
||||||
".DS_Store",
|
".DS_Store",
|
||||||
".HA_RESTORE",
|
|
||||||
"*.db-shm",
|
"*.db-shm",
|
||||||
"*.log.*",
|
"*.log.*",
|
||||||
"*.log",
|
"*.log",
|
||||||
|
|
|
@ -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,24 +4,18 @@ 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.const import __version__ as HAVERSION
|
from homeassistant.const import __version__ as HAVERSION
|
||||||
from homeassistant.core import HomeAssistant, callback
|
from homeassistant.core import HomeAssistant, callback
|
||||||
from homeassistant.exceptions import HomeAssistantError
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
|
@ -35,13 +29,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 +44,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 +60,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] = {}
|
||||||
|
@ -146,16 +124,7 @@ class BaseBackupManager(abc.ABC):
|
||||||
self.loaded_platforms = True
|
self.loaded_platforms = True
|
||||||
|
|
||||||
@abc.abstractmethod
|
@abc.abstractmethod
|
||||||
async def async_restore_backup(self, slug: str, **kwargs: Any) -> None:
|
async def async_create_backup(self, **kwargs: Any) -> Backup:
|
||||||
"""Restore a backup."""
|
|
||||||
|
|
||||||
@abc.abstractmethod
|
|
||||||
async def async_create_backup(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
on_progress: Callable[[BackupProgress], None] | None,
|
|
||||||
**kwargs: Any,
|
|
||||||
) -> NewBackup:
|
|
||||||
"""Generate a backup."""
|
"""Generate a backup."""
|
||||||
|
|
||||||
@abc.abstractmethod
|
@abc.abstractmethod
|
||||||
|
@ -173,15 +142,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 +217,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 +254,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(
|
||||||
|
@ -410,25 +291,6 @@ class BackupManager(BaseBackupManager):
|
||||||
|
|
||||||
return tar_file_path.stat().st_size
|
return tar_file_path.stat().st_size
|
||||||
|
|
||||||
async def async_restore_backup(self, slug: str, **kwargs: Any) -> None:
|
|
||||||
"""Restore a backup.
|
|
||||||
|
|
||||||
This will write the restore information to .HA_RESTORE which
|
|
||||||
will be handled during startup by the restore_backup module.
|
|
||||||
"""
|
|
||||||
if (backup := await self.async_get_backup(slug=slug)) is None:
|
|
||||||
raise HomeAssistantError(f"Backup {slug} not found")
|
|
||||||
|
|
||||||
def _write_restore_file() -> None:
|
|
||||||
"""Write the restore file."""
|
|
||||||
Path(self.hass.config.path(RESTORE_BACKUP_FILE)).write_text(
|
|
||||||
json.dumps({"path": backup.path.as_posix()}),
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
|
|
||||||
await self.hass.async_add_executor_job(_write_restore_file)
|
|
||||||
await self.hass.services.async_call("homeassistant", "restart", {})
|
|
||||||
|
|
||||||
|
|
||||||
def _generate_slug(date: str, name: str) -> str:
|
def _generate_slug(date: str, name: str) -> str:
|
||||||
"""Generate a backup slug."""
|
"""Generate a backup slug."""
|
||||||
|
|
|
@ -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
|
||||||
|
@ -23,7 +22,6 @@ def async_register_websocket_handlers(hass: HomeAssistant, with_hassio: bool) ->
|
||||||
websocket_api.async_register_command(hass, handle_info)
|
websocket_api.async_register_command(hass, handle_info)
|
||||||
websocket_api.async_register_command(hass, handle_create)
|
websocket_api.async_register_command(hass, handle_create)
|
||||||
websocket_api.async_register_command(hass, handle_remove)
|
websocket_api.async_register_command(hass, handle_remove)
|
||||||
websocket_api.async_register_command(hass, handle_restore)
|
|
||||||
|
|
||||||
|
|
||||||
@websocket_api.require_admin
|
@websocket_api.require_admin
|
||||||
|
@ -41,7 +39,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,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -87,24 +85,6 @@ async def handle_remove(
|
||||||
connection.send_result(msg["id"])
|
connection.send_result(msg["id"])
|
||||||
|
|
||||||
|
|
||||||
@websocket_api.require_admin
|
|
||||||
@websocket_api.websocket_command(
|
|
||||||
{
|
|
||||||
vol.Required("type"): "backup/restore",
|
|
||||||
vol.Required("slug"): str,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
@websocket_api.async_response
|
|
||||||
async def handle_restore(
|
|
||||||
hass: HomeAssistant,
|
|
||||||
connection: websocket_api.ActiveConnection,
|
|
||||||
msg: dict[str, Any],
|
|
||||||
) -> None:
|
|
||||||
"""Restore a backup."""
|
|
||||||
await hass.data[DATA_MANAGER].async_restore_backup(msg["slug"])
|
|
||||||
connection.send_result(msg["id"])
|
|
||||||
|
|
||||||
|
|
||||||
@websocket_api.require_admin
|
@websocket_api.require_admin
|
||||||
@websocket_api.websocket_command({vol.Required("type"): "backup/generate"})
|
@websocket_api.websocket_command({vol.Required("type"): "backup/generate"})
|
||||||
@websocket_api.async_response
|
@websocket_api.async_response
|
||||||
|
@ -114,11 +94,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 +108,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 +130,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"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -39,21 +39,16 @@ HOST_SCHEMA = vol.Schema(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def write_tls_asset(
|
def write_tls_asset(hass: HomeAssistant, filename: str, asset: bytes) -> None:
|
||||||
hass: HomeAssistant, folder: str, filename: str, asset: bytes
|
|
||||||
) -> None:
|
|
||||||
"""Write the tls assets to disk."""
|
"""Write the tls assets to disk."""
|
||||||
makedirs(hass.config.path(DOMAIN, folder), exist_ok=True)
|
makedirs(hass.config.path(DOMAIN), exist_ok=True)
|
||||||
with open(
|
with open(hass.config.path(DOMAIN, filename), "w", encoding="utf8") as file_handle:
|
||||||
hass.config.path(DOMAIN, folder, filename), "w", encoding="utf8"
|
|
||||||
) as file_handle:
|
|
||||||
file_handle.write(asset.decode("utf-8"))
|
file_handle.write(asset.decode("utf-8"))
|
||||||
|
|
||||||
|
|
||||||
def create_credentials_and_validate(
|
def create_credentials_and_validate(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
host: str,
|
host: str,
|
||||||
unique_id: str,
|
|
||||||
user_input: dict[str, Any],
|
user_input: dict[str, Any],
|
||||||
zeroconf_instance: zeroconf.HaZeroconf,
|
zeroconf_instance: zeroconf.HaZeroconf,
|
||||||
) -> dict[str, Any] | None:
|
) -> dict[str, Any] | None:
|
||||||
|
@ -62,15 +57,13 @@ def create_credentials_and_validate(
|
||||||
result = helper.register(host, "HomeAssistant")
|
result = helper.register(host, "HomeAssistant")
|
||||||
|
|
||||||
if result is not None:
|
if result is not None:
|
||||||
# Save key/certificate pair for each registered host separately
|
write_tls_asset(hass, CONF_SHC_CERT, result["cert"])
|
||||||
# otherwise only the last registered host is accessible.
|
write_tls_asset(hass, CONF_SHC_KEY, result["key"])
|
||||||
write_tls_asset(hass, unique_id, CONF_SHC_CERT, result["cert"])
|
|
||||||
write_tls_asset(hass, unique_id, CONF_SHC_KEY, result["key"])
|
|
||||||
|
|
||||||
session = SHCSession(
|
session = SHCSession(
|
||||||
host,
|
host,
|
||||||
hass.config.path(DOMAIN, unique_id, CONF_SHC_CERT),
|
hass.config.path(DOMAIN, CONF_SHC_CERT),
|
||||||
hass.config.path(DOMAIN, unique_id, CONF_SHC_KEY),
|
hass.config.path(DOMAIN, CONF_SHC_KEY),
|
||||||
True,
|
True,
|
||||||
zeroconf_instance,
|
zeroconf_instance,
|
||||||
)
|
)
|
||||||
|
@ -150,16 +143,11 @@ class BoschSHCConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||||
errors: dict[str, str] = {}
|
errors: dict[str, str] = {}
|
||||||
if user_input is not None:
|
if user_input is not None:
|
||||||
zeroconf_instance = await zeroconf.async_get_instance(self.hass)
|
zeroconf_instance = await zeroconf.async_get_instance(self.hass)
|
||||||
# unique_id uniquely identifies the registered controller and is used
|
|
||||||
# to save the key/certificate pair for each controller separately
|
|
||||||
unique_id = self.info["unique_id"]
|
|
||||||
assert unique_id
|
|
||||||
try:
|
try:
|
||||||
result = await self.hass.async_add_executor_job(
|
result = await self.hass.async_add_executor_job(
|
||||||
create_credentials_and_validate,
|
create_credentials_and_validate,
|
||||||
self.hass,
|
self.hass,
|
||||||
self.host,
|
self.host,
|
||||||
unique_id,
|
|
||||||
user_input,
|
user_input,
|
||||||
zeroconf_instance,
|
zeroconf_instance,
|
||||||
)
|
)
|
||||||
|
@ -179,18 +167,13 @@ class BoschSHCConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||||
else:
|
else:
|
||||||
assert result
|
assert result
|
||||||
entry_data = {
|
entry_data = {
|
||||||
# Each host has its own key/certificate pair
|
CONF_SSL_CERTIFICATE: self.hass.config.path(DOMAIN, CONF_SHC_CERT),
|
||||||
CONF_SSL_CERTIFICATE: self.hass.config.path(
|
CONF_SSL_KEY: self.hass.config.path(DOMAIN, CONF_SHC_KEY),
|
||||||
DOMAIN, unique_id, CONF_SHC_CERT
|
|
||||||
),
|
|
||||||
CONF_SSL_KEY: self.hass.config.path(
|
|
||||||
DOMAIN, unique_id, CONF_SHC_KEY
|
|
||||||
),
|
|
||||||
CONF_HOST: self.host,
|
CONF_HOST: self.host,
|
||||||
CONF_TOKEN: result["token"],
|
CONF_TOKEN: result["token"],
|
||||||
CONF_HOSTNAME: result["token"].split(":", 1)[1],
|
CONF_HOSTNAME: result["token"].split(":", 1)[1],
|
||||||
}
|
}
|
||||||
existing_entry = await self.async_set_unique_id(unique_id)
|
existing_entry = await self.async_set_unique_id(self.info["unique_id"])
|
||||||
if existing_entry:
|
if existing_entry:
|
||||||
return self.async_update_reload_and_abort(
|
return self.async_update_reload_and_abort(
|
||||||
existing_entry,
|
existing_entry,
|
||||||
|
|
|
@ -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==0.6.4"]
|
||||||
}
|
}
|
||||||
|
|
|
@ -364,7 +364,7 @@ SENSOR_DESCRIPTIONS = {
|
||||||
): SensorEntityDescription(
|
): SensorEntityDescription(
|
||||||
key=f"{BTHomeSensorDeviceClass.CONDUCTIVITY}_{Units.CONDUCTIVITY}",
|
key=f"{BTHomeSensorDeviceClass.CONDUCTIVITY}_{Units.CONDUCTIVITY}",
|
||||||
device_class=SensorDeviceClass.CONDUCTIVITY,
|
device_class=SensorDeviceClass.CONDUCTIVITY,
|
||||||
native_unit_of_measurement=UnitOfConductivity.MICROSIEMENS_PER_CM,
|
native_unit_of_measurement=UnitOfConductivity.MICROSIEMENS,
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -8,9 +8,6 @@
|
||||||
"dim": "mdi:brightness-6",
|
"dim": "mdi:brightness-6",
|
||||||
"off": "mdi:brightness-3"
|
"off": "mdi:brightness-3"
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"audio_output": {
|
|
||||||
"default": "mdi:audio-input-stereo-minijack"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"switch": {
|
"switch": {
|
||||||
|
|
|
@ -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."]
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
"""Support for Cambridge Audio select entities."""
|
"""Support for Cambridge Audio select entities."""
|
||||||
|
|
||||||
from collections.abc import Awaitable, Callable
|
from collections.abc import Awaitable, Callable
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from aiostreammagic import StreamMagicClient
|
from aiostreammagic import StreamMagicClient
|
||||||
from aiostreammagic.models import DisplayBrightness
|
from aiostreammagic.models import DisplayBrightness
|
||||||
|
@ -19,61 +19,21 @@ from .entity import CambridgeAudioEntity
|
||||||
class CambridgeAudioSelectEntityDescription(SelectEntityDescription):
|
class CambridgeAudioSelectEntityDescription(SelectEntityDescription):
|
||||||
"""Describes Cambridge Audio select entity."""
|
"""Describes Cambridge Audio select entity."""
|
||||||
|
|
||||||
options_fn: Callable[[StreamMagicClient], list[str]] = field(default=lambda _: [])
|
|
||||||
load_fn: Callable[[StreamMagicClient], bool] = field(default=lambda _: True)
|
|
||||||
value_fn: Callable[[StreamMagicClient], str | None]
|
value_fn: Callable[[StreamMagicClient], str | None]
|
||||||
set_value_fn: Callable[[StreamMagicClient, str], Awaitable[None]]
|
set_value_fn: Callable[[StreamMagicClient, str], Awaitable[None]]
|
||||||
|
|
||||||
|
|
||||||
async def _audio_output_set_value_fn(client: StreamMagicClient, value: str) -> None:
|
|
||||||
"""Set the audio output using the display name."""
|
|
||||||
audio_output_id = next(
|
|
||||||
(output.id for output in client.audio_output.outputs if value == output.name),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
assert audio_output_id is not None
|
|
||||||
await client.set_audio_output(audio_output_id)
|
|
||||||
|
|
||||||
|
|
||||||
def _audio_output_value_fn(client: StreamMagicClient) -> str | None:
|
|
||||||
"""Convert the current audio output id to name."""
|
|
||||||
return next(
|
|
||||||
(
|
|
||||||
output.name
|
|
||||||
for output in client.audio_output.outputs
|
|
||||||
if client.state.audio_output == output.id
|
|
||||||
),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
CONTROL_ENTITIES: tuple[CambridgeAudioSelectEntityDescription, ...] = (
|
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)
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
CambridgeAudioSelectEntityDescription(
|
|
||||||
key="audio_output",
|
|
||||||
translation_key="audio_output",
|
|
||||||
entity_category=EntityCategory.CONFIG,
|
|
||||||
options_fn=lambda client: [
|
|
||||||
output.name for output in client.audio_output.outputs
|
|
||||||
],
|
|
||||||
load_fn=lambda client: len(client.audio_output.outputs) > 0,
|
|
||||||
value_fn=_audio_output_value_fn,
|
|
||||||
set_value_fn=_audio_output_set_value_fn,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -86,9 +46,7 @@ async def async_setup_entry(
|
||||||
|
|
||||||
client: StreamMagicClient = entry.runtime_data
|
client: StreamMagicClient = entry.runtime_data
|
||||||
entities: list[CambridgeAudioSelect] = [
|
entities: list[CambridgeAudioSelect] = [
|
||||||
CambridgeAudioSelect(client, description)
|
CambridgeAudioSelect(client, description) for description in CONTROL_ENTITIES
|
||||||
for description in CONTROL_ENTITIES
|
|
||||||
if description.load_fn(client)
|
|
||||||
]
|
]
|
||||||
async_add_entities(entities)
|
async_add_entities(entities)
|
||||||
|
|
||||||
|
@ -107,9 +65,6 @@ class CambridgeAudioSelect(CambridgeAudioEntity, SelectEntity):
|
||||||
super().__init__(client)
|
super().__init__(client)
|
||||||
self.entity_description = description
|
self.entity_description = description
|
||||||
self._attr_unique_id = f"{client.info.unit_id}-{description.key}"
|
self._attr_unique_id = f"{client.info.unit_id}-{description.key}"
|
||||||
options_fn = description.options_fn(client)
|
|
||||||
if options_fn:
|
|
||||||
self._attr_options = options_fn
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def current_option(self) -> str | None:
|
def current_option(self) -> str | None:
|
||||||
|
|
|
@ -32,9 +32,6 @@
|
||||||
"dim": "Dim",
|
"dim": "Dim",
|
||||||
"off": "[%key:common::state::off%]"
|
"off": "[%key:common::state::off%]"
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"audio_output": {
|
|
||||||
"name": "Audio output"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"switch": {
|
"switch": {
|
||||||
|
|
|
@ -6,7 +6,7 @@ import asyncio
|
||||||
import collections
|
import collections
|
||||||
from collections.abc import Awaitable, Callable, Coroutine
|
from collections.abc import Awaitable, Callable, Coroutine
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from dataclasses import asdict, dataclass
|
from dataclasses import asdict
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from enum import IntFlag
|
from enum import IntFlag
|
||||||
from functools import partial
|
from functools import partial
|
||||||
|
@ -18,9 +18,9 @@ from typing import Any, Final, final
|
||||||
|
|
||||||
from aiohttp import hdrs, web
|
from aiohttp import hdrs, web
|
||||||
import attr
|
import attr
|
||||||
from propcache import cached_property, under_cached_property
|
from propcache import 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
|
||||||
|
@ -177,13 +177,6 @@ class Image:
|
||||||
content: bytes = attr.ib()
|
content: bytes = attr.ib()
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class CameraCapabilities:
|
|
||||||
"""Camera capabilities."""
|
|
||||||
|
|
||||||
frontend_stream_types: set[StreamType]
|
|
||||||
|
|
||||||
|
|
||||||
@bind_hass
|
@bind_hass
|
||||||
async def async_request_stream(hass: HomeAssistant, entity_id: str, fmt: str) -> str:
|
async def async_request_stream(hass: HomeAssistant, entity_id: str, fmt: str) -> str:
|
||||||
"""Request a stream for a camera entity."""
|
"""Request a stream for a camera entity."""
|
||||||
|
@ -359,7 +352,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||||
websocket_api.async_register_command(hass, ws_camera_stream)
|
websocket_api.async_register_command(hass, ws_camera_stream)
|
||||||
websocket_api.async_register_command(hass, websocket_get_prefs)
|
websocket_api.async_register_command(hass, websocket_get_prefs)
|
||||||
websocket_api.async_register_command(hass, websocket_update_prefs)
|
websocket_api.async_register_command(hass, websocket_update_prefs)
|
||||||
websocket_api.async_register_command(hass, ws_camera_capabilities)
|
|
||||||
async_register_ws(hass)
|
async_register_ws(hass)
|
||||||
|
|
||||||
await component.async_setup(config)
|
await component.async_setup(config)
|
||||||
|
@ -420,14 +412,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||||
def get_ice_servers() -> list[RTCIceServer]:
|
def get_ice_servers() -> list[RTCIceServer]:
|
||||||
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(urls="stun:stun.home-assistant.io:80")]
|
||||||
RTCIceServer(
|
|
||||||
urls=[
|
|
||||||
"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)
|
||||||
return True
|
return True
|
||||||
|
@ -476,11 +461,8 @@ 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.stream: Stream | None = None
|
self.stream: Stream | None = None
|
||||||
self.stream_options: dict[str, str | bool | float] = {}
|
self.stream_options: dict[str, str | bool | float] = {}
|
||||||
self.content_type: str = DEFAULT_CONTENT_TYPE
|
self.content_type: str = DEFAULT_CONTENT_TYPE
|
||||||
|
@ -490,13 +472,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 +611,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 +767,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 +776,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(
|
||||||
|
@ -823,7 +791,6 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||||
if old_provider != new_provider or old_legacy_provider != new_legacy_provider:
|
if old_provider != new_provider or old_legacy_provider != new_legacy_provider:
|
||||||
self._webrtc_provider = new_provider
|
self._webrtc_provider = new_provider
|
||||||
self._legacy_webrtc_provider = new_legacy_provider
|
self._legacy_webrtc_provider = new_legacy_provider
|
||||||
self._invalidate_camera_capabilities_cache()
|
|
||||||
if write_state:
|
if write_state:
|
||||||
self.async_write_ha_state()
|
self.async_write_ha_state()
|
||||||
|
|
||||||
|
@ -847,9 +814,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 +822,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)
|
||||||
|
@ -879,43 +840,6 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||||
if self._webrtc_provider:
|
if self._webrtc_provider:
|
||||||
self._webrtc_provider.async_close_session(session_id)
|
self._webrtc_provider.async_close_session(session_id)
|
||||||
|
|
||||||
@callback
|
|
||||||
def _invalidate_camera_capabilities_cache(self) -> None:
|
|
||||||
"""Invalidate the camera capabilities cache."""
|
|
||||||
self._cache.pop("camera_capabilities", None)
|
|
||||||
|
|
||||||
@final
|
|
||||||
@under_cached_property
|
|
||||||
def camera_capabilities(self) -> CameraCapabilities:
|
|
||||||
"""Return the camera capabilities."""
|
|
||||||
frontend_stream_types = set()
|
|
||||||
if CameraEntityFeature.STREAM in self.supported_features_compat:
|
|
||||||
if self._supports_native_sync_webrtc or self._supports_native_async_webrtc:
|
|
||||||
# The camera has a native WebRTC implementation
|
|
||||||
frontend_stream_types.add(StreamType.WEB_RTC)
|
|
||||||
else:
|
|
||||||
frontend_stream_types.add(StreamType.HLS)
|
|
||||||
|
|
||||||
if self._webrtc_provider:
|
|
||||||
frontend_stream_types.add(StreamType.WEB_RTC)
|
|
||||||
|
|
||||||
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."""
|
||||||
|
@ -1006,24 +930,6 @@ class CameraMjpegStream(CameraView):
|
||||||
raise web.HTTPBadRequest from err
|
raise web.HTTPBadRequest from err
|
||||||
|
|
||||||
|
|
||||||
@websocket_api.websocket_command(
|
|
||||||
{
|
|
||||||
vol.Required("type"): "camera/capabilities",
|
|
||||||
vol.Required("entity_id"): cv.entity_id,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
@websocket_api.async_response
|
|
||||||
async def ws_camera_capabilities(
|
|
||||||
hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any]
|
|
||||||
) -> None:
|
|
||||||
"""Handle get camera capabilities websocket command.
|
|
||||||
|
|
||||||
Async friendly.
|
|
||||||
"""
|
|
||||||
camera = get_camera_from_entity_id(hass, msg["entity_id"])
|
|
||||||
connection.send_result(msg["id"], asdict(camera.camera_capabilities))
|
|
||||||
|
|
||||||
|
|
||||||
@websocket_api.websocket_command(
|
@websocket_api.websocket_command(
|
||||||
{
|
{
|
||||||
vol.Required("type"): "camera/stream",
|
vol.Required("type"): "camera/stream",
|
||||||
|
|
|
@ -46,10 +46,6 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"legacy_webrtc_provider": {
|
|
||||||
"title": "Detected use of legacy WebRTC provider registered by {legacy_integration}",
|
|
||||||
"description": "The {legacy_integration} integration has registered a legacy WebRTC provider. Home Assistant prefers using the built-in modern WebRTC provider registered by the {builtin_integration} integration.\n\nBenefits of the built-in integration are:\n\n- The camera stream is started faster.\n- More camera devices are supported.\n\nTo fix this issue, you can either keep using the built-in modern WebRTC provider and remove the {legacy_integration} integration or remove the {builtin_integration} integration to use the legacy provider, and then restart Home Assistant."
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"services": {
|
"services": {
|
||||||
|
|
|
@ -2,21 +2,20 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
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
|
||||||
from homeassistant.exceptions import HomeAssistantError
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
from homeassistant.helpers import config_validation as cv, issue_registry as ir
|
from homeassistant.helpers import config_validation as cv
|
||||||
from homeassistant.util.hass_dict import HassKey
|
from homeassistant.util.hass_dict import HassKey
|
||||||
from homeassistant.util.ulid import ulid
|
from homeassistant.util.ulid import ulid
|
||||||
|
|
||||||
|
@ -32,7 +31,7 @@ _LOGGER = logging.getLogger(__name__)
|
||||||
DATA_WEBRTC_PROVIDERS: HassKey[set[CameraWebRTCProvider]] = HassKey(
|
DATA_WEBRTC_PROVIDERS: HassKey[set[CameraWebRTCProvider]] = HassKey(
|
||||||
"camera_webrtc_providers"
|
"camera_webrtc_providers"
|
||||||
)
|
)
|
||||||
DATA_WEBRTC_LEGACY_PROVIDERS: HassKey[dict[str, CameraWebRTCLegacyProvider]] = HassKey(
|
DATA_WEBRTC_LEGACY_PROVIDERS: HassKey[set[CameraWebRTCLegacyProvider]] = HassKey(
|
||||||
"camera_webrtc_legacy_providers"
|
"camera_webrtc_legacy_providers"
|
||||||
)
|
)
|
||||||
DATA_ICE_SERVERS: HassKey[list[Callable[[], Iterable[RTCIceServer]]]] = HassKey(
|
DATA_ICE_SERVERS: HassKey[list[Callable[[], Iterable[RTCIceServer]]]] = HassKey(
|
||||||
|
@ -78,14 +77,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)
|
||||||
|
@ -121,20 +113,13 @@ class WebRTCClientConfiguration:
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
class CameraWebRTCProvider(ABC):
|
class CameraWebRTCProvider(Protocol):
|
||||||
"""WebRTC provider."""
|
"""WebRTC provider."""
|
||||||
|
|
||||||
@property
|
|
||||||
@abstractmethod
|
|
||||||
def domain(self) -> str:
|
|
||||||
"""Return the integration domain of the provider."""
|
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
@abstractmethod
|
|
||||||
def async_is_supported(self, stream_source: str) -> bool:
|
def async_is_supported(self, stream_source: str) -> bool:
|
||||||
"""Determine if the provider supports the stream source."""
|
"""Determine if the provider supports the stream source."""
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
async def async_handle_async_webrtc_offer(
|
async def async_handle_async_webrtc_offer(
|
||||||
self,
|
self,
|
||||||
camera: Camera,
|
camera: Camera,
|
||||||
|
@ -144,16 +129,12 @@ class CameraWebRTCProvider(ABC):
|
||||||
) -> None:
|
) -> None:
|
||||||
"""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
|
async def async_on_webrtc_candidate(self, session_id: str, candidate: str) -> None:
|
||||||
async def async_on_webrtc_candidate(
|
|
||||||
self, session_id: str, candidate: RTCIceCandidate
|
|
||||||
) -> None:
|
|
||||||
"""Handle the WebRTC candidate."""
|
"""Handle the WebRTC candidate."""
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def async_close_session(self, session_id: str) -> None:
|
def async_close_session(self, session_id: str) -> None:
|
||||||
"""Close the session."""
|
"""Close the session."""
|
||||||
return ## This is an optional method so we need a default here.
|
|
||||||
|
|
||||||
|
|
||||||
class CameraWebRTCLegacyProvider(Protocol):
|
class CameraWebRTCLegacyProvider(Protocol):
|
||||||
|
@ -168,10 +149,10 @@ class CameraWebRTCLegacyProvider(Protocol):
|
||||||
"""Handle the WebRTC offer and return an answer."""
|
"""Handle the WebRTC offer and return an answer."""
|
||||||
|
|
||||||
|
|
||||||
@callback
|
def _async_register_webrtc_provider[_T](
|
||||||
def async_register_webrtc_provider(
|
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
provider: CameraWebRTCProvider,
|
key: HassKey[set[_T]],
|
||||||
|
provider: _T,
|
||||||
) -> Callable[[], None]:
|
) -> Callable[[], None]:
|
||||||
"""Register a WebRTC provider.
|
"""Register a WebRTC provider.
|
||||||
|
|
||||||
|
@ -180,7 +161,7 @@ def async_register_webrtc_provider(
|
||||||
if DOMAIN not in hass.data:
|
if DOMAIN not in hass.data:
|
||||||
raise ValueError("Unexpected state, camera not loaded")
|
raise ValueError("Unexpected state, camera not loaded")
|
||||||
|
|
||||||
providers = hass.data.setdefault(DATA_WEBRTC_PROVIDERS, set())
|
providers = hass.data.setdefault(key, set())
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def remove_provider() -> None:
|
def remove_provider() -> None:
|
||||||
|
@ -195,9 +176,20 @@ def async_register_webrtc_provider(
|
||||||
return remove_provider
|
return remove_provider
|
||||||
|
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def async_register_webrtc_provider(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
provider: CameraWebRTCProvider,
|
||||||
|
) -> Callable[[], None]:
|
||||||
|
"""Register a WebRTC provider.
|
||||||
|
|
||||||
|
The first provider to satisfy the offer will be used.
|
||||||
|
"""
|
||||||
|
return _async_register_webrtc_provider(hass, DATA_WEBRTC_PROVIDERS, provider)
|
||||||
|
|
||||||
|
|
||||||
async def _async_refresh_providers(hass: HomeAssistant) -> None:
|
async def _async_refresh_providers(hass: HomeAssistant) -> None:
|
||||||
"""Check all cameras for any state changes for registered providers."""
|
"""Check all cameras for any state changes for registered providers."""
|
||||||
_async_check_conflicting_legacy_provider(hass)
|
|
||||||
|
|
||||||
component = hass.data[DATA_COMPONENT]
|
component = hass.data[DATA_COMPONENT]
|
||||||
await asyncio.gather(
|
await asyncio.gather(
|
||||||
|
@ -205,49 +197,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 +205,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 +218,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 +270,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 +303,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"]))
|
||||||
|
|
||||||
|
|
||||||
|
@ -350,11 +333,11 @@ def async_register_ws(hass: HomeAssistant) -> None:
|
||||||
websocket_api.async_register_command(hass, ws_candidate)
|
websocket_api.async_register_command(hass, ws_candidate)
|
||||||
|
|
||||||
|
|
||||||
async def async_get_supported_provider(
|
async def _async_get_supported_provider[
|
||||||
hass: HomeAssistant, camera: Camera
|
_T: CameraWebRTCLegacyProvider | CameraWebRTCProvider
|
||||||
) -> CameraWebRTCProvider | None:
|
](hass: HomeAssistant, camera: Camera, key: HassKey[set[_T]]) -> _T | None:
|
||||||
"""Return the first supported provider for the camera."""
|
"""Return the first supported provider for the camera."""
|
||||||
providers = hass.data.get(DATA_WEBRTC_PROVIDERS)
|
providers = hass.data.get(key)
|
||||||
if not providers or not (stream_source := await camera.stream_source()):
|
if not providers or not (stream_source := await camera.stream_source()):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
@ -365,19 +348,20 @@ async def async_get_supported_provider(
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def async_get_supported_provider(
|
||||||
|
hass: HomeAssistant, camera: Camera
|
||||||
|
) -> CameraWebRTCProvider | None:
|
||||||
|
"""Return the first supported provider for the camera."""
|
||||||
|
return await _async_get_supported_provider(hass, camera, DATA_WEBRTC_PROVIDERS)
|
||||||
|
|
||||||
|
|
||||||
async def async_get_supported_legacy_provider(
|
async def async_get_supported_legacy_provider(
|
||||||
hass: HomeAssistant, camera: Camera
|
hass: HomeAssistant, camera: Camera
|
||||||
) -> CameraWebRTCLegacyProvider | None:
|
) -> CameraWebRTCLegacyProvider | None:
|
||||||
"""Return the first supported provider for the camera."""
|
"""Return the first supported provider for the camera."""
|
||||||
providers = hass.data.get(DATA_WEBRTC_LEGACY_PROVIDERS)
|
return await _async_get_supported_provider(
|
||||||
if not providers or not (stream_source := await camera.stream_source()):
|
hass, camera, DATA_WEBRTC_LEGACY_PROVIDERS
|
||||||
return None
|
)
|
||||||
|
|
||||||
for provider in providers.values():
|
|
||||||
if await provider.async_is_supported(stream_source):
|
|
||||||
return provider
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
|
@ -440,49 +424,7 @@ def async_register_rtsp_to_web_rtc_provider(
|
||||||
|
|
||||||
The first provider to satisfy the offer will be used.
|
The first provider to satisfy the offer will be used.
|
||||||
"""
|
"""
|
||||||
if DOMAIN not in hass.data:
|
|
||||||
raise ValueError("Unexpected state, camera not loaded")
|
|
||||||
|
|
||||||
legacy_providers = hass.data.setdefault(DATA_WEBRTC_LEGACY_PROVIDERS, {})
|
|
||||||
|
|
||||||
if domain in legacy_providers:
|
|
||||||
raise ValueError("Provider already registered")
|
|
||||||
|
|
||||||
provider_instance = _CameraRtspToWebRTCProvider(provider)
|
provider_instance = _CameraRtspToWebRTCProvider(provider)
|
||||||
|
return _async_register_webrtc_provider(
|
||||||
@callback
|
hass, DATA_WEBRTC_LEGACY_PROVIDERS, provider_instance
|
||||||
def remove_provider() -> None:
|
|
||||||
legacy_providers.pop(domain)
|
|
||||||
hass.async_create_task(_async_refresh_providers(hass))
|
|
||||||
|
|
||||||
legacy_providers[domain] = provider_instance
|
|
||||||
hass.async_create_task(_async_refresh_providers(hass))
|
|
||||||
|
|
||||||
return remove_provider
|
|
||||||
|
|
||||||
|
|
||||||
@callback
|
|
||||||
def _async_check_conflicting_legacy_provider(hass: HomeAssistant) -> None:
|
|
||||||
"""Check if a legacy provider is registered together with the builtin provider."""
|
|
||||||
builtin_provider_domain = "go2rtc"
|
|
||||||
if (
|
|
||||||
(legacy_providers := hass.data.get(DATA_WEBRTC_LEGACY_PROVIDERS))
|
|
||||||
and (providers := hass.data.get(DATA_WEBRTC_PROVIDERS))
|
|
||||||
and any(provider.domain == builtin_provider_domain for provider in providers)
|
|
||||||
):
|
|
||||||
for domain in legacy_providers:
|
|
||||||
ir.async_create_issue(
|
|
||||||
hass,
|
|
||||||
DOMAIN,
|
|
||||||
f"legacy_webrtc_provider_{domain}",
|
|
||||||
is_fixable=False,
|
|
||||||
is_persistent=False,
|
|
||||||
issue_domain=domain,
|
|
||||||
learn_more_url="https://www.home-assistant.io/integrations/go2rtc/",
|
|
||||||
severity=ir.IssueSeverity.WARNING,
|
|
||||||
translation_key="legacy_webrtc_provider",
|
|
||||||
translation_placeholders={
|
|
||||||
"legacy_integration": domain,
|
|
||||||
"builtin_integration": builtin_provider_domain,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
|
@ -52,7 +52,7 @@ class CanaryConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||||
@callback
|
@callback
|
||||||
def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow:
|
def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow:
|
||||||
"""Get the options flow for this handler."""
|
"""Get the options flow for this handler."""
|
||||||
return CanaryOptionsFlowHandler()
|
return CanaryOptionsFlowHandler(config_entry)
|
||||||
|
|
||||||
async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult:
|
async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult:
|
||||||
"""Handle a flow initiated by configuration file."""
|
"""Handle a flow initiated by configuration file."""
|
||||||
|
@ -104,6 +104,10 @@ class CanaryConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||||
class CanaryOptionsFlowHandler(OptionsFlow):
|
class CanaryOptionsFlowHandler(OptionsFlow):
|
||||||
"""Handle Canary client options."""
|
"""Handle Canary client options."""
|
||||||
|
|
||||||
|
def __init__(self, config_entry: ConfigEntry) -> None:
|
||||||
|
"""Initialize options flow."""
|
||||||
|
self.config_entry = config_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:
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -3,7 +3,6 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
from collections.abc import Callable
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
import logging
|
import logging
|
||||||
|
@ -12,14 +11,12 @@ from typing import Any, Literal
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
from hass_nabucasa.client import CloudClient as Interface, RemoteActivationNotAllowed
|
from hass_nabucasa.client import CloudClient as Interface, RemoteActivationNotAllowed
|
||||||
from webrtc_models import RTCIceServer
|
|
||||||
|
|
||||||
from homeassistant.components import google_assistant, persistent_notification, webhook
|
from homeassistant.components import google_assistant, persistent_notification, webhook
|
||||||
from homeassistant.components.alexa import (
|
from homeassistant.components.alexa import (
|
||||||
errors as alexa_errors,
|
errors as alexa_errors,
|
||||||
smart_home as alexa_smart_home,
|
smart_home as alexa_smart_home,
|
||||||
)
|
)
|
||||||
from homeassistant.components.camera.webrtc import async_register_ice_servers
|
|
||||||
from homeassistant.components.google_assistant import smart_home as ga
|
from homeassistant.components.google_assistant import smart_home as ga
|
||||||
from homeassistant.const import __version__ as HA_VERSION
|
from homeassistant.const import __version__ as HA_VERSION
|
||||||
from homeassistant.core import Context, HassJob, HomeAssistant, callback
|
from homeassistant.core import Context, HassJob, HomeAssistant, callback
|
||||||
|
@ -30,7 +27,7 @@ from homeassistant.helpers.issue_registry import IssueSeverity, async_create_iss
|
||||||
from homeassistant.util.aiohttp import MockRequest, serialize_response
|
from homeassistant.util.aiohttp import MockRequest, serialize_response
|
||||||
|
|
||||||
from . import alexa_config, google_config
|
from . import alexa_config, google_config
|
||||||
from .const import DISPATCHER_REMOTE_UPDATE, DOMAIN, PREF_ENABLE_CLOUD_ICE_SERVERS
|
from .const import DISPATCHER_REMOTE_UPDATE, DOMAIN
|
||||||
from .prefs import CloudPreferences
|
from .prefs import CloudPreferences
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
@ -63,7 +60,6 @@ class CloudClient(Interface):
|
||||||
self._alexa_config_init_lock = asyncio.Lock()
|
self._alexa_config_init_lock = asyncio.Lock()
|
||||||
self._google_config_init_lock = asyncio.Lock()
|
self._google_config_init_lock = asyncio.Lock()
|
||||||
self._relayer_region: str | None = None
|
self._relayer_region: str | None = None
|
||||||
self._cloud_ice_servers_listener: Callable[[], None] | None = None
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def base_path(self) -> Path:
|
def base_path(self) -> Path:
|
||||||
|
@ -191,49 +187,6 @@ class CloudClient(Interface):
|
||||||
if is_new_user:
|
if is_new_user:
|
||||||
await gconf.async_sync_entities(gconf.agent_user_id)
|
await gconf.async_sync_entities(gconf.agent_user_id)
|
||||||
|
|
||||||
async def setup_cloud_ice_servers(_: datetime) -> None:
|
|
||||||
async def register_cloud_ice_server(
|
|
||||||
ice_servers: list[RTCIceServer],
|
|
||||||
) -> Callable[[], None]:
|
|
||||||
"""Register cloud ice server."""
|
|
||||||
|
|
||||||
def get_ice_servers() -> list[RTCIceServer]:
|
|
||||||
return ice_servers
|
|
||||||
|
|
||||||
return async_register_ice_servers(self._hass, get_ice_servers)
|
|
||||||
|
|
||||||
async def async_register_cloud_ice_servers_listener(
|
|
||||||
prefs: CloudPreferences,
|
|
||||||
) -> None:
|
|
||||||
is_cloud_ice_servers_enabled = (
|
|
||||||
self.cloud.is_logged_in
|
|
||||||
and not self.cloud.subscription_expired
|
|
||||||
and prefs.cloud_ice_servers_enabled
|
|
||||||
)
|
|
||||||
if is_cloud_ice_servers_enabled:
|
|
||||||
if self._cloud_ice_servers_listener is None:
|
|
||||||
self._cloud_ice_servers_listener = await self.cloud.ice_servers.async_register_ice_servers_listener(
|
|
||||||
register_cloud_ice_server
|
|
||||||
)
|
|
||||||
elif self._cloud_ice_servers_listener:
|
|
||||||
self._cloud_ice_servers_listener()
|
|
||||||
self._cloud_ice_servers_listener = None
|
|
||||||
|
|
||||||
async def async_prefs_updated(prefs: CloudPreferences) -> None:
|
|
||||||
updated_prefs = prefs.last_updated
|
|
||||||
|
|
||||||
if (
|
|
||||||
updated_prefs is None
|
|
||||||
or PREF_ENABLE_CLOUD_ICE_SERVERS not in updated_prefs
|
|
||||||
):
|
|
||||||
return
|
|
||||||
|
|
||||||
await async_register_cloud_ice_servers_listener(prefs)
|
|
||||||
|
|
||||||
await async_register_cloud_ice_servers_listener(self._prefs)
|
|
||||||
|
|
||||||
self._prefs.async_listen_updates(async_prefs_updated)
|
|
||||||
|
|
||||||
tasks = []
|
tasks = []
|
||||||
|
|
||||||
if self._prefs.alexa_enabled and self._prefs.alexa_report_state:
|
if self._prefs.alexa_enabled and self._prefs.alexa_report_state:
|
||||||
|
@ -242,8 +195,6 @@ class CloudClient(Interface):
|
||||||
if self._prefs.google_enabled:
|
if self._prefs.google_enabled:
|
||||||
tasks.append(enable_google)
|
tasks.append(enable_google)
|
||||||
|
|
||||||
tasks.append(setup_cloud_ice_servers)
|
|
||||||
|
|
||||||
if tasks:
|
if tasks:
|
||||||
await asyncio.gather(*(task(None) for task in tasks))
|
await asyncio.gather(*(task(None) for task in tasks))
|
||||||
|
|
||||||
|
@ -271,10 +222,6 @@ class CloudClient(Interface):
|
||||||
self._google_config.async_deinitialize()
|
self._google_config.async_deinitialize()
|
||||||
self._google_config = None
|
self._google_config = None
|
||||||
|
|
||||||
if self._cloud_ice_servers_listener:
|
|
||||||
self._cloud_ice_servers_listener()
|
|
||||||
self._cloud_ice_servers_listener = None
|
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def user_message(self, identifier: str, title: str, message: str) -> None:
|
def user_message(self, identifier: str, title: str, message: str) -> None:
|
||||||
"""Create a message for user to UI."""
|
"""Create a message for user to UI."""
|
||||||
|
|
|
@ -43,7 +43,6 @@ PREF_GOOGLE_SETTINGS_VERSION = "google_settings_version"
|
||||||
PREF_TTS_DEFAULT_VOICE = "tts_default_voice"
|
PREF_TTS_DEFAULT_VOICE = "tts_default_voice"
|
||||||
PREF_GOOGLE_CONNECTED = "google_connected"
|
PREF_GOOGLE_CONNECTED = "google_connected"
|
||||||
PREF_REMOTE_ALLOW_REMOTE_ENABLE = "remote_allow_remote_enable"
|
PREF_REMOTE_ALLOW_REMOTE_ENABLE = "remote_allow_remote_enable"
|
||||||
PREF_ENABLE_CLOUD_ICE_SERVERS = "cloud_ice_servers_enabled"
|
|
||||||
DEFAULT_TTS_DEFAULT_VOICE = ("en-US", "JennyNeural")
|
DEFAULT_TTS_DEFAULT_VOICE = ("en-US", "JennyNeural")
|
||||||
DEFAULT_DISABLE_2FA = False
|
DEFAULT_DISABLE_2FA = False
|
||||||
DEFAULT_ALEXA_REPORT_STATE = True
|
DEFAULT_ALEXA_REPORT_STATE = True
|
||||||
|
|
|
@ -42,7 +42,6 @@ from .const import (
|
||||||
PREF_ALEXA_REPORT_STATE,
|
PREF_ALEXA_REPORT_STATE,
|
||||||
PREF_DISABLE_2FA,
|
PREF_DISABLE_2FA,
|
||||||
PREF_ENABLE_ALEXA,
|
PREF_ENABLE_ALEXA,
|
||||||
PREF_ENABLE_CLOUD_ICE_SERVERS,
|
|
||||||
PREF_ENABLE_GOOGLE,
|
PREF_ENABLE_GOOGLE,
|
||||||
PREF_GOOGLE_REPORT_STATE,
|
PREF_GOOGLE_REPORT_STATE,
|
||||||
PREF_GOOGLE_SECURE_DEVICES_PIN,
|
PREF_GOOGLE_SECURE_DEVICES_PIN,
|
||||||
|
@ -440,16 +439,15 @@ 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,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@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.81.1"],
|
||||||
"single_config_entry": true
|
"single_config_entry": true
|
||||||
}
|
}
|
||||||
|
|
|
@ -32,7 +32,6 @@ from .const import (
|
||||||
PREF_CLOUD_USER,
|
PREF_CLOUD_USER,
|
||||||
PREF_CLOUDHOOKS,
|
PREF_CLOUDHOOKS,
|
||||||
PREF_ENABLE_ALEXA,
|
PREF_ENABLE_ALEXA,
|
||||||
PREF_ENABLE_CLOUD_ICE_SERVERS,
|
|
||||||
PREF_ENABLE_GOOGLE,
|
PREF_ENABLE_GOOGLE,
|
||||||
PREF_ENABLE_REMOTE,
|
PREF_ENABLE_REMOTE,
|
||||||
PREF_GOOGLE_CONNECTED,
|
PREF_GOOGLE_CONNECTED,
|
||||||
|
@ -163,21 +162,20 @@ 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,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Update user preferences."""
|
"""Update user preferences."""
|
||||||
prefs = {**self._prefs}
|
prefs = {**self._prefs}
|
||||||
|
@ -186,21 +184,20 @@ 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),
|
||||||
)
|
)
|
||||||
if value is not UNDEFINED
|
if value is not UNDEFINED
|
||||||
}
|
}
|
||||||
|
@ -242,7 +239,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,
|
||||||
|
@ -366,14 +362,6 @@ class CloudPreferences:
|
||||||
"""
|
"""
|
||||||
return self._prefs.get(PREF_TTS_DEFAULT_VOICE, DEFAULT_TTS_DEFAULT_VOICE) # type: ignore[no-any-return]
|
return self._prefs.get(PREF_TTS_DEFAULT_VOICE, DEFAULT_TTS_DEFAULT_VOICE) # type: ignore[no-any-return]
|
||||||
|
|
||||||
@property
|
|
||||||
def cloud_ice_servers_enabled(self) -> bool:
|
|
||||||
"""Return if cloud ICE servers are enabled."""
|
|
||||||
cloud_ice_servers_enabled: bool = self._prefs.get(
|
|
||||||
PREF_ENABLE_CLOUD_ICE_SERVERS, True
|
|
||||||
)
|
|
||||||
return cloud_ice_servers_enabled
|
|
||||||
|
|
||||||
async def get_cloud_user(self) -> str:
|
async def get_cloud_user(self) -> str:
|
||||||
"""Return ID of Home Assistant Cloud system user."""
|
"""Return ID of Home Assistant Cloud system user."""
|
||||||
user = await self._load_cloud_user()
|
user = await self._load_cloud_user()
|
||||||
|
@ -421,7 +409,6 @@ class CloudPreferences:
|
||||||
PREF_ENABLE_ALEXA: True,
|
PREF_ENABLE_ALEXA: True,
|
||||||
PREF_ENABLE_GOOGLE: True,
|
PREF_ENABLE_GOOGLE: True,
|
||||||
PREF_ENABLE_REMOTE: False,
|
PREF_ENABLE_REMOTE: False,
|
||||||
PREF_ENABLE_CLOUD_ICE_SERVERS: True,
|
|
||||||
PREF_GOOGLE_CONNECTED: False,
|
PREF_GOOGLE_CONNECTED: False,
|
||||||
PREF_GOOGLE_DEFAULT_EXPOSE: DEFAULT_EXPOSED_DOMAINS,
|
PREF_GOOGLE_DEFAULT_EXPOSE: DEFAULT_EXPOSED_DOMAINS,
|
||||||
PREF_GOOGLE_ENTITY_CONFIGS: {},
|
PREF_GOOGLE_ENTITY_CONFIGS: {},
|
||||||
|
|
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