This commit is contained in:
Franck Nijhof 2023-05-03 20:46:28 +02:00 committed by GitHub
commit c61e29709c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
1435 changed files with 81708 additions and 22238 deletions

View File

@ -226,6 +226,7 @@ omit =
homeassistant/components/dublin_bus_transport/sensor.py
homeassistant/components/dunehd/__init__.py
homeassistant/components/dunehd/media_player.py
homeassistant/components/dwd_weather_warnings/const.py
homeassistant/components/dwd_weather_warnings/sensor.py
homeassistant/components/dweet/*
homeassistant/components/ebox/sensor.py
@ -385,7 +386,10 @@ omit =
homeassistant/components/foscam/camera.py
homeassistant/components/foursquare/*
homeassistant/components/free_mobile/notify.py
homeassistant/components/freebox/camera.py
homeassistant/components/freebox/device_tracker.py
homeassistant/components/freebox/home_base.py
homeassistant/components/freebox/router.py
homeassistant/components/freebox/sensor.py
homeassistant/components/freebox/switch.py
homeassistant/components/fritz/common.py
@ -479,8 +483,6 @@ omit =
homeassistant/components/homematic/sensor.py
homeassistant/components/homematic/switch.py
homeassistant/components/homeworks/*
homeassistant/components/honeywell/__init__.py
homeassistant/components/honeywell/climate.py
homeassistant/components/horizon/media_player.py
homeassistant/components/hp_ilo/sensor.py
homeassistant/components/huawei_lte/__init__.py
@ -831,6 +833,7 @@ omit =
homeassistant/components/onvif/event.py
homeassistant/components/onvif/parsers.py
homeassistant/components/onvif/sensor.py
homeassistant/components/onvif/util.py
homeassistant/components/open_meteo/weather.py
homeassistant/components/opencv/*
homeassistant/components/openevse/sensor.py
@ -938,6 +941,7 @@ omit =
homeassistant/components/pushover/notify.py
homeassistant/components/pushsafer/notify.py
homeassistant/components/pyload/sensor.py
homeassistant/components/qbittorrent/__init__.py
homeassistant/components/qbittorrent/sensor.py
homeassistant/components/qnap/sensor.py
homeassistant/components/qrcode/image_processing.py
@ -995,6 +999,7 @@ omit =
homeassistant/components/ridwell/switch.py
homeassistant/components/ring/camera.py
homeassistant/components/ripple/sensor.py
homeassistant/components/roborock/coordinator.py
homeassistant/components/rocketchat/notify.py
homeassistant/components/roomba/__init__.py
homeassistant/components/roomba/binary_sensor.py
@ -1100,7 +1105,9 @@ omit =
homeassistant/components/sms/notify.py
homeassistant/components/sms/sensor.py
homeassistant/components/smtp/notify.py
homeassistant/components/snapcast/*
homeassistant/components/snapcast/__init__.py
homeassistant/components/snapcast/media_player.py
homeassistant/components/snapcast/server.py
homeassistant/components/snmp/device_tracker.py
homeassistant/components/snmp/sensor.py
homeassistant/components/snmp/switch.py
@ -1379,7 +1386,6 @@ omit =
homeassistant/components/verisure/sensor.py
homeassistant/components/verisure/switch.py
homeassistant/components/versasense/*
homeassistant/components/vesync/common.py
homeassistant/components/vesync/fan.py
homeassistant/components/vesync/light.py
homeassistant/components/vesync/sensor.py
@ -1435,7 +1441,6 @@ omit =
homeassistant/components/xbox/media_player.py
homeassistant/components/xbox/remote.py
homeassistant/components/xbox/sensor.py
homeassistant/components/xbox_live/sensor.py
homeassistant/components/xeoma/camera.py
homeassistant/components/xiaomi/camera.py
homeassistant/components/xiaomi_aqara/__init__.py
@ -1509,7 +1514,7 @@ omit =
homeassistant/components/zeversolar/entity.py
homeassistant/components/zeversolar/sensor.py
homeassistant/components/zha/websocket_api.py
homeassistant/components/zha/core/channels/*
homeassistant/components/zha/core/cluster_handlers/*
homeassistant/components/zha/core/device.py
homeassistant/components/zha/core/gateway.py
homeassistant/components/zha/core/helpers.py

1
.gitattributes vendored
View File

@ -8,5 +8,6 @@
*.png binary
*.zip binary
*.mp3 binary
*.pcm binary
Dockerfile.dev linguist-language=Dockerfile

View File

@ -24,12 +24,12 @@ jobs:
publish: ${{ steps.version.outputs.publish }}
steps:
- name: Checkout the repository
uses: actions/checkout@v3.5.0
uses: actions/checkout@v3.5.2
with:
fetch-depth: 0
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@v4.5.0
uses: actions/setup-python@v4.6.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}
@ -67,10 +67,10 @@ jobs:
if: github.repository_owner == 'home-assistant' && needs.init.outputs.publish == 'true'
steps:
- name: Checkout the repository
uses: actions/checkout@v3.5.0
uses: actions/checkout@v3.5.2
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@v4.5.0
uses: actions/setup-python@v4.6.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}
@ -105,7 +105,7 @@ jobs:
arch: ${{ fromJson(needs.init.outputs.architectures) }}
steps:
- name: Checkout the repository
uses: actions/checkout@v3.5.0
uses: actions/checkout@v3.5.2
- name: Download nightly wheels of frontend
if: needs.init.outputs.channel == 'dev'
@ -131,7 +131,7 @@ jobs:
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
if: needs.init.outputs.channel == 'dev'
uses: actions/setup-python@v4.5.0
uses: actions/setup-python@v4.6.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}
@ -249,7 +249,7 @@ jobs:
- yellow
steps:
- name: Checkout the repository
uses: actions/checkout@v3.5.0
uses: actions/checkout@v3.5.2
- name: Set build additional args
run: |
@ -292,7 +292,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout the repository
uses: actions/checkout@v3.5.0
uses: actions/checkout@v3.5.2
- name: Initialize git
uses: home-assistant/actions/helpers/git-init@master
@ -331,7 +331,7 @@ jobs:
- "homeassistant"
steps:
- name: Checkout the repository
uses: actions/checkout@v3.5.0
uses: actions/checkout@v3.5.2
- name: Login to DockerHub
if: matrix.registry == 'homeassistant'

View File

@ -1,4 +1,5 @@
name: CI
run-name: "${{ github.event_name == 'workflow_dispatch' && format('CI: {0}', github.ref_name) || '' }}"
# yamllint disable-line rule:truthy
on:
@ -31,7 +32,7 @@ env:
CACHE_VERSION: 5
PIP_CACHE_VERSION: 4
MYPY_CACHE_VERSION: 4
HA_SHORT_VERSION: 2023.4
HA_SHORT_VERSION: 2023.5
DEFAULT_PYTHON: "3.10"
ALL_PYTHON_VERSIONS: "['3.10', '3.11']"
# 10.3 is the oldest supported version
@ -40,7 +41,9 @@ env:
# - 10.6.10 is the version currently shipped with the Add-on (as of 31 Jan 2023)
# 10.10 is the latest short-term-support
# - 10.10.3 is the latest (as of 6 Feb 2023)
MARIADB_VERSIONS: "['mariadb:10.3.32','mariadb:10.6.10','mariadb:10.10.3']"
# mysql 8.0.32 does not always behave the same as MariaDB
# and some queries that work on MariaDB do not work on MySQL
MARIADB_VERSIONS: "['mariadb:10.3.32','mariadb:10.6.10','mariadb:10.10.3','mysql:8.0.32']"
# 12 is the oldest supported version
# - 12.14 is the latest (as of 9 Feb 2023)
# 15 is the latest version
@ -79,7 +82,7 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: Check out code from GitHub
uses: actions/checkout@v3.5.0
uses: actions/checkout@v3.5.2
- name: Generate partial Python venv restore key
id: generate_python_cache_key
run: >-
@ -203,10 +206,10 @@ jobs:
- info
steps:
- name: Check out code from GitHub
uses: actions/checkout@v3.5.0
uses: actions/checkout@v3.5.2
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python
uses: actions/setup-python@v4.5.0
uses: actions/setup-python@v4.6.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}
check-latest: true
@ -248,9 +251,9 @@ jobs:
- pre-commit
steps:
- name: Check out code from GitHub
uses: actions/checkout@v3.5.0
uses: actions/checkout@v3.5.2
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@v4.5.0
uses: actions/setup-python@v4.6.0
id: python
with:
python-version: ${{ env.DEFAULT_PYTHON }}
@ -294,9 +297,9 @@ jobs:
- pre-commit
steps:
- name: Check out code from GitHub
uses: actions/checkout@v3.5.0
uses: actions/checkout@v3.5.2
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@v4.5.0
uses: actions/setup-python@v4.6.0
id: python
with:
python-version: ${{ env.DEFAULT_PYTHON }}
@ -343,9 +346,9 @@ jobs:
- pre-commit
steps:
- name: Check out code from GitHub
uses: actions/checkout@v3.5.0
uses: actions/checkout@v3.5.2
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@v4.5.0
uses: actions/setup-python@v4.6.0
id: python
with:
python-version: ${{ env.DEFAULT_PYTHON }}
@ -381,9 +384,9 @@ jobs:
- pre-commit
steps:
- name: Check out code from GitHub
uses: actions/checkout@v3.5.0
uses: actions/checkout@v3.5.2
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@v4.5.0
uses: actions/setup-python@v4.6.0
id: python
with:
python-version: ${{ env.DEFAULT_PYTHON }}
@ -434,6 +437,7 @@ jobs:
shell: bash
run: |
. venv/bin/activate
shopt -s globstar
pre-commit run --hook-stage manual prettier --files {homeassistant,tests}/components/${{ needs.info.outputs.integrations_glob }}/{*,**/*}
- name: Register check executables problem matcher
@ -487,10 +491,10 @@ jobs:
python-version: ${{ fromJSON(needs.info.outputs.python_versions) }}
steps:
- name: Check out code from GitHub
uses: actions/checkout@v3.5.0
uses: actions/checkout@v3.5.2
- name: Set up Python ${{ matrix.python-version }}
id: python
uses: actions/setup-python@v4.5.0
uses: actions/setup-python@v4.6.0
with:
python-version: ${{ matrix.python-version }}
check-latest: true
@ -539,7 +543,7 @@ jobs:
python -m venv venv
. venv/bin/activate
python --version
pip install --cache-dir=$PIP_CACHE -U "pip>=21.0,<23.1" setuptools wheel
pip install --cache-dir=$PIP_CACHE -U "pip>=21.0,<23.2" setuptools wheel
pip install --cache-dir=$PIP_CACHE -r requirements_all.txt --use-deprecated=legacy-resolver
pip install --cache-dir=$PIP_CACHE -r requirements_test.txt --use-deprecated=legacy-resolver
pip install -e .
@ -555,10 +559,10 @@ jobs:
- base
steps:
- name: Check out code from GitHub
uses: actions/checkout@v3.5.0
uses: actions/checkout@v3.5.2
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python
uses: actions/setup-python@v4.5.0
uses: actions/setup-python@v4.6.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}
check-latest: true
@ -587,10 +591,10 @@ jobs:
- base
steps:
- name: Check out code from GitHub
uses: actions/checkout@v3.5.0
uses: actions/checkout@v3.5.2
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python
uses: actions/setup-python@v4.5.0
uses: actions/setup-python@v4.6.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}
check-latest: true
@ -620,10 +624,10 @@ jobs:
- base
steps:
- name: Check out code from GitHub
uses: actions/checkout@v3.5.0
uses: actions/checkout@v3.5.2
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python
uses: actions/setup-python@v4.5.0
uses: actions/setup-python@v4.6.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}
check-latest: true
@ -664,10 +668,10 @@ jobs:
- base
steps:
- name: Check out code from GitHub
uses: actions/checkout@v3.5.0
uses: actions/checkout@v3.5.2
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python
uses: actions/setup-python@v4.5.0
uses: actions/setup-python@v4.6.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}
check-latest: true
@ -730,10 +734,10 @@ jobs:
name: Run pip check ${{ matrix.python-version }}
steps:
- name: Check out code from GitHub
uses: actions/checkout@v3.5.0
uses: actions/checkout@v3.5.2
- name: Set up Python ${{ matrix.python-version }}
id: python
uses: actions/setup-python@v4.5.0
uses: actions/setup-python@v4.6.0
with:
python-version: ${{ matrix.python-version }}
check-latest: true
@ -783,10 +787,10 @@ jobs:
bluez \
ffmpeg
- name: Check out code from GitHub
uses: actions/checkout@v3.5.0
uses: actions/checkout@v3.5.2
- name: Set up Python ${{ matrix.python-version }}
id: python
uses: actions/setup-python@v4.5.0
uses: actions/setup-python@v4.6.0
with:
python-version: ${{ matrix.python-version }}
check-latest: true
@ -909,10 +913,10 @@ jobs:
ffmpeg \
libmariadb-dev-compat
- name: Check out code from GitHub
uses: actions/checkout@v3.5.0
uses: actions/checkout@v3.5.2
- name: Set up Python ${{ matrix.python-version }}
id: python
uses: actions/setup-python@v4.5.0
uses: actions/setup-python@v4.6.0
with:
python-version: ${{ matrix.python-version }}
check-latest: true
@ -1017,10 +1021,10 @@ jobs:
ffmpeg \
postgresql-server-dev-14
- name: Check out code from GitHub
uses: actions/checkout@v3.5.0
uses: actions/checkout@v3.5.2
- name: Set up Python ${{ matrix.python-version }}
id: python
uses: actions/setup-python@v4.5.0
uses: actions/setup-python@v4.6.0
with:
python-version: ${{ matrix.python-version }}
check-latest: true
@ -1091,19 +1095,28 @@ jobs:
needs:
- info
- pytest
timeout-minutes: 10
steps:
- name: Check out code from GitHub
uses: actions/checkout@v3.5.0
uses: actions/checkout@v3.5.2
- name: Download all coverage artifacts
uses: actions/download-artifact@v3
- name: Upload coverage to Codecov (full coverage)
if: needs.info.outputs.test_full_suite == 'true'
uses: codecov/codecov-action@v3.1.1
uses: Wandalen/wretry.action@v1.0.36
with:
fail_ci_if_error: true
flags: full-suite
action: codecov/codecov-action@v3.1.3
with: |
fail_ci_if_error: true
flags: full-suite
attempt_limit: 5
attempt_delay: 30000
- name: Upload coverage to Codecov (partial coverage)
if: needs.info.outputs.test_full_suite == 'false'
uses: codecov/codecov-action@v3.1.1
uses: Wandalen/wretry.action@v1.0.36
with:
fail_ci_if_error: true
action: codecov/codecov-action@v3.1.3
with: |
fail_ci_if_error: true
attempt_limit: 5
attempt_delay: 30000

View File

@ -19,10 +19,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout the repository
uses: actions/checkout@v3.5.0
uses: actions/checkout@v3.5.2
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@v4.5.0
uses: actions/setup-python@v4.6.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}

View File

@ -13,6 +13,10 @@ on:
- "requirements.txt"
- "requirements_all.txt"
concurrency:
group: ${{ github.workflow }}-${{ github.ref_name}}
cancel-in-progress: true
jobs:
init:
name: Initialize wheels builder
@ -22,7 +26,7 @@ jobs:
architectures: ${{ steps.info.outputs.architectures }}
steps:
- name: Checkout the repository
uses: actions/checkout@v3.5.0
uses: actions/checkout@v3.5.2
- name: Get information
id: info
@ -72,17 +76,18 @@ jobs:
path: ./requirements_diff.txt
core:
name: Build musllinux wheels with musllinux_1_2 / cp310 at ${{ matrix.arch }} for core
name: Build Core wheels ${{ matrix.abi }} for ${{ matrix.arch }} (musllinux_1_2)
if: github.repository_owner == 'home-assistant'
needs: init
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
abi: ["cp310", "cp311"]
arch: ${{ fromJson(needs.init.outputs.architectures) }}
steps:
- name: Checkout the repository
uses: actions/checkout@v3.5.0
uses: actions/checkout@v3.5.2
- name: Download env_file
uses: actions/download-artifact@v3
@ -95,9 +100,9 @@ jobs:
name: requirements_diff
- name: Build wheels
uses: home-assistant/wheels@2022.10.1
uses: home-assistant/wheels@2023.04.0
with:
abi: cp310
abi: ${{ matrix.abi }}
tag: musllinux_1_2
arch: ${{ matrix.arch }}
wheels-key: ${{ secrets.WHEELS_KEY }}
@ -108,18 +113,19 @@ jobs:
requirements-diff: "requirements_diff.txt"
requirements: "requirements.txt"
integrations:
name: Build musllinux wheels with musllinux_1_2 / cp310 at ${{ matrix.arch }} for integrations
integrations_cp310:
name: Build wheels ${{ matrix.abi }} for ${{ matrix.arch }}
if: github.repository_owner == 'home-assistant'
needs: init
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
abi: ["cp310"]
arch: ${{ fromJson(needs.init.outputs.architectures) }}
steps:
- name: Checkout the repository
uses: actions/checkout@v3.5.0
uses: actions/checkout@v3.5.2
- name: Download env_file
uses: actions/download-artifact@v3
@ -135,6 +141,7 @@ jobs:
run: |
requirement_files="requirements_all.txt requirements_diff.txt"
for requirement_file in ${requirement_files}; do
sed -i "s|# azure-servicebus|azure-servicebus|g" ${requirement_file}
sed -i "s|# pybluez|pybluez|g" ${requirement_file}
sed -i "s|# beacontools|beacontools|g" ${requirement_file}
sed -i "s|# fritzconnection|fritzconnection|g" ${requirement_file}
@ -171,30 +178,177 @@ jobs:
sed -i "/numpy/d" homeassistant/package_constraints.txt
- name: Build wheels (part 1)
uses: home-assistant/wheels@2022.10.1
uses: home-assistant/wheels@2023.04.0
with:
abi: cp310
abi: ${{ matrix.abi }}
tag: musllinux_1_2
arch: ${{ matrix.arch }}
wheels-key: ${{ secrets.WHEELS_KEY }}
env-file: true
apk: "libexecinfo-dev;bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev"
skip-binary: aiohttp;grpcio;sqlalchemy
apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev"
skip-binary: aiohttp;grpcio;sqlalchemy;protobuf
legacy: true
constraints: "homeassistant/package_constraints.txt"
requirements-diff: "requirements_diff.txt"
requirements: "requirements_all.txtaa"
- name: Build wheels (part 2)
uses: home-assistant/wheels@2022.10.1
uses: home-assistant/wheels@2023.04.0
with:
abi: cp310
abi: ${{ matrix.abi }}
tag: musllinux_1_2
arch: ${{ matrix.arch }}
wheels-key: ${{ secrets.WHEELS_KEY }}
env-file: true
apk: "libexecinfo-dev;bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev"
skip-binary: aiohttp;grpcio;sqlalchemy
apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev"
skip-binary: aiohttp;grpcio;sqlalchemy;protobuf
legacy: true
constraints: "homeassistant/package_constraints.txt"
requirements-diff: "requirements_diff.txt"
requirements: "requirements_all.txtab"
# Wheels building for the cp311 ABI is currently split
# This is mainly until we have figured out to get all wheels built.
# Without harming our current workflow.
integrations_cp311:
name: Build wheels ${{ matrix.abi }} for ${{ matrix.arch }}
if: github.repository_owner == 'home-assistant'
needs: init
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
abi: ["cp311"]
arch: ${{ fromJson(needs.init.outputs.architectures) }}
steps:
- name: Checkout the repository
uses: actions/checkout@v3.5.2
- name: Write alternative env-file for cp311
run: |
(
echo "GRPC_BUILD_WITH_BORING_SSL_ASM=false"
echo "GRPC_PYTHON_BUILD_SYSTEM_OPENSSL=true"
echo "GRPC_PYTHON_BUILD_WITH_CYTHON=true"
echo "GRPC_PYTHON_DISABLE_LIBC_COMPATIBILITY=true"
# GRPC on armv7 needed -lexecinfo (issue #56669) since home assistant installed
# execinfo-dev when building wheels. However, this package is no longer available
# Alpine 3.17, which we use for the cp311 ABI, so the flag should no longer be needed.
echo "GRPC_PYTHON_LDFLAGS=-lpthread -Wl,-wrap,memcpy -static-libgcc" # -lexecinfo
# Fix out of memory issues with rust
echo "CARGO_NET_GIT_FETCH_WITH_CLI=true"
# OpenCV headless installation
echo "CI_BUILD=1"
echo "ENABLE_HEADLESS=1"
# Use C-Extension for sqlalchemy
echo "REQUIRE_SQLALCHEMY_CEXT=1"
) > .env_file
- name: Download requirements_diff
uses: actions/download-artifact@v3
with:
name: requirements_diff
- name: (Un)comment packages
run: |
requirement_files="requirements_all.txt requirements_diff.txt"
for requirement_file in ${requirement_files}; do
# PyBluez no longer compiles. Commented it out for now.
# It need further cleanup down the line, as all machine images
# try to install it.
# sed -i "s|# pybluez|pybluez|g" ${requirement_file}
# beacontools requires PyBluez.
# sed -i "s|# beacontools|beacontools|g" ${requirement_file}
# azure-servicebus requires uamqp, which requires OpenSSL 1.1 to
# compile/build. This is not available on Alpine 3.17. The compat
# layer offered by Alpine conflicts, so we have no way to build
# this package.
# sed -i "s|# azure-servicebus|azure-servicebus|g" ${requirement_file}
# It doesn't build for some reason, so we skip it for now.
# Bumping to the latest version (4.7.0.72) supporting Python 3.11
# doesn't help. Reverted bump in #91871. There are 8 registered
# instances using this integration according to analytics.
# sed -i "s|# opencv-python-headless|opencv-python-headless|g" ${requirement_file}
sed -i "s|# fritzconnection|fritzconnection|g" ${requirement_file}
sed -i "s|# pyuserinput|pyuserinput|g" ${requirement_file}
sed -i "s|# evdev|evdev|g" ${requirement_file}
sed -i "s|# pycups|pycups|g" ${requirement_file}
sed -i "s|# homekit|homekit|g" ${requirement_file}
sed -i "s|# decora_wifi|decora_wifi|g" ${requirement_file}
sed -i "s|# python-gammu|python-gammu|g" ${requirement_file}
# Some packages are not buildable on armhf anymore
if [ "${{ matrix.arch }}" = "armhf" ]; then
# Pandas has issues building on armhf, it is expected they
# will drop the platform in the near future (they consider it
# "flimsy" on 386). The following packages depend on pandas,
# so we comment them out.
sed -i "s|env_canada|# env_canada|g" ${requirement_file}
sed -i "s|noaa-coops|# noaa-coops|g" ${requirement_file}
sed -i "s|pyezviz|# pyezviz|g" ${requirement_file}
sed -i "s|pykrakenapi|# pykrakenapi|g" ${requirement_file}
fi
done
- name: Split requirements all
run: |
# We split requirements all into two different files.
# This is to prevent the build from running out of memory when
# resolving packages on 32-bits systems (like armhf, armv7).
split -l $(expr $(expr $(cat requirements_all.txt | wc -l) + 1) / 2) requirements_all.txt requirements_all.txt
- name: Adjust build env
run: |
if [ "${{ matrix.arch }}" = "i386" ]; then
echo "NPY_DISABLE_SVML=1" >> .env_file
fi
# Probably not an issue anymore. Removing for now.
# (
# # cmake > 3.22.2 have issue on arm
# # Tested until 3.22.5
# echo "cmake==3.22.2"
# ) >> homeassistant/package_constraints.txt
# Do not pin numpy in wheels building
sed -i "/numpy/d" homeassistant/package_constraints.txt
- name: Build wheels (part 1)
uses: home-assistant/wheels@2023.04.0
with:
abi: ${{ matrix.abi }}
tag: musllinux_1_2
arch: ${{ matrix.arch }}
wheels-key: ${{ secrets.WHEELS_KEY }}
env-file: true
apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev"
skip-binary: aiohttp;grpcio;sqlalchemy;protobuf
legacy: true
constraints: "homeassistant/package_constraints.txt"
requirements-diff: "requirements_diff.txt"
requirements: "requirements_all.txtaa"
- name: Build wheels (part 2)
uses: home-assistant/wheels@2023.04.0
with:
abi: ${{ matrix.abi }}
tag: musllinux_1_2
arch: ${{ matrix.arch }}
wheels-key: ${{ secrets.WHEELS_KEY }}
env-file: true
apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev"
skip-binary: aiohttp;grpcio;sqlalchemy;protobuf
legacy: true
constraints: "homeassistant/package_constraints.txt"
requirements-diff: "requirements_diff.txt"

View File

@ -1,12 +1,12 @@
repos:
- repo: https://github.com/charliermarsh/ruff-pre-commit
rev: v0.0.256
rev: v0.0.262
hooks:
- id: ruff
args:
- --fix
- repo: https://github.com/psf/black
rev: 23.1.0
rev: 23.3.0
hooks:
- id: black
args:

View File

@ -57,10 +57,12 @@ homeassistant.components.ambient_station.*
homeassistant.components.amcrest.*
homeassistant.components.ampio.*
homeassistant.components.analytics.*
homeassistant.components.anova.*
homeassistant.components.anthemav.*
homeassistant.components.apcupsd.*
homeassistant.components.aqualogic.*
homeassistant.components.aseko_pool_live.*
homeassistant.components.assist_pipeline.*
homeassistant.components.asuswrt.*
homeassistant.components.auth.*
homeassistant.components.automation.*
@ -137,6 +139,7 @@ homeassistant.components.hardkernel.*
homeassistant.components.hardware.*
homeassistant.components.here_travel_time.*
homeassistant.components.history.*
homeassistant.components.homeassistant.exposed_entities
homeassistant.components.homeassistant.triggers.event
homeassistant.components.homeassistant_alerts.*
homeassistant.components.homeassistant_hardware.*

4
.vscode/launch.json vendored
View File

@ -23,7 +23,7 @@
"preLaunchTask": "Compile English translations"
},
{
// Debug by attaching to local Home Asistant server using Remote Python Debugger.
// Debug by attaching to local Home Assistant server using Remote Python Debugger.
// See https://www.home-assistant.io/integrations/debugpy/
"name": "Home Assistant: Attach Local",
"type": "python",
@ -38,7 +38,7 @@
]
},
{
// Debug by attaching to remote Home Asistant server using Remote Python Debugger.
// Debug by attaching to remote Home Assistant server using Remote Python Debugger.
// See https://www.home-assistant.io/integrations/debugpy/
"name": "Home Assistant: Attach Remote",
"type": "python",

View File

@ -80,6 +80,10 @@ build.json @home-assistant/supervisor
/tests/components/android_ip_webcam/ @engrbm87
/homeassistant/components/androidtv/ @JeffLIrion @ollo69
/tests/components/androidtv/ @JeffLIrion @ollo69
/homeassistant/components/androidtv_remote/ @tronikos
/tests/components/androidtv_remote/ @tronikos
/homeassistant/components/anova/ @Lash-L
/tests/components/anova/ @Lash-L
/homeassistant/components/anthemav/ @hyralex
/tests/components/anthemav/ @hyralex
/homeassistant/components/apache_kafka/ @bachya
@ -103,6 +107,8 @@ build.json @home-assistant/supervisor
/homeassistant/components/arris_tg2492lg/ @vanbalken
/homeassistant/components/aseko_pool_live/ @milanmeu
/tests/components/aseko_pool_live/ @milanmeu
/homeassistant/components/assist_pipeline/ @balloob @synesthesiam
/tests/components/assist_pipeline/ @balloob @synesthesiam
/homeassistant/components/asuswrt/ @kennedyshead @ollo69
/tests/components/asuswrt/ @kennedyshead @ollo69
/homeassistant/components/atag/ @MatsNL
@ -168,6 +174,8 @@ build.json @home-assistant/supervisor
/tests/components/broadlink/ @danielhiversen @felipediel @L-I-Am
/homeassistant/components/brother/ @bieniu
/tests/components/brother/ @bieniu
/homeassistant/components/brottsplatskartan/ @gjohansson-ST
/tests/components/brottsplatskartan/ @gjohansson-ST
/homeassistant/components/brunt/ @eavanvalkenburg
/tests/components/brunt/ @eavanvalkenburg
/homeassistant/components/bsblan/ @liudger
@ -215,8 +223,6 @@ build.json @home-assistant/supervisor
/tests/components/conversation/ @home-assistant/core @synesthesiam
/homeassistant/components/coolmaster/ @OnFreund
/tests/components/coolmaster/ @OnFreund
/homeassistant/components/coronavirus/ @home-assistant/core
/tests/components/coronavirus/ @home-assistant/core
/homeassistant/components/counter/ @fabaff
/tests/components/counter/ @fabaff
/homeassistant/components/cover/ @home-assistant/core
@ -281,7 +287,7 @@ build.json @home-assistant/supervisor
/tests/components/dsmr_reader/ @depl0y @glodenox
/homeassistant/components/dunehd/ @bieniu
/tests/components/dunehd/ @bieniu
/homeassistant/components/dwd_weather_warnings/ @runningman84 @stephan192 @Hummel95
/homeassistant/components/dwd_weather_warnings/ @runningman84 @stephan192 @Hummel95 @andarotajo
/homeassistant/components/dynalite/ @ziv1234
/tests/components/dynalite/ @ziv1234
/homeassistant/components/eafm/ @Jc2k
@ -544,8 +550,8 @@ build.json @home-assistant/supervisor
/tests/components/image_processing/ @home-assistant/core
/homeassistant/components/image_upload/ @home-assistant/core
/tests/components/image_upload/ @home-assistant/core
/homeassistant/components/imap/ @engrbm87
/tests/components/imap/ @engrbm87
/homeassistant/components/imap/ @engrbm87 @jbouwh
/tests/components/imap/ @engrbm87 @jbouwh
/homeassistant/components/incomfort/ @zxdavb
/homeassistant/components/influxdb/ @mdegat01
/tests/components/influxdb/ @mdegat01
@ -649,8 +655,8 @@ build.json @home-assistant/supervisor
/tests/components/lidarr/ @tkdrob
/homeassistant/components/life360/ @pnbruckner
/tests/components/life360/ @pnbruckner
/homeassistant/components/lifx/ @bdraco @Djelibeybi
/tests/components/lifx/ @bdraco @Djelibeybi
/homeassistant/components/lifx/ @bdraco
/tests/components/lifx/ @bdraco
/homeassistant/components/light/ @home-assistant/core
/tests/components/light/ @home-assistant/core
/homeassistant/components/linux_battery/ @fabaff
@ -819,8 +825,8 @@ build.json @home-assistant/supervisor
/tests/components/numato/ @clssn
/homeassistant/components/number/ @home-assistant/core @Shulyaka
/tests/components/number/ @home-assistant/core @Shulyaka
/homeassistant/components/nut/ @bdraco @ollo69
/tests/components/nut/ @bdraco @ollo69
/homeassistant/components/nut/ @bdraco @ollo69 @pestevez
/tests/components/nut/ @bdraco @ollo69 @pestevez
/homeassistant/components/nws/ @MatthewFlamm @kamiyo
/tests/components/nws/ @MatthewFlamm @kamiyo
/homeassistant/components/nzbget/ @chriscla
@ -893,8 +899,8 @@ build.json @home-assistant/supervisor
/tests/components/plaato/ @JohNan
/homeassistant/components/plex/ @jjlawren
/tests/components/plex/ @jjlawren
/homeassistant/components/plugwise/ @CoMPaTech @bouwew @brefra @frenck
/tests/components/plugwise/ @CoMPaTech @bouwew @brefra @frenck
/homeassistant/components/plugwise/ @CoMPaTech @bouwew @frenck
/tests/components/plugwise/ @CoMPaTech @bouwew @frenck
/homeassistant/components/plum_lightpad/ @ColinHarrington @prystupa
/tests/components/plum_lightpad/ @ColinHarrington @prystupa
/homeassistant/components/point/ @fredrike
@ -931,6 +937,7 @@ build.json @home-assistant/supervisor
/homeassistant/components/pvpc_hourly_pricing/ @azogue
/tests/components/pvpc_hourly_pricing/ @azogue
/homeassistant/components/qbittorrent/ @geoffreylagaisse
/tests/components/qbittorrent/ @geoffreylagaisse
/homeassistant/components/qingping/ @bdraco @skgsergio
/tests/components/qingping/ @bdraco @skgsergio
/homeassistant/components/qld_bushfire/ @exxamalte
@ -958,6 +965,8 @@ build.json @home-assistant/supervisor
/tests/components/rainmachine/ @bachya
/homeassistant/components/random/ @fabaff
/tests/components/random/ @fabaff
/homeassistant/components/rapt_ble/ @sairon
/tests/components/rapt_ble/ @sairon
/homeassistant/components/raspberry_pi/ @home-assistant/core
/tests/components/raspberry_pi/ @home-assistant/core
/homeassistant/components/rdw/ @frenck
@ -976,6 +985,8 @@ build.json @home-assistant/supervisor
/homeassistant/components/repairs/ @home-assistant/core
/tests/components/repairs/ @home-assistant/core
/homeassistant/components/repetier/ @MTrab @ShadowBr0ther
/homeassistant/components/rest/ @epenet
/tests/components/rest/ @epenet
/homeassistant/components/rflink/ @javicalle
/tests/components/rflink/ @javicalle
/homeassistant/components/rfxtrx/ @danielhiversen @elupus @RobBie1221
@ -990,6 +1001,8 @@ build.json @home-assistant/supervisor
/tests/components/rituals_perfume_genie/ @milanmeu
/homeassistant/components/rmvtransport/ @cgtobi
/tests/components/rmvtransport/ @cgtobi
/homeassistant/components/roborock/ @humbertogontijo @Lash-L
/tests/components/roborock/ @humbertogontijo @Lash-L
/homeassistant/components/roku/ @ctalkington
/tests/components/roku/ @ctalkington
/homeassistant/components/roomba/ @pschmitt @cyr-ius @shenxn
@ -1102,6 +1115,7 @@ build.json @home-assistant/supervisor
/tests/components/smhi/ @gjohansson-ST
/homeassistant/components/sms/ @ocalvo
/homeassistant/components/snapcast/ @luar123
/tests/components/snapcast/ @luar123
/homeassistant/components/snooz/ @AustinBrunkhorst
/tests/components/snooz/ @AustinBrunkhorst
/homeassistant/components/solaredge/ @frenck
@ -1130,8 +1144,8 @@ build.json @home-assistant/supervisor
/homeassistant/components/splunk/ @Bre77
/homeassistant/components/spotify/ @frenck
/tests/components/spotify/ @frenck
/homeassistant/components/sql/ @dgomes @gjohansson-ST
/tests/components/sql/ @dgomes @gjohansson-ST
/homeassistant/components/sql/ @dgomes @gjohansson-ST @dougiteixeira
/tests/components/sql/ @dgomes @gjohansson-ST @dougiteixeira
/homeassistant/components/squeezebox/ @rajlaud
/tests/components/squeezebox/ @rajlaud
/homeassistant/components/srp_energy/ @briglx
@ -1153,8 +1167,8 @@ build.json @home-assistant/supervisor
/tests/components/stookwijzer/ @fwestenberg
/homeassistant/components/stream/ @hunterjm @uvjustin @allenporter
/tests/components/stream/ @hunterjm @uvjustin @allenporter
/homeassistant/components/stt/ @pvizeli
/tests/components/stt/ @pvizeli
/homeassistant/components/stt/ @home-assistant/core @pvizeli
/tests/components/stt/ @home-assistant/core @pvizeli
/homeassistant/components/subaru/ @G-Two
/tests/components/subaru/ @G-Two
/homeassistant/components/suez_water/ @ooii
@ -1249,8 +1263,8 @@ build.json @home-assistant/supervisor
/tests/components/trafikverket_weatherstation/ @endor-force @gjohansson-ST
/homeassistant/components/transmission/ @engrbm87 @JPHutchins
/tests/components/transmission/ @engrbm87 @JPHutchins
/homeassistant/components/tts/ @pvizeli
/tests/components/tts/ @pvizeli
/homeassistant/components/tts/ @home-assistant/core @pvizeli
/tests/components/tts/ @home-assistant/core @pvizeli
/homeassistant/components/tuya/ @Tuya @zlinoliver @frenck
/tests/components/tuya/ @Tuya @zlinoliver @frenck
/homeassistant/components/twentemilieu/ @frenck
@ -1262,8 +1276,8 @@ build.json @home-assistant/supervisor
/homeassistant/components/unifi/ @Kane610
/tests/components/unifi/ @Kane610
/homeassistant/components/unifiled/ @florisvdk
/homeassistant/components/unifiprotect/ @briis @AngellusMortis @bdraco
/tests/components/unifiprotect/ @briis @AngellusMortis @bdraco
/homeassistant/components/unifiprotect/ @AngellusMortis @bdraco
/tests/components/unifiprotect/ @AngellusMortis @bdraco
/homeassistant/components/upb/ @gwww
/tests/components/upb/ @gwww
/homeassistant/components/upc_connect/ @pvizeli @fabaff
@ -1299,8 +1313,6 @@ build.json @home-assistant/supervisor
/tests/components/version/ @ludeeus
/homeassistant/components/vesync/ @markperdue @webdjoe @thegardenmonkey
/tests/components/vesync/ @markperdue @webdjoe @thegardenmonkey
/homeassistant/components/vicare/ @oischinger
/tests/components/vicare/ @oischinger
/homeassistant/components/vilfo/ @ManneW
/tests/components/vilfo/ @ManneW
/homeassistant/components/vivotek/ @HarlemSquirrel
@ -1308,8 +1320,8 @@ build.json @home-assistant/supervisor
/tests/components/vizio/ @raman325
/homeassistant/components/vlc_telnet/ @rodripf @MartinHjelmare
/tests/components/vlc_telnet/ @rodripf @MartinHjelmare
/homeassistant/components/voice_assistant/ @balloob @synesthesiam
/tests/components/voice_assistant/ @balloob @synesthesiam
/homeassistant/components/voip/ @balloob @synesthesiam
/tests/components/voip/ @balloob @synesthesiam
/homeassistant/components/volumio/ @OnFreund
/tests/components/volumio/ @OnFreund
/homeassistant/components/volvooncall/ @molobrakos
@ -1361,9 +1373,10 @@ build.json @home-assistant/supervisor
/tests/components/worldclock/ @fabaff
/homeassistant/components/ws66i/ @ssaenger
/tests/components/ws66i/ @ssaenger
/homeassistant/components/wyoming/ @balloob @synesthesiam
/tests/components/wyoming/ @balloob @synesthesiam
/homeassistant/components/xbox/ @hunterjm
/tests/components/xbox/ @hunterjm
/homeassistant/components/xbox_live/ @MartinHjelmare
/homeassistant/components/xiaomi_aqara/ @danielhiversen @syssi
/tests/components/xiaomi_aqara/ @danielhiversen @syssi
/homeassistant/components/xiaomi_ble/ @Jc2k @Ernst79

View File

@ -132,8 +132,8 @@ For answers to common questions about this code of conduct, see the FAQ at
<https://www.contributor-covenant.org/faq>. Translations are available at
<https://www.contributor-covenant.org/translations>.
[coc-blog]: /blog/2017/01/21/home-assistant-governance/
[coc2-blog]: /blog/2020/05/25/code-of-conduct-updated/
[coc-blog]: https://www.home-assistant.io/blog/2017/01/21/home-assistant-governance/
[coc2-blog]: https://www.home-assistant.io/blog/2020/05/25/code-of-conduct-updated/
[email]: mailto:safety@home-assistant.io
[homepage]: http://contributor-covenant.org
[mozilla]: https://github.com/mozilla/diversity

View File

@ -4,11 +4,12 @@ SHELL ["/bin/bash", "-o", "pipefail", "-c"]
# Uninstall pre-installed formatting and linting tools
# They would conflict with our pinned versions
RUN pipx uninstall black
RUN pipx uninstall pydocstyle
RUN pipx uninstall pycodestyle
RUN pipx uninstall mypy
RUN pipx uninstall pylint
RUN \
pipx uninstall black \
&& pipx uninstall pydocstyle \
&& pipx uninstall pycodestyle \
&& pipx uninstall mypy \
&& pipx uninstall pylint
RUN \
curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - \

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

After

Width:  |  Height:  |  Size: 164 KiB

View File

@ -629,6 +629,9 @@ async def _async_set_up_integrations(
- stage_1_domains
)
# Enables after dependencies when setting up stage 1 domains
async_set_domains_to_be_loaded(hass, stage_1_domains)
# Start setup
if stage_1_domains:
_LOGGER.info("Setting up stage 1: %s", stage_1_domains)
@ -640,7 +643,7 @@ async def _async_set_up_integrations(
except asyncio.TimeoutError:
_LOGGER.warning("Setup timed out for stage 1 - moving forward")
# Enables after dependencies
# Add after dependencies when setting up stage 2 domains
async_set_domains_to_be_loaded(hass, stage_2_domains)
if stage_2_domains:

View File

@ -10,7 +10,6 @@
"microsoft_face",
"microsoft",
"msteams",
"xbox",
"xbox_live"
"xbox"
]
}

View File

@ -10,14 +10,15 @@ from aiohttp import ClientSession
from aiohttp.client_exceptions import ClientConnectorError
from async_timeout import timeout
from homeassistant.components.sensor import DOMAIN as SENSOR_PLATFORM
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_KEY, CONF_NAME, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.device_registry import DeviceEntryType
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util.unit_system import METRIC_SYSTEM
from .const import ATTR_FORECAST, CONF_FORECAST, DOMAIN, MANUFACTURER
@ -49,6 +50,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
# Remove ozone sensors from registry if they exist
ent_reg = er.async_get(hass)
for day in range(0, 5):
unique_id = f"{coordinator.location_key}-ozone-{day}"
if entity_id := ent_reg.async_get_entity_id(SENSOR_PLATFORM, DOMAIN, unique_id):
_LOGGER.debug("Removing ozone sensor entity %s", entity_id)
ent_reg.async_remove(entity_id)
return True
@ -116,11 +125,7 @@ class AccuWeatherDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
async with timeout(10):
current = await self.accuweather.async_get_current_conditions()
forecast = (
await self.accuweather.async_get_forecast(
metric=self.hass.config.units is METRIC_SYSTEM
)
if self.forecast
else {}
await self.accuweather.async_get_forecast() if self.forecast else {}
)
except (
ApiError,

View File

@ -20,7 +20,6 @@ from homeassistant.components.weather import (
ATTR_CONDITION_WINDY,
)
API_IMPERIAL: Final = "Imperial"
API_METRIC: Final = "Metric"
ATTRIBUTION: Final = "Data provided by AccuWeather"
ATTR_CATEGORY: Final = "Category"

View File

@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["accuweather"],
"quality_scale": "platinum",
"requirements": ["accuweather==0.5.0"]
"requirements": ["accuweather==0.5.1"]
}

View File

@ -26,11 +26,9 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util.unit_system import METRIC_SYSTEM
from . import AccuWeatherDataUpdateCoordinator
from .const import (
API_IMPERIAL,
API_METRIC,
ATTR_CATEGORY,
ATTR_DIRECTION,
@ -51,7 +49,7 @@ PARALLEL_UPDATES = 1
class AccuWeatherSensorDescriptionMixin:
"""Mixin for AccuWeather sensor."""
value_fn: Callable[[dict[str, Any], str], StateType]
value_fn: Callable[[dict[str, Any]], StateType]
@dataclass
@ -61,18 +59,25 @@ class AccuWeatherSensorDescription(
"""Class describing AccuWeather sensor entities."""
attr_fn: Callable[[dict[str, Any]], dict[str, StateType]] = lambda _: {}
metric_unit: str | None = None
us_customary_unit: str | None = None
FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = (
AccuWeatherSensorDescription(
key="AirQuality",
icon="mdi:air-filter",
name="Air quality",
value_fn=lambda data: cast(str, data[ATTR_CATEGORY]),
device_class=SensorDeviceClass.ENUM,
options=["good", "hazardous", "high", "low", "moderate", "unhealthy"],
translation_key="air_quality",
),
AccuWeatherSensorDescription(
key="CloudCoverDay",
icon="mdi:weather-cloudy",
name="Cloud cover day",
entity_registry_enabled_default=False,
native_unit_of_measurement=PERCENTAGE,
value_fn=lambda data, _: cast(int, data),
value_fn=lambda data: cast(int, data),
),
AccuWeatherSensorDescription(
key="CloudCoverNight",
@ -80,7 +85,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = (
name="Cloud cover night",
entity_registry_enabled_default=False,
native_unit_of_measurement=PERCENTAGE,
value_fn=lambda data, _: cast(int, data),
value_fn=lambda data: cast(int, data),
),
AccuWeatherSensorDescription(
key="Grass",
@ -88,15 +93,16 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = (
name="Grass pollen",
entity_registry_enabled_default=False,
native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER,
value_fn=lambda data, _: cast(int, data[ATTR_VALUE]),
value_fn=lambda data: cast(int, data[ATTR_VALUE]),
attr_fn=lambda data: {ATTR_LEVEL: data[ATTR_CATEGORY]},
translation_key="grass_pollen",
),
AccuWeatherSensorDescription(
key="HoursOfSun",
icon="mdi:weather-partly-cloudy",
name="Hours of sun",
native_unit_of_measurement=UnitOfTime.HOURS,
value_fn=lambda data, _: cast(float, data),
value_fn=lambda data: cast(float, data),
),
AccuWeatherSensorDescription(
key="Mold",
@ -104,16 +110,9 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = (
name="Mold pollen",
entity_registry_enabled_default=False,
native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER,
value_fn=lambda data, _: cast(int, data[ATTR_VALUE]),
attr_fn=lambda data: {ATTR_LEVEL: data[ATTR_CATEGORY]},
),
AccuWeatherSensorDescription(
key="Ozone",
icon="mdi:vector-triangle",
name="Ozone",
entity_registry_enabled_default=False,
value_fn=lambda data, _: cast(int, data[ATTR_VALUE]),
value_fn=lambda data: cast(int, data[ATTR_VALUE]),
attr_fn=lambda data: {ATTR_LEVEL: data[ATTR_CATEGORY]},
translation_key="mold_pollen",
),
AccuWeatherSensorDescription(
key="Ragweed",
@ -121,56 +120,53 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = (
name="Ragweed pollen",
native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER,
entity_registry_enabled_default=False,
value_fn=lambda data, _: cast(int, data[ATTR_VALUE]),
value_fn=lambda data: cast(int, data[ATTR_VALUE]),
attr_fn=lambda data: {ATTR_LEVEL: data[ATTR_CATEGORY]},
translation_key="ragweed_pollen",
),
AccuWeatherSensorDescription(
key="RealFeelTemperatureMax",
device_class=SensorDeviceClass.TEMPERATURE,
name="RealFeel temperature max",
metric_unit=UnitOfTemperature.CELSIUS,
us_customary_unit=UnitOfTemperature.FAHRENHEIT,
value_fn=lambda data, _: cast(float, data[ATTR_VALUE]),
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
value_fn=lambda data: cast(float, data[ATTR_VALUE]),
),
AccuWeatherSensorDescription(
key="RealFeelTemperatureMin",
device_class=SensorDeviceClass.TEMPERATURE,
name="RealFeel temperature min",
metric_unit=UnitOfTemperature.CELSIUS,
us_customary_unit=UnitOfTemperature.FAHRENHEIT,
value_fn=lambda data, _: cast(float, data[ATTR_VALUE]),
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
value_fn=lambda data: cast(float, data[ATTR_VALUE]),
),
AccuWeatherSensorDescription(
key="RealFeelTemperatureShadeMax",
device_class=SensorDeviceClass.TEMPERATURE,
name="RealFeel temperature shade max",
entity_registry_enabled_default=False,
metric_unit=UnitOfTemperature.CELSIUS,
us_customary_unit=UnitOfTemperature.FAHRENHEIT,
value_fn=lambda data, _: cast(float, data[ATTR_VALUE]),
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
value_fn=lambda data: cast(float, data[ATTR_VALUE]),
),
AccuWeatherSensorDescription(
key="RealFeelTemperatureShadeMin",
device_class=SensorDeviceClass.TEMPERATURE,
name="RealFeel temperature shade min",
entity_registry_enabled_default=False,
metric_unit=UnitOfTemperature.CELSIUS,
us_customary_unit=UnitOfTemperature.FAHRENHEIT,
value_fn=lambda data, _: cast(float, data[ATTR_VALUE]),
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
value_fn=lambda data: cast(float, data[ATTR_VALUE]),
),
AccuWeatherSensorDescription(
key="ThunderstormProbabilityDay",
icon="mdi:weather-lightning",
name="Thunderstorm probability day",
native_unit_of_measurement=PERCENTAGE,
value_fn=lambda data, _: cast(int, data),
value_fn=lambda data: cast(int, data),
),
AccuWeatherSensorDescription(
key="ThunderstormProbabilityNight",
icon="mdi:weather-lightning",
name="Thunderstorm probability night",
native_unit_of_measurement=PERCENTAGE,
value_fn=lambda data, _: cast(int, data),
value_fn=lambda data: cast(int, data),
),
AccuWeatherSensorDescription(
key="Tree",
@ -178,25 +174,26 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = (
name="Tree pollen",
native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER,
entity_registry_enabled_default=False,
value_fn=lambda data, _: cast(int, data[ATTR_VALUE]),
value_fn=lambda data: cast(int, data[ATTR_VALUE]),
attr_fn=lambda data: {ATTR_LEVEL: data[ATTR_CATEGORY]},
translation_key="tree_pollen",
),
AccuWeatherSensorDescription(
key="UVIndex",
icon="mdi:weather-sunny",
name="UV index",
native_unit_of_measurement=UV_INDEX,
value_fn=lambda data, _: cast(int, data[ATTR_VALUE]),
value_fn=lambda data: cast(int, data[ATTR_VALUE]),
attr_fn=lambda data: {ATTR_LEVEL: data[ATTR_CATEGORY]},
translation_key="uv_index",
),
AccuWeatherSensorDescription(
key="WindGustDay",
device_class=SensorDeviceClass.WIND_SPEED,
name="Wind gust day",
entity_registry_enabled_default=False,
metric_unit=UnitOfSpeed.KILOMETERS_PER_HOUR,
us_customary_unit=UnitOfSpeed.MILES_PER_HOUR,
value_fn=lambda data, _: cast(float, data[ATTR_SPEED][ATTR_VALUE]),
native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR,
value_fn=lambda data: cast(float, data[ATTR_SPEED][ATTR_VALUE]),
attr_fn=lambda data: {"direction": data[ATTR_DIRECTION][ATTR_ENGLISH]},
),
AccuWeatherSensorDescription(
@ -204,27 +201,24 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = (
device_class=SensorDeviceClass.WIND_SPEED,
name="Wind gust night",
entity_registry_enabled_default=False,
metric_unit=UnitOfSpeed.KILOMETERS_PER_HOUR,
us_customary_unit=UnitOfSpeed.MILES_PER_HOUR,
value_fn=lambda data, _: cast(float, data[ATTR_SPEED][ATTR_VALUE]),
native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR,
value_fn=lambda data: cast(float, data[ATTR_SPEED][ATTR_VALUE]),
attr_fn=lambda data: {"direction": data[ATTR_DIRECTION][ATTR_ENGLISH]},
),
AccuWeatherSensorDescription(
key="WindDay",
device_class=SensorDeviceClass.WIND_SPEED,
name="Wind day",
metric_unit=UnitOfSpeed.KILOMETERS_PER_HOUR,
us_customary_unit=UnitOfSpeed.MILES_PER_HOUR,
value_fn=lambda data, _: cast(float, data[ATTR_SPEED][ATTR_VALUE]),
native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR,
value_fn=lambda data: cast(float, data[ATTR_SPEED][ATTR_VALUE]),
attr_fn=lambda data: {"direction": data[ATTR_DIRECTION][ATTR_ENGLISH]},
),
AccuWeatherSensorDescription(
key="WindNight",
device_class=SensorDeviceClass.WIND_SPEED,
name="Wind night",
metric_unit=UnitOfSpeed.KILOMETERS_PER_HOUR,
us_customary_unit=UnitOfSpeed.MILES_PER_HOUR,
value_fn=lambda data, _: cast(float, data[ATTR_SPEED][ATTR_VALUE]),
native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR,
value_fn=lambda data: cast(float, data[ATTR_SPEED][ATTR_VALUE]),
attr_fn=lambda data: {"direction": data[ATTR_DIRECTION][ATTR_ENGLISH]},
),
)
@ -236,9 +230,8 @@ SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = (
name="Apparent temperature",
entity_registry_enabled_default=False,
state_class=SensorStateClass.MEASUREMENT,
metric_unit=UnitOfTemperature.CELSIUS,
us_customary_unit=UnitOfTemperature.FAHRENHEIT,
value_fn=lambda data, unit: cast(float, data[unit][ATTR_VALUE]),
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
value_fn=lambda data: cast(float, data[API_METRIC][ATTR_VALUE]),
),
AccuWeatherSensorDescription(
key="Ceiling",
@ -246,9 +239,8 @@ SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = (
icon="mdi:weather-fog",
name="Cloud ceiling",
state_class=SensorStateClass.MEASUREMENT,
metric_unit=UnitOfLength.METERS,
us_customary_unit=UnitOfLength.FEET,
value_fn=lambda data, unit: cast(float, data[unit][ATTR_VALUE]),
native_unit_of_measurement=UnitOfLength.METERS,
value_fn=lambda data: cast(float, data[API_METRIC][ATTR_VALUE]),
suggested_display_precision=0,
),
AccuWeatherSensorDescription(
@ -258,7 +250,7 @@ SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = (
entity_registry_enabled_default=False,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=PERCENTAGE,
value_fn=lambda data, _: cast(int, data),
value_fn=lambda data: cast(int, data),
),
AccuWeatherSensorDescription(
key="DewPoint",
@ -266,18 +258,16 @@ SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = (
name="Dew point",
entity_registry_enabled_default=False,
state_class=SensorStateClass.MEASUREMENT,
metric_unit=UnitOfTemperature.CELSIUS,
us_customary_unit=UnitOfTemperature.FAHRENHEIT,
value_fn=lambda data, unit: cast(float, data[unit][ATTR_VALUE]),
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
value_fn=lambda data: cast(float, data[API_METRIC][ATTR_VALUE]),
),
AccuWeatherSensorDescription(
key="RealFeelTemperature",
device_class=SensorDeviceClass.TEMPERATURE,
name="RealFeel temperature",
state_class=SensorStateClass.MEASUREMENT,
metric_unit=UnitOfTemperature.CELSIUS,
us_customary_unit=UnitOfTemperature.FAHRENHEIT,
value_fn=lambda data, unit: cast(float, data[unit][ATTR_VALUE]),
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
value_fn=lambda data: cast(float, data[API_METRIC][ATTR_VALUE]),
),
AccuWeatherSensorDescription(
key="RealFeelTemperatureShade",
@ -285,18 +275,16 @@ SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = (
name="RealFeel temperature shade",
entity_registry_enabled_default=False,
state_class=SensorStateClass.MEASUREMENT,
metric_unit=UnitOfTemperature.CELSIUS,
us_customary_unit=UnitOfTemperature.FAHRENHEIT,
value_fn=lambda data, unit: cast(float, data[unit][ATTR_VALUE]),
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
value_fn=lambda data: cast(float, data[API_METRIC][ATTR_VALUE]),
),
AccuWeatherSensorDescription(
key="Precipitation",
device_class=SensorDeviceClass.PRECIPITATION_INTENSITY,
name="Precipitation",
state_class=SensorStateClass.MEASUREMENT,
metric_unit=UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR,
us_customary_unit=UnitOfVolumetricFlux.INCHES_PER_HOUR,
value_fn=lambda data, unit: cast(float, data[unit][ATTR_VALUE]),
native_unit_of_measurement=UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR,
value_fn=lambda data: cast(float, data[API_METRIC][ATTR_VALUE]),
attr_fn=lambda data: {"type": data["PrecipitationType"]},
),
AccuWeatherSensorDescription(
@ -306,7 +294,7 @@ SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = (
name="Pressure tendency",
options=["falling", "rising", "steady"],
translation_key="pressure_tendency",
value_fn=lambda data, _: cast(str, data["LocalizedText"]).lower(),
value_fn=lambda data: cast(str, data["LocalizedText"]).lower(),
),
AccuWeatherSensorDescription(
key="UVIndex",
@ -314,7 +302,7 @@ SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = (
name="UV index",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UV_INDEX,
value_fn=lambda data, _: cast(int, data),
value_fn=lambda data: cast(int, data),
attr_fn=lambda data: {ATTR_LEVEL: data["UVIndexText"]},
),
AccuWeatherSensorDescription(
@ -323,9 +311,8 @@ SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = (
name="Wet bulb temperature",
entity_registry_enabled_default=False,
state_class=SensorStateClass.MEASUREMENT,
metric_unit=UnitOfTemperature.CELSIUS,
us_customary_unit=UnitOfTemperature.FAHRENHEIT,
value_fn=lambda data, unit: cast(float, data[unit][ATTR_VALUE]),
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
value_fn=lambda data: cast(float, data[API_METRIC][ATTR_VALUE]),
),
AccuWeatherSensorDescription(
key="WindChillTemperature",
@ -333,18 +320,16 @@ SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = (
name="Wind chill temperature",
entity_registry_enabled_default=False,
state_class=SensorStateClass.MEASUREMENT,
metric_unit=UnitOfTemperature.CELSIUS,
us_customary_unit=UnitOfTemperature.FAHRENHEIT,
value_fn=lambda data, unit: cast(float, data[unit][ATTR_VALUE]),
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
value_fn=lambda data: cast(float, data[API_METRIC][ATTR_VALUE]),
),
AccuWeatherSensorDescription(
key="Wind",
device_class=SensorDeviceClass.WIND_SPEED,
name="Wind",
state_class=SensorStateClass.MEASUREMENT,
metric_unit=UnitOfSpeed.KILOMETERS_PER_HOUR,
us_customary_unit=UnitOfSpeed.MILES_PER_HOUR,
value_fn=lambda data, unit: cast(float, data[ATTR_SPEED][unit][ATTR_VALUE]),
native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR,
value_fn=lambda data: cast(float, data[ATTR_SPEED][API_METRIC][ATTR_VALUE]),
),
AccuWeatherSensorDescription(
key="WindGust",
@ -352,9 +337,8 @@ SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = (
name="Wind gust",
entity_registry_enabled_default=False,
state_class=SensorStateClass.MEASUREMENT,
metric_unit=UnitOfSpeed.KILOMETERS_PER_HOUR,
us_customary_unit=UnitOfSpeed.MILES_PER_HOUR,
value_fn=lambda data, unit: cast(float, data[ATTR_SPEED][unit][ATTR_VALUE]),
native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR,
value_fn=lambda data: cast(float, data[ATTR_SPEED][API_METRIC][ATTR_VALUE]),
),
)
@ -374,7 +358,7 @@ async def async_setup_entry(
# Some air quality/allergy sensors are only available for certain
# locations.
sensors.extend(
AccuWeatherForecastSensor(coordinator, description, forecast_day=day)
AccuWeatherSensor(coordinator, description, forecast_day=day)
for day in range(MAX_FORECAST_DAYS + 1)
for description in FORECAST_SENSOR_TYPES
if description.key in coordinator.data[ATTR_FORECAST][0]
@ -413,34 +397,27 @@ class AccuWeatherSensor(
self._attr_unique_id = (
f"{coordinator.location_key}-{description.key}".lower()
)
self._attr_native_unit_of_measurement = description.native_unit_of_measurement
if self.coordinator.hass.config.units is METRIC_SYSTEM:
self._unit_system = API_METRIC
if metric_unit := description.metric_unit:
self._attr_native_unit_of_measurement = metric_unit
else:
self._unit_system = API_IMPERIAL
if us_customary_unit := description.us_customary_unit:
self._attr_native_unit_of_measurement = us_customary_unit
self._attr_device_info = coordinator.device_info
if forecast_day is not None:
self.forecast_day = forecast_day
self.forecast_day = forecast_day
@property
def native_value(self) -> StateType:
"""Return the state."""
return self.entity_description.value_fn(self._sensor_data, self._unit_system)
return self.entity_description.value_fn(self._sensor_data)
@property
def extra_state_attributes(self) -> dict[str, Any]:
"""Return the state attributes."""
if self.forecast_day is not None:
return self.entity_description.attr_fn(self._sensor_data)
return self.entity_description.attr_fn(self.coordinator.data)
@callback
def _handle_coordinator_update(self) -> None:
"""Handle data update."""
self._sensor_data = _get_sensor_data(
self.coordinator.data, self.entity_description.key
self.coordinator.data, self.entity_description.key, self.forecast_day
)
self.async_write_ha_state()
@ -458,20 +435,3 @@ def _get_sensor_data(
return sensors["PrecipitationSummary"]["PastHour"]
return sensors[kind]
class AccuWeatherForecastSensor(AccuWeatherSensor):
"""Define an AccuWeather forecast entity."""
@property
def extra_state_attributes(self) -> dict[str, Any]:
"""Return the state attributes."""
return self.entity_description.attr_fn(self._sensor_data)
@callback
def _handle_coordinator_update(self) -> None:
"""Handle data update."""
self._sensor_data = _get_sensor_data(
self.coordinator.data, self.entity_description.key, self.forecast_day
)
self.async_write_ha_state()

View File

@ -30,6 +30,91 @@
"rising": "Rising",
"falling": "Falling"
}
},
"air_quality": {
"state": {
"good": "Good",
"hazardous": "Hazardous",
"high": "High",
"low": "Low",
"moderate": "Moderate",
"unhealthy": "Unhealthy"
}
},
"grass_pollen": {
"state_attributes": {
"level": {
"name": "Level",
"state": {
"good": "[%key:component::accuweather::entity::sensor::air_quality::state::good%]",
"hazardous": "[%key:component::accuweather::entity::sensor::air_quality::state::hazardous%]",
"high": "[%key:component::accuweather::entity::sensor::air_quality::state::high%]",
"low": "[%key:component::accuweather::entity::sensor::air_quality::state::low%]",
"moderate": "[%key:component::accuweather::entity::sensor::air_quality::state::moderate%]",
"unhealthy": "[%key:component::accuweather::entity::sensor::air_quality::state::unhealthy%]"
}
}
}
},
"mold_pollen": {
"state_attributes": {
"level": {
"name": "Level",
"state": {
"good": "[%key:component::accuweather::entity::sensor::air_quality::state::good%]",
"hazardous": "[%key:component::accuweather::entity::sensor::air_quality::state::hazardous%]",
"high": "[%key:component::accuweather::entity::sensor::air_quality::state::high%]",
"low": "[%key:component::accuweather::entity::sensor::air_quality::state::low%]",
"moderate": "[%key:component::accuweather::entity::sensor::air_quality::state::moderate%]",
"unhealthy": "[%key:component::accuweather::entity::sensor::air_quality::state::unhealthy%]"
}
}
}
},
"ragweed_pollen": {
"state_attributes": {
"level": {
"name": "Level",
"state": {
"good": "[%key:component::accuweather::entity::sensor::air_quality::state::good%]",
"hazardous": "[%key:component::accuweather::entity::sensor::air_quality::state::hazardous%]",
"high": "[%key:component::accuweather::entity::sensor::air_quality::state::high%]",
"low": "[%key:component::accuweather::entity::sensor::air_quality::state::low%]",
"moderate": "[%key:component::accuweather::entity::sensor::air_quality::state::moderate%]",
"unhealthy": "[%key:component::accuweather::entity::sensor::air_quality::state::unhealthy%]"
}
}
}
},
"tree_pollen": {
"state_attributes": {
"level": {
"name": "Level",
"state": {
"good": "[%key:component::accuweather::entity::sensor::air_quality::state::good%]",
"hazardous": "[%key:component::accuweather::entity::sensor::air_quality::state::hazardous%]",
"high": "[%key:component::accuweather::entity::sensor::air_quality::state::high%]",
"low": "[%key:component::accuweather::entity::sensor::air_quality::state::low%]",
"moderate": "[%key:component::accuweather::entity::sensor::air_quality::state::moderate%]",
"unhealthy": "[%key:component::accuweather::entity::sensor::air_quality::state::unhealthy%]"
}
}
}
},
"uv_index": {
"state_attributes": {
"level": {
"name": "Level",
"state": {
"good": "[%key:component::accuweather::entity::sensor::air_quality::state::good%]",
"hazardous": "[%key:component::accuweather::entity::sensor::air_quality::state::hazardous%]",
"high": "[%key:component::accuweather::entity::sensor::air_quality::state::high%]",
"low": "[%key:component::accuweather::entity::sensor::air_quality::state::low%]",
"moderate": "[%key:component::accuweather::entity::sensor::air_quality::state::moderate%]",
"unhealthy": "[%key:component::accuweather::entity::sensor::air_quality::state::unhealthy%]"
}
}
}
}
}
},

View File

@ -28,17 +28,9 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util.dt import utc_from_timestamp
from homeassistant.util.unit_system import METRIC_SYSTEM
from . import AccuWeatherDataUpdateCoordinator
from .const import (
API_IMPERIAL,
API_METRIC,
ATTR_FORECAST,
ATTRIBUTION,
CONDITION_CLASSES,
DOMAIN,
)
from .const import API_METRIC, ATTR_FORECAST, ATTRIBUTION, CONDITION_CLASSES, DOMAIN
PARALLEL_UPDATES = 1
@ -66,20 +58,11 @@ class AccuWeatherEntity(
# Coordinator data is used also for sensors which don't have units automatically
# converted, hence the weather entity's native units follow the configured unit
# system
if coordinator.hass.config.units is METRIC_SYSTEM:
self._attr_native_precipitation_unit = UnitOfPrecipitationDepth.MILLIMETERS
self._attr_native_pressure_unit = UnitOfPressure.HPA
self._attr_native_temperature_unit = UnitOfTemperature.CELSIUS
self._attr_native_visibility_unit = UnitOfLength.KILOMETERS
self._attr_native_wind_speed_unit = UnitOfSpeed.KILOMETERS_PER_HOUR
self._unit_system = API_METRIC
else:
self._unit_system = API_IMPERIAL
self._attr_native_precipitation_unit = UnitOfPrecipitationDepth.INCHES
self._attr_native_pressure_unit = UnitOfPressure.INHG
self._attr_native_temperature_unit = UnitOfTemperature.FAHRENHEIT
self._attr_native_visibility_unit = UnitOfLength.MILES
self._attr_native_wind_speed_unit = UnitOfSpeed.MILES_PER_HOUR
self._attr_native_precipitation_unit = UnitOfPrecipitationDepth.MILLIMETERS
self._attr_native_pressure_unit = UnitOfPressure.HPA
self._attr_native_temperature_unit = UnitOfTemperature.CELSIUS
self._attr_native_visibility_unit = UnitOfLength.KILOMETERS
self._attr_native_wind_speed_unit = UnitOfSpeed.KILOMETERS_PER_HOUR
self._attr_unique_id = coordinator.location_key
self._attr_attribution = ATTRIBUTION
self._attr_device_info = coordinator.device_info
@ -99,16 +82,12 @@ class AccuWeatherEntity(
@property
def native_temperature(self) -> float:
"""Return the temperature."""
return cast(
float, self.coordinator.data["Temperature"][self._unit_system]["Value"]
)
return cast(float, self.coordinator.data["Temperature"][API_METRIC]["Value"])
@property
def native_pressure(self) -> float:
"""Return the pressure."""
return cast(
float, self.coordinator.data["Pressure"][self._unit_system]["Value"]
)
return cast(float, self.coordinator.data["Pressure"][API_METRIC]["Value"])
@property
def humidity(self) -> int:
@ -118,9 +97,7 @@ class AccuWeatherEntity(
@property
def native_wind_speed(self) -> float:
"""Return the wind speed."""
return cast(
float, self.coordinator.data["Wind"]["Speed"][self._unit_system]["Value"]
)
return cast(float, self.coordinator.data["Wind"]["Speed"][API_METRIC]["Value"])
@property
def wind_bearing(self) -> int:
@ -130,19 +107,7 @@ class AccuWeatherEntity(
@property
def native_visibility(self) -> float:
"""Return the visibility."""
return cast(
float, self.coordinator.data["Visibility"][self._unit_system]["Value"]
)
@property
def ozone(self) -> int | None:
"""Return the ozone level."""
# We only have ozone data for certain locations and only in the forecast data.
if self.coordinator.forecast and self.coordinator.data[ATTR_FORECAST][0].get(
"Ozone"
):
return cast(int, self.coordinator.data[ATTR_FORECAST][0]["Ozone"]["Value"])
return None
return cast(float, self.coordinator.data["Visibility"][API_METRIC]["Value"])
@property
def forecast(self) -> list[Forecast] | None:

View File

@ -7,11 +7,11 @@ from advantage_air import ApiError, advantage_air
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import ADVANTAGE_AIR_RETRY, DOMAIN
from .models import AdvantageAirData
ADVANTAGE_AIR_SYNC_INTERVAL = 15
PLATFORMS = [
@ -53,29 +53,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
update_interval=timedelta(seconds=ADVANTAGE_AIR_SYNC_INTERVAL),
)
def error_handle_factory(func):
"""Return the provided API function wrapped.
Adds an error handler and coordinator refresh.
"""
async def error_handle(param):
try:
if await func(param):
await coordinator.async_refresh()
except ApiError as err:
raise HomeAssistantError(err) from err
return error_handle
await coordinator.async_config_entry_first_refresh()
hass.data.setdefault(DOMAIN, {})
hass.data[DOMAIN][entry.entry_id] = {
"coordinator": coordinator,
"aircon": error_handle_factory(api.aircon.async_set),
"lights": error_handle_factory(api.lights.async_set),
}
hass.data[DOMAIN][entry.entry_id] = AdvantageAirData(coordinator, api)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)

View File

@ -1,8 +1,6 @@
"""Binary Sensor platform for Advantage Air integration."""
from __future__ import annotations
from typing import Any
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
@ -14,6 +12,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN as ADVANTAGE_AIR_DOMAIN
from .entity import AdvantageAirAcEntity, AdvantageAirZoneEntity
from .models import AdvantageAirData
PARALLEL_UPDATES = 0
@ -25,10 +24,10 @@ async def async_setup_entry(
) -> None:
"""Set up AdvantageAir Binary Sensor platform."""
instance = hass.data[ADVANTAGE_AIR_DOMAIN][config_entry.entry_id]
instance: AdvantageAirData = hass.data[ADVANTAGE_AIR_DOMAIN][config_entry.entry_id]
entities: list[BinarySensorEntity] = []
if aircons := instance["coordinator"].data.get("aircons"):
if aircons := instance.coordinator.data.get("aircons"):
for ac_key, ac_device in aircons.items():
entities.append(AdvantageAirFilter(instance, ac_key))
for zone_key, zone in ac_device["zones"].items():
@ -48,7 +47,7 @@ class AdvantageAirFilter(AdvantageAirAcEntity, BinarySensorEntity):
_attr_entity_category = EntityCategory.DIAGNOSTIC
_attr_name = "Filter"
def __init__(self, instance: dict[str, Any], ac_key: str) -> None:
def __init__(self, instance: AdvantageAirData, ac_key: str) -> None:
"""Initialize an Advantage Air Filter sensor."""
super().__init__(instance, ac_key)
self._attr_unique_id += "-filter"
@ -64,7 +63,7 @@ class AdvantageAirZoneMotion(AdvantageAirZoneEntity, BinarySensorEntity):
_attr_device_class = BinarySensorDeviceClass.MOTION
def __init__(self, instance: dict[str, Any], ac_key: str, zone_key: str) -> None:
def __init__(self, instance: AdvantageAirData, ac_key: str, zone_key: str) -> None:
"""Initialize an Advantage Air Zone Motion sensor."""
super().__init__(instance, ac_key, zone_key)
self._attr_name = f'{self._zone["name"]} motion'
@ -82,7 +81,7 @@ class AdvantageAirZoneMyZone(AdvantageAirZoneEntity, BinarySensorEntity):
_attr_entity_registry_enabled_default = False
_attr_entity_category = EntityCategory.DIAGNOSTIC
def __init__(self, instance: dict[str, Any], ac_key: str, zone_key: str) -> None:
def __init__(self, instance: AdvantageAirData, ac_key: str, zone_key: str) -> None:
"""Initialize an Advantage Air Zone MyZone sensor."""
super().__init__(instance, ac_key, zone_key)
self._attr_name = f'{self._zone["name"]} myZone'

View File

@ -5,6 +5,8 @@ import logging
from typing import Any
from homeassistant.components.climate import (
ATTR_TARGET_TEMP_HIGH,
ATTR_TARGET_TEMP_LOW,
FAN_AUTO,
FAN_HIGH,
FAN_LOW,
@ -26,24 +28,17 @@ from .const import (
DOMAIN as ADVANTAGE_AIR_DOMAIN,
)
from .entity import AdvantageAirAcEntity, AdvantageAirZoneEntity
from .models import AdvantageAirData
ADVANTAGE_AIR_HVAC_MODES = {
"heat": HVACMode.HEAT,
"cool": HVACMode.COOL,
"vent": HVACMode.FAN_ONLY,
"dry": HVACMode.DRY,
"myauto": HVACMode.AUTO,
"myauto": HVACMode.HEAT_COOL,
}
HASS_HVAC_MODES = {v: k for k, v in ADVANTAGE_AIR_HVAC_MODES.items()}
AC_HVAC_MODES = [
HVACMode.OFF,
HVACMode.COOL,
HVACMode.HEAT,
HVACMode.FAN_ONLY,
HVACMode.DRY,
]
ADVANTAGE_AIR_FAN_MODES = {
"autoAA": FAN_AUTO,
"low": FAN_LOW,
@ -53,7 +48,14 @@ ADVANTAGE_AIR_FAN_MODES = {
HASS_FAN_MODES = {v: k for k, v in ADVANTAGE_AIR_FAN_MODES.items()}
FAN_SPEEDS = {FAN_LOW: 30, FAN_MEDIUM: 60, FAN_HIGH: 100}
ZONE_HVAC_MODES = [HVACMode.OFF, HVACMode.HEAT_COOL]
ADVANTAGE_AIR_AUTOFAN = "aaAutoFanModeEnabled"
ADVANTAGE_AIR_MYZONE = "MyZone"
ADVANTAGE_AIR_MYAUTO = "MyAuto"
ADVANTAGE_AIR_MYAUTO_ENABLED = "myAutoModeEnabled"
ADVANTAGE_AIR_MYTEMP = "MyTemp"
ADVANTAGE_AIR_MYTEMP_ENABLED = "climateControlModeEnabled"
ADVANTAGE_AIR_HEAT_TARGET = "myAutoHeatTargetTemp"
ADVANTAGE_AIR_COOL_TARGET = "myAutoCoolTargetTemp"
PARALLEL_UPDATES = 0
@ -67,15 +69,15 @@ async def async_setup_entry(
) -> None:
"""Set up AdvantageAir climate platform."""
instance = hass.data[ADVANTAGE_AIR_DOMAIN][config_entry.entry_id]
instance: AdvantageAirData = hass.data[ADVANTAGE_AIR_DOMAIN][config_entry.entry_id]
entities: list[ClimateEntity] = []
if aircons := instance["coordinator"].data.get("aircons"):
if aircons := instance.coordinator.data.get("aircons"):
for ac_key, ac_device in aircons.items():
entities.append(AdvantageAirAC(instance, ac_key))
for zone_key, zone in ac_device["zones"].items():
# Only add zone climate control when zone is in temperature control
if zone["type"] != 0:
if zone["type"] > 0:
entities.append(AdvantageAirZone(instance, ac_key, zone_key))
async_add_entities(entities)
@ -83,24 +85,56 @@ async def async_setup_entry(
class AdvantageAirAC(AdvantageAirAcEntity, ClimateEntity):
"""AdvantageAir AC unit."""
_attr_fan_modes = [FAN_LOW, FAN_MEDIUM, FAN_HIGH]
_attr_temperature_unit = UnitOfTemperature.CELSIUS
_attr_target_temperature_step = PRECISION_WHOLE
_attr_max_temp = 32
_attr_min_temp = 16
_attr_fan_modes = [FAN_AUTO, FAN_LOW, FAN_MEDIUM, FAN_HIGH]
_attr_hvac_modes = AC_HVAC_MODES
_attr_supported_features = (
ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE
)
def __init__(self, instance: dict[str, Any], ac_key: str) -> None:
def __init__(self, instance: AdvantageAirData, ac_key: str) -> None:
"""Initialize an AdvantageAir AC unit."""
super().__init__(instance, ac_key)
if self._ac.get("myAutoModeEnabled"):
self._attr_hvac_modes = AC_HVAC_MODES + [HVACMode.AUTO]
# Set supported features and HVAC modes based on current operating mode
if self._ac.get(ADVANTAGE_AIR_MYAUTO_ENABLED):
# MyAuto
self._attr_supported_features = (
ClimateEntityFeature.FAN_MODE
| ClimateEntityFeature.TARGET_TEMPERATURE
| ClimateEntityFeature.TARGET_TEMPERATURE_RANGE
)
self._attr_hvac_modes = [
HVACMode.OFF,
HVACMode.COOL,
HVACMode.HEAT,
HVACMode.FAN_ONLY,
HVACMode.DRY,
HVACMode.HEAT_COOL,
]
elif self._ac.get(ADVANTAGE_AIR_MYTEMP_ENABLED):
# MyTemp
self._attr_supported_features = ClimateEntityFeature.FAN_MODE
self._attr_hvac_modes = [HVACMode.OFF, HVACMode.COOL, HVACMode.HEAT]
else:
# MyZone
self._attr_supported_features = (
ClimateEntityFeature.FAN_MODE | ClimateEntityFeature.TARGET_TEMPERATURE
)
self._attr_hvac_modes = [
HVACMode.OFF,
HVACMode.COOL,
HVACMode.HEAT,
HVACMode.FAN_ONLY,
HVACMode.DRY,
]
# Add "ezfan" mode if supported
if self._ac.get(ADVANTAGE_AIR_AUTOFAN):
self._attr_fan_modes += [FAN_AUTO]
@property
def target_temperature(self) -> float:
def target_temperature(self) -> float | None:
"""Return the current target temperature."""
return self._ac["setTemp"]
@ -116,77 +150,71 @@ class AdvantageAirAC(AdvantageAirAcEntity, ClimateEntity):
"""Return the current fan modes."""
return ADVANTAGE_AIR_FAN_MODES.get(self._ac["fan"])
@property
def target_temperature_high(self) -> float | None:
"""Return the temperature cool mode is enabled."""
return self._ac.get(ADVANTAGE_AIR_COOL_TARGET)
@property
def target_temperature_low(self) -> float | None:
"""Return the temperature heat mode is enabled."""
return self._ac.get(ADVANTAGE_AIR_HEAT_TARGET)
async def async_turn_on(self) -> None:
"""Set the HVAC State to on."""
await self.aircon(
{
self.ac_key: {
"info": {
"state": ADVANTAGE_AIR_STATE_ON,
}
}
}
)
await self.async_update_ac({"state": ADVANTAGE_AIR_STATE_ON})
async def async_turn_off(self) -> None:
"""Set the HVAC State to off."""
await self.aircon(
await self.async_update_ac(
{
self.ac_key: {
"info": {
"state": ADVANTAGE_AIR_STATE_OFF,
}
}
"state": ADVANTAGE_AIR_STATE_OFF,
}
)
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set the HVAC Mode and State."""
if hvac_mode == HVACMode.OFF:
await self.aircon(
{self.ac_key: {"info": {"state": ADVANTAGE_AIR_STATE_OFF}}}
)
await self.async_update_ac({"state": ADVANTAGE_AIR_STATE_OFF})
else:
await self.aircon(
await self.async_update_ac(
{
self.ac_key: {
"info": {
"state": ADVANTAGE_AIR_STATE_ON,
"mode": HASS_HVAC_MODES.get(hvac_mode),
}
}
"state": ADVANTAGE_AIR_STATE_ON,
"mode": HASS_HVAC_MODES.get(hvac_mode),
}
)
async def async_set_fan_mode(self, fan_mode: str) -> None:
"""Set the Fan Mode."""
await self.aircon(
{self.ac_key: {"info": {"fan": HASS_FAN_MODES.get(fan_mode)}}}
)
await self.async_update_ac({"fan": HASS_FAN_MODES.get(fan_mode)})
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set the Temperature."""
temp = kwargs.get(ATTR_TEMPERATURE)
await self.aircon({self.ac_key: {"info": {"setTemp": temp}}})
if ATTR_TEMPERATURE in kwargs:
await self.async_update_ac({"setTemp": kwargs[ATTR_TEMPERATURE]})
if ATTR_TARGET_TEMP_LOW in kwargs and ATTR_TARGET_TEMP_HIGH in kwargs:
await self.async_update_ac(
{
ADVANTAGE_AIR_COOL_TARGET: kwargs[ATTR_TARGET_TEMP_HIGH],
ADVANTAGE_AIR_HEAT_TARGET: kwargs[ATTR_TARGET_TEMP_LOW],
}
)
class AdvantageAirZone(AdvantageAirZoneEntity, ClimateEntity):
"""AdvantageAir Zone control."""
"""AdvantageAir MyTemp Zone control."""
_attr_hvac_modes = [HVACMode.OFF, HVACMode.HEAT_COOL]
_attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE
_attr_temperature_unit = UnitOfTemperature.CELSIUS
_attr_target_temperature_step = PRECISION_WHOLE
_attr_max_temp = 32
_attr_min_temp = 16
_attr_hvac_modes = ZONE_HVAC_MODES
_attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE
def __init__(self, instance: dict[str, Any], ac_key: str, zone_key: str) -> None:
def __init__(self, instance: AdvantageAirData, ac_key: str, zone_key: str) -> None:
"""Initialize an AdvantageAir Zone control."""
super().__init__(instance, ac_key, zone_key)
self._attr_name = self._zone["name"]
self._attr_unique_id = (
f'{self.coordinator.data["system"]["rid"]}-{ac_key}-{zone_key}'
)
@property
def hvac_mode(self) -> HVACMode:
@ -196,7 +224,7 @@ class AdvantageAirZone(AdvantageAirZoneEntity, ClimateEntity):
return HVACMode.OFF
@property
def current_temperature(self) -> float:
def current_temperature(self) -> float | None:
"""Return the current temperature."""
return self._zone["measuredTemp"]
@ -207,23 +235,11 @@ class AdvantageAirZone(AdvantageAirZoneEntity, ClimateEntity):
async def async_turn_on(self) -> None:
"""Set the HVAC State to on."""
await self.aircon(
{
self.ac_key: {
"zones": {self.zone_key: {"state": ADVANTAGE_AIR_STATE_OPEN}}
}
}
)
await self.async_update_zone({"state": ADVANTAGE_AIR_STATE_OPEN})
async def async_turn_off(self) -> None:
"""Set the HVAC State to off."""
await self.aircon(
{
self.ac_key: {
"zones": {self.zone_key: {"state": ADVANTAGE_AIR_STATE_CLOSE}}
}
}
)
await self.async_update_zone({"state": ADVANTAGE_AIR_STATE_CLOSE})
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set the HVAC Mode and State."""
@ -235,4 +251,4 @@ class AdvantageAirZone(AdvantageAirZoneEntity, ClimateEntity):
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set the Temperature."""
temp = kwargs.get(ATTR_TEMPERATURE)
await self.aircon({self.ac_key: {"zones": {self.zone_key: {"setTemp": temp}}}})
await self.async_update_zone({"setTemp": temp})

View File

@ -16,7 +16,8 @@ from .const import (
ADVANTAGE_AIR_STATE_OPEN,
DOMAIN as ADVANTAGE_AIR_DOMAIN,
)
from .entity import AdvantageAirZoneEntity
from .entity import AdvantageAirThingEntity, AdvantageAirZoneEntity
from .models import AdvantageAirData
PARALLEL_UPDATES = 0
@ -28,15 +29,25 @@ async def async_setup_entry(
) -> None:
"""Set up AdvantageAir cover platform."""
instance = hass.data[ADVANTAGE_AIR_DOMAIN][config_entry.entry_id]
instance: AdvantageAirData = hass.data[ADVANTAGE_AIR_DOMAIN][config_entry.entry_id]
entities: list[CoverEntity] = []
if aircons := instance["coordinator"].data.get("aircons"):
if aircons := instance.coordinator.data.get("aircons"):
for ac_key, ac_device in aircons.items():
for zone_key, zone in ac_device["zones"].items():
# Only add zone vent controls when zone in vent control mode.
if zone["type"] == 0:
entities.append(AdvantageAirZoneVent(instance, ac_key, zone_key))
if things := instance.coordinator.data.get("myThings"):
for thing in things["things"].values():
if thing["channelDipState"] in [1, 2]: # 1 = "Blind", 2 = "Blind 2"
entities.append(
AdvantageAirThingCover(instance, thing, CoverDeviceClass.BLIND)
)
elif thing["channelDipState"] == 3: # 3 = "Garage door"
entities.append(
AdvantageAirThingCover(instance, thing, CoverDeviceClass.GARAGE)
)
async_add_entities(entities)
@ -50,7 +61,7 @@ class AdvantageAirZoneVent(AdvantageAirZoneEntity, CoverEntity):
| CoverEntityFeature.SET_POSITION
)
def __init__(self, instance: dict[str, Any], ac_key: str, zone_key: str) -> None:
def __init__(self, instance: AdvantageAirData, ac_key: str, zone_key: str) -> None:
"""Initialize an Advantage Air Zone Vent."""
super().__init__(instance, ac_key, zone_key)
self._attr_name = self._zone["name"]
@ -69,47 +80,52 @@ class AdvantageAirZoneVent(AdvantageAirZoneEntity, CoverEntity):
async def async_open_cover(self, **kwargs: Any) -> None:
"""Fully open zone vent."""
await self.aircon(
{
self.ac_key: {
"zones": {
self.zone_key: {"state": ADVANTAGE_AIR_STATE_OPEN, "value": 100}
}
}
}
await self.async_update_zone(
{"state": ADVANTAGE_AIR_STATE_OPEN, "value": 100},
)
async def async_close_cover(self, **kwargs: Any) -> None:
"""Fully close zone vent."""
await self.aircon(
{
self.ac_key: {
"zones": {self.zone_key: {"state": ADVANTAGE_AIR_STATE_CLOSE}}
}
}
)
await self.async_update_zone({"state": ADVANTAGE_AIR_STATE_CLOSE})
async def async_set_cover_position(self, **kwargs: Any) -> None:
"""Change vent position."""
position = round(kwargs[ATTR_POSITION] / 5) * 5
if position == 0:
await self.aircon(
{
self.ac_key: {
"zones": {self.zone_key: {"state": ADVANTAGE_AIR_STATE_CLOSE}}
}
}
)
await self.async_update_zone({"state": ADVANTAGE_AIR_STATE_CLOSE})
else:
await self.aircon(
await self.async_update_zone(
{
self.ac_key: {
"zones": {
self.zone_key: {
"state": ADVANTAGE_AIR_STATE_OPEN,
"value": position,
}
}
}
"state": ADVANTAGE_AIR_STATE_OPEN,
"value": position,
}
)
class AdvantageAirThingCover(AdvantageAirThingEntity, CoverEntity):
"""Representation of Advantage Air Cover controlled by MyPlace."""
_attr_supported_features = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE
def __init__(
self,
instance: AdvantageAirData,
thing: dict[str, Any],
device_class: CoverDeviceClass,
) -> None:
"""Initialize an Advantage Air Things Cover."""
super().__init__(instance, thing)
self._attr_device_class = device_class
@property
def is_closed(self) -> bool:
"""Return if cover is fully closed."""
return self._data["value"] == 0
async def async_open_cover(self, **kwargs: Any) -> None:
"""Fully open zone vent."""
return await self.async_turn_on()
async def async_close_cover(self, **kwargs: Any) -> None:
"""Fully close zone vent."""
return await self.async_turn_off()

View File

@ -1,11 +1,14 @@
"""Advantage Air parent entity class."""
from typing import Any
from advantage_air import ApiError
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .models import AdvantageAirData
class AdvantageAirEntity(CoordinatorEntity):
@ -13,19 +16,34 @@ class AdvantageAirEntity(CoordinatorEntity):
_attr_has_entity_name = True
def __init__(self, instance: dict[str, Any]) -> None:
def __init__(self, instance: AdvantageAirData) -> None:
"""Initialize common aspects of an Advantage Air entity."""
super().__init__(instance["coordinator"])
super().__init__(instance.coordinator)
self._attr_unique_id: str = self.coordinator.data["system"]["rid"]
def update_handle_factory(self, func, *keys):
"""Return the provided API function wrapped.
Adds an error handler and coordinator refresh, and presets keys.
"""
async def update_handle(*values):
try:
if await func(*keys, *values):
await self.coordinator.async_refresh()
except ApiError as err:
raise HomeAssistantError(err) from err
return update_handle
class AdvantageAirAcEntity(AdvantageAirEntity):
"""Parent class for Advantage Air AC Entities."""
def __init__(self, instance: dict[str, Any], ac_key: str) -> None:
def __init__(self, instance: AdvantageAirData, ac_key: str) -> None:
"""Initialize common aspects of an Advantage Air ac entity."""
super().__init__(instance)
self.aircon = instance["aircon"]
self.ac_key: str = ac_key
self._attr_unique_id += f"-{ac_key}"
@ -36,6 +54,9 @@ class AdvantageAirAcEntity(AdvantageAirEntity):
model=self.coordinator.data["system"]["sysType"],
name=self.coordinator.data["aircons"][self.ac_key]["info"]["name"],
)
self.async_update_ac = self.update_handle_factory(
instance.api.aircon.async_update_ac, self.ac_key
)
@property
def _ac(self) -> dict[str, Any]:
@ -45,12 +66,56 @@ class AdvantageAirAcEntity(AdvantageAirEntity):
class AdvantageAirZoneEntity(AdvantageAirAcEntity):
"""Parent class for Advantage Air Zone Entities."""
def __init__(self, instance: dict[str, Any], ac_key: str, zone_key: str) -> None:
def __init__(self, instance: AdvantageAirData, ac_key: str, zone_key: str) -> None:
"""Initialize common aspects of an Advantage Air zone entity."""
super().__init__(instance, ac_key)
self.zone_key: str = zone_key
self._attr_unique_id += f"-{zone_key}"
self.async_update_zone = self.update_handle_factory(
instance.api.aircon.async_update_zone, self.ac_key, self.zone_key
)
@property
def _zone(self) -> dict[str, Any]:
return self.coordinator.data["aircons"][self.ac_key]["zones"][self.zone_key]
class AdvantageAirThingEntity(AdvantageAirEntity):
"""Parent class for Advantage Air Things Entities."""
def __init__(self, instance: AdvantageAirData, thing: dict[str, Any]) -> None:
"""Initialize common aspects of an Advantage Air Things entity."""
super().__init__(instance)
self._id = thing["id"]
self._attr_unique_id += f"-{self._id}"
self._attr_device_info = DeviceInfo(
via_device=(DOMAIN, self.coordinator.data["system"]["rid"]),
identifiers={(DOMAIN, self._attr_unique_id)},
manufacturer="Advantage Air",
model="MyPlace",
name=thing["name"],
)
self.async_update_value = self.update_handle_factory(
instance.api.things.async_update_value, self._id
)
@property
def _data(self) -> dict:
"""Return the thing data."""
return self.coordinator.data["myThings"]["things"][self._id]
@property
def is_on(self):
"""Return if the thing is considered on."""
return self._data["value"] > 0
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the thing on."""
await self.async_update_value(True)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the thing off."""
await self.async_update_value(False)

View File

@ -7,12 +7,9 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import (
ADVANTAGE_AIR_STATE_OFF,
ADVANTAGE_AIR_STATE_ON,
DOMAIN as ADVANTAGE_AIR_DOMAIN,
)
from .entity import AdvantageAirEntity
from .const import ADVANTAGE_AIR_STATE_ON, DOMAIN as ADVANTAGE_AIR_DOMAIN
from .entity import AdvantageAirEntity, AdvantageAirThingEntity
from .models import AdvantageAirData
async def async_setup_entry(
@ -22,15 +19,21 @@ async def async_setup_entry(
) -> None:
"""Set up AdvantageAir light platform."""
instance = hass.data[ADVANTAGE_AIR_DOMAIN][config_entry.entry_id]
instance: AdvantageAirData = hass.data[ADVANTAGE_AIR_DOMAIN][config_entry.entry_id]
entities: list[LightEntity] = []
if my_lights := instance["coordinator"].data.get("myLights"):
if my_lights := instance.coordinator.data.get("myLights"):
for light in my_lights["lights"].values():
if light.get("relay"):
entities.append(AdvantageAirLight(instance, light))
else:
entities.append(AdvantageAirLightDimmable(instance, light))
if things := instance.coordinator.data.get("myThings"):
for thing in things["things"].values():
if thing["channelDipState"] == 4: # 4 = "Light (on/off)""
entities.append(AdvantageAirThingLight(instance, thing))
elif thing["channelDipState"] == 5: # 5 = "Light (Dimmable)""
entities.append(AdvantageAirThingLightDimmable(instance, thing))
async_add_entities(entities)
@ -39,10 +42,10 @@ class AdvantageAirLight(AdvantageAirEntity, LightEntity):
_attr_supported_color_modes = {ColorMode.ONOFF}
def __init__(self, instance: dict[str, Any], light: dict[str, Any]) -> None:
def __init__(self, instance: AdvantageAirData, light: dict[str, Any]) -> None:
"""Initialize an Advantage Air Light."""
super().__init__(instance)
self.lights = instance["lights"]
self._id: str = light["id"]
self._attr_unique_id += f"-{self._id}"
self._attr_device_info = DeviceInfo(
@ -52,24 +55,27 @@ class AdvantageAirLight(AdvantageAirEntity, LightEntity):
model=light.get("moduleType"),
name=light["name"],
)
self.async_update_state = self.update_handle_factory(
instance.api.lights.async_update_state, self._id
)
@property
def _light(self) -> dict[str, Any]:
def _data(self) -> dict[str, Any]:
"""Return the light object."""
return self.coordinator.data["myLights"]["lights"][self._id]
@property
def is_on(self) -> bool:
"""Return if the light is on."""
return self._light["state"] == ADVANTAGE_AIR_STATE_ON
return self._data["state"] == ADVANTAGE_AIR_STATE_ON
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the light on."""
await self.lights({"id": self._id, "state": ADVANTAGE_AIR_STATE_ON})
await self.async_update_state(True)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the light off."""
await self.lights({"id": self._id, "state": ADVANTAGE_AIR_STATE_OFF})
await self.async_update_state(False)
class AdvantageAirLightDimmable(AdvantageAirLight):
@ -77,14 +83,41 @@ class AdvantageAirLightDimmable(AdvantageAirLight):
_attr_supported_color_modes = {ColorMode.ONOFF, ColorMode.BRIGHTNESS}
def __init__(self, instance: AdvantageAirData, light: dict[str, Any]) -> None:
"""Initialize an Advantage Air Dimmable Light."""
super().__init__(instance, light)
self.async_update_value = self.update_handle_factory(
instance.api.lights.async_update_value, self._id
)
@property
def brightness(self) -> int:
"""Return the brightness of this light between 0..255."""
return round(self._light["value"] * 255 / 100)
return round(self._data["value"] * 255 / 100)
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the light on and optionally set the brightness."""
data: dict[str, Any] = {"id": self._id, "state": ADVANTAGE_AIR_STATE_ON}
if ATTR_BRIGHTNESS in kwargs:
data["value"] = round(kwargs[ATTR_BRIGHTNESS] * 100 / 255)
await self.lights(data)
return await self.async_update_value(round(kwargs[ATTR_BRIGHTNESS] / 2.55))
return await self.async_update_state(True)
class AdvantageAirThingLight(AdvantageAirThingEntity, LightEntity):
"""Representation of Advantage Air Light controlled by myThings."""
_attr_supported_color_modes = {ColorMode.ONOFF}
class AdvantageAirThingLightDimmable(AdvantageAirThingEntity, LightEntity):
"""Representation of Advantage Air Dimmable Light controlled by myThings."""
_attr_supported_color_modes = {ColorMode.ONOFF, ColorMode.BRIGHTNESS}
@property
def brightness(self) -> int:
"""Return the brightness of this light between 0..255."""
return round(self._data["value"] * 255 / 100)
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the light on by setting the brightness."""
await self.async_update_value(round(kwargs.get(ATTR_BRIGHTNESS, 255) / 2.55))

View File

@ -7,5 +7,5 @@
"iot_class": "local_polling",
"loggers": ["advantage_air"],
"quality_scale": "platinum",
"requirements": ["advantage_air==0.4.1"]
"requirements": ["advantage_air==0.4.4"]
}

View File

@ -0,0 +1,16 @@
"""The Advantage Air integration models."""
from __future__ import annotations
from dataclasses import dataclass
from advantage_air import advantage_air
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
@dataclass
class AdvantageAirData:
"""Data for the Advantage Air integration."""
coordinator: DataUpdateCoordinator
api: advantage_air

View File

@ -1,5 +1,4 @@
"""Select platform for Advantage Air integration."""
from typing import Any
from homeassistant.components.select import SelectEntity
from homeassistant.config_entries import ConfigEntry
@ -8,6 +7,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN as ADVANTAGE_AIR_DOMAIN
from .entity import AdvantageAirAcEntity
from .models import AdvantageAirData
ADVANTAGE_AIR_INACTIVE = "Inactive"
@ -19,10 +19,10 @@ async def async_setup_entry(
) -> None:
"""Set up AdvantageAir select platform."""
instance = hass.data[ADVANTAGE_AIR_DOMAIN][config_entry.entry_id]
instance: AdvantageAirData = hass.data[ADVANTAGE_AIR_DOMAIN][config_entry.entry_id]
entities: list[SelectEntity] = []
if aircons := instance["coordinator"].data.get("aircons"):
if aircons := instance.coordinator.data.get("aircons"):
for ac_key in aircons:
entities.append(AdvantageAirMyZone(instance, ac_key))
async_add_entities(entities)
@ -34,7 +34,7 @@ class AdvantageAirMyZone(AdvantageAirAcEntity, SelectEntity):
_attr_icon = "mdi:home-thermometer"
_attr_name = "MyZone"
def __init__(self, instance: dict[str, Any], ac_key: str) -> None:
def __init__(self, instance: AdvantageAirData, ac_key: str) -> None:
"""Initialize an Advantage Air MyZone control."""
super().__init__(instance, ac_key)
self._attr_unique_id += "-myzone"
@ -42,11 +42,12 @@ class AdvantageAirMyZone(AdvantageAirAcEntity, SelectEntity):
self._number_to_name = {0: ADVANTAGE_AIR_INACTIVE}
self._name_to_number = {ADVANTAGE_AIR_INACTIVE: 0}
for zone in instance["coordinator"].data["aircons"][ac_key]["zones"].values():
if zone["type"] > 0:
self._name_to_number[zone["name"]] = zone["number"]
self._number_to_name[zone["number"]] = zone["name"]
self._attr_options.append(zone["name"])
if "aircons" in instance.coordinator.data:
for zone in instance.coordinator.data["aircons"][ac_key]["zones"].values():
if zone["type"] > 0:
self._name_to_number[zone["name"]] = zone["number"]
self._number_to_name[zone["number"]] = zone["name"]
self._attr_options.append(zone["name"])
@property
def current_option(self) -> str:
@ -55,6 +56,4 @@ class AdvantageAirMyZone(AdvantageAirAcEntity, SelectEntity):
async def async_select_option(self, option: str) -> None:
"""Set the MyZone."""
await self.aircon(
{self.ac_key: {"info": {"myZone": self._name_to_number[option]}}}
)
await self.async_update_ac({"myZone": self._name_to_number[option]})

View File

@ -19,6 +19,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import ADVANTAGE_AIR_STATE_OPEN, DOMAIN as ADVANTAGE_AIR_DOMAIN
from .entity import AdvantageAirAcEntity, AdvantageAirZoneEntity
from .models import AdvantageAirData
ADVANTAGE_AIR_SET_COUNTDOWN_VALUE = "minutes"
ADVANTAGE_AIR_SET_COUNTDOWN_UNIT = "min"
@ -34,10 +35,10 @@ async def async_setup_entry(
) -> None:
"""Set up AdvantageAir sensor platform."""
instance = hass.data[ADVANTAGE_AIR_DOMAIN][config_entry.entry_id]
instance: AdvantageAirData = hass.data[ADVANTAGE_AIR_DOMAIN][config_entry.entry_id]
entities: list[SensorEntity] = []
if aircons := instance["coordinator"].data.get("aircons"):
if aircons := instance.coordinator.data.get("aircons"):
for ac_key, ac_device in aircons.items():
entities.append(AdvantageAirTimeTo(instance, ac_key, "On"))
entities.append(AdvantageAirTimeTo(instance, ac_key, "Off"))
@ -65,7 +66,7 @@ class AdvantageAirTimeTo(AdvantageAirAcEntity, SensorEntity):
_attr_native_unit_of_measurement = ADVANTAGE_AIR_SET_COUNTDOWN_UNIT
_attr_entity_category = EntityCategory.DIAGNOSTIC
def __init__(self, instance: dict[str, Any], ac_key: str, action: str) -> None:
def __init__(self, instance: AdvantageAirData, ac_key: str, action: str) -> None:
"""Initialize the Advantage Air timer control."""
super().__init__(instance, ac_key)
self.action = action
@ -88,7 +89,7 @@ class AdvantageAirTimeTo(AdvantageAirAcEntity, SensorEntity):
async def set_time_to(self, **kwargs: Any) -> None:
"""Set the timer value."""
value = min(720, max(0, int(kwargs[ADVANTAGE_AIR_SET_COUNTDOWN_VALUE])))
await self.aircon({self.ac_key: {"info": {self._time_key: value}}})
await self.async_update_ac({self._time_key: value})
class AdvantageAirZoneVent(AdvantageAirZoneEntity, SensorEntity):
@ -98,7 +99,7 @@ class AdvantageAirZoneVent(AdvantageAirZoneEntity, SensorEntity):
_attr_state_class = SensorStateClass.MEASUREMENT
_attr_entity_category = EntityCategory.DIAGNOSTIC
def __init__(self, instance: dict[str, Any], ac_key: str, zone_key: str) -> None:
def __init__(self, instance: AdvantageAirData, ac_key: str, zone_key: str) -> None:
"""Initialize an Advantage Air Zone Vent Sensor."""
super().__init__(instance, ac_key, zone_key=zone_key)
self._attr_name = f'{self._zone["name"]} vent'
@ -126,7 +127,7 @@ class AdvantageAirZoneSignal(AdvantageAirZoneEntity, SensorEntity):
_attr_state_class = SensorStateClass.MEASUREMENT
_attr_entity_category = EntityCategory.DIAGNOSTIC
def __init__(self, instance: dict[str, Any], ac_key: str, zone_key: str) -> None:
def __init__(self, instance: AdvantageAirData, ac_key: str, zone_key: str) -> None:
"""Initialize an Advantage Air Zone wireless signal sensor."""
super().__init__(instance, ac_key, zone_key)
self._attr_name = f'{self._zone["name"]} signal'
@ -160,7 +161,7 @@ class AdvantageAirZoneTemp(AdvantageAirZoneEntity, SensorEntity):
_attr_entity_registry_enabled_default = False
_attr_entity_category = EntityCategory.DIAGNOSTIC
def __init__(self, instance: dict[str, Any], ac_key: str, zone_key: str) -> None:
def __init__(self, instance: AdvantageAirData, ac_key: str, zone_key: str) -> None:
"""Initialize an Advantage Air Zone Temp Sensor."""
super().__init__(instance, ac_key, zone_key)
self._attr_name = f'{self._zone["name"]} temperature'

View File

@ -1,7 +1,7 @@
"""Switch platform for Advantage Air integration."""
from typing import Any
from homeassistant.components.switch import SwitchEntity
from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
@ -11,7 +11,8 @@ from .const import (
ADVANTAGE_AIR_STATE_ON,
DOMAIN as ADVANTAGE_AIR_DOMAIN,
)
from .entity import AdvantageAirAcEntity
from .entity import AdvantageAirAcEntity, AdvantageAirThingEntity
from .models import AdvantageAirData
async def async_setup_entry(
@ -21,13 +22,17 @@ async def async_setup_entry(
) -> None:
"""Set up AdvantageAir switch platform."""
instance = hass.data[ADVANTAGE_AIR_DOMAIN][config_entry.entry_id]
instance: AdvantageAirData = hass.data[ADVANTAGE_AIR_DOMAIN][config_entry.entry_id]
entities: list[SwitchEntity] = []
if aircons := instance["coordinator"].data.get("aircons"):
if aircons := instance.coordinator.data.get("aircons"):
for ac_key, ac_device in aircons.items():
if ac_device["info"]["freshAirStatus"] != "none":
entities.append(AdvantageAirFreshAir(instance, ac_key))
if things := instance.coordinator.data.get("myThings"):
for thing in things["things"].values():
if thing["channelDipState"] == 8: # 8 = Other relay
entities.append(AdvantageAirRelay(instance, thing))
async_add_entities(entities)
@ -36,8 +41,9 @@ class AdvantageAirFreshAir(AdvantageAirAcEntity, SwitchEntity):
_attr_icon = "mdi:air-filter"
_attr_name = "Fresh air"
_attr_device_class = SwitchDeviceClass.SWITCH
def __init__(self, instance: dict[str, Any], ac_key: str) -> None:
def __init__(self, instance: AdvantageAirData, ac_key: str) -> None:
"""Initialize an Advantage Air fresh air control."""
super().__init__(instance, ac_key)
self._attr_unique_id += "-freshair"
@ -49,12 +55,14 @@ class AdvantageAirFreshAir(AdvantageAirAcEntity, SwitchEntity):
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn fresh air on."""
await self.aircon(
{self.ac_key: {"info": {"freshAirStatus": ADVANTAGE_AIR_STATE_ON}}}
)
await self.async_update_ac({"freshAirStatus": ADVANTAGE_AIR_STATE_ON})
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn fresh air off."""
await self.aircon(
{self.ac_key: {"info": {"freshAirStatus": ADVANTAGE_AIR_STATE_OFF}}}
)
await self.async_update_ac({"freshAirStatus": ADVANTAGE_AIR_STATE_OFF})
class AdvantageAirRelay(AdvantageAirThingEntity, SwitchEntity):
"""Representation of Advantage Air Thing."""
_attr_device_class = SwitchDeviceClass.SWITCH

View File

@ -1,5 +1,4 @@
"""Advantage Air Update platform."""
from typing import Any
from homeassistant.components.update import UpdateEntity
from homeassistant.config_entries import ConfigEntry
@ -9,6 +8,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN as ADVANTAGE_AIR_DOMAIN
from .entity import AdvantageAirEntity
from .models import AdvantageAirData
async def async_setup_entry(
@ -18,7 +18,7 @@ async def async_setup_entry(
) -> None:
"""Set up AdvantageAir update platform."""
instance = hass.data[ADVANTAGE_AIR_DOMAIN][config_entry.entry_id]
instance: AdvantageAirData = hass.data[ADVANTAGE_AIR_DOMAIN][config_entry.entry_id]
async_add_entities([AdvantageAirApp(instance)])
@ -28,7 +28,7 @@ class AdvantageAirApp(AdvantageAirEntity, UpdateEntity):
_attr_name = "App"
def __init__(self, instance: dict[str, Any]) -> None:
def __init__(self, instance: AdvantageAirData) -> None:
"""Initialize the Advantage Air App."""
super().__init__(instance)
self._attr_device_info = DeviceInfo(

View File

@ -380,7 +380,6 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
)
else:
entry.version = version
hass.config_entries.async_update_entry(entry)
LOGGER.info("Migration to version %s successful", version)

View File

@ -25,7 +25,7 @@ from homeassistant.const import (
STATE_OFF,
STATE_ON,
)
from homeassistant.core import Event, HomeAssistant
from homeassistant.core import Event, HassJob, HomeAssistant
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.entity_component import EntityComponent
@ -237,7 +237,13 @@ class Alert(Entity):
"""Schedule a notification."""
delay = self._delay[self._next_delay]
next_msg = now() + delay
self._cancel = async_track_point_in_time(self.hass, self._notify, next_msg)
self._cancel = async_track_point_in_time(
self.hass,
HassJob(
self._notify, name="Schedule notify alert", cancel_on_shutdown=True
),
next_msg,
)
self._next_delay = min(self._next_delay + 1, len(self._delay) - 1)
async def _notify(self, *args: Any) -> None:

View File

@ -60,6 +60,7 @@ class AlexaConfig(AbstractConfig):
"""Return an identifier for the user that represents this config."""
return ""
@core.callback
def should_expose(self, entity_id):
"""If an entity should be exposed."""
if not self._config[CONF_FILTER].empty_filter:

View File

@ -117,7 +117,6 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
en_reg.async_clear_config_entry(entry.entry_id)
version = entry.version = 2
hass.config_entries.async_update_entry(entry)
LOGGER.info("Migration to version %s successful", version)

View File

@ -19,7 +19,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import Throttle
from .const import ATTRIBUTION, CONF_STATION_ID, SCAN_INTERVAL
from .const import CONF_STATION_ID, SCAN_INTERVAL
_LOGGER: Final = logging.getLogger(__name__)
@ -54,6 +54,8 @@ async def async_setup_platform(
class AmpioSmogQuality(AirQualityEntity):
"""Implementation of an Ampio Smog air quality entity."""
_attr_attribution = "Data provided by Ampio"
def __init__(
self, api: AmpioSmogMapData, station_id: str, name: str | None
) -> None:
@ -82,11 +84,6 @@ class AmpioSmogQuality(AirQualityEntity):
"""Return the particulate matter 10 level."""
return self._ampio.api.pm10 # type: ignore[no-any-return]
@property
def attribution(self) -> str:
"""Return the attribution."""
return ATTRIBUTION
async def async_update(self) -> None:
"""Get the latest data from the AmpioMap API."""
await self._ampio.async_update()

View File

@ -2,6 +2,5 @@
from datetime import timedelta
from typing import Final
ATTRIBUTION: Final = "Data provided by Ampio"
CONF_STATION_ID: Final = "station_id"
SCAN_INTERVAL: Final = timedelta(minutes=10)

View File

@ -5,7 +5,7 @@ import voluptuous as vol
from homeassistant.components import websocket_api
from homeassistant.const import EVENT_HOMEASSISTANT_STARTED
from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.core import Event, HassJob, HomeAssistant, callback
from homeassistant.helpers.event import async_call_later, async_track_time_interval
from homeassistant.helpers.typing import ConfigType
@ -24,11 +24,23 @@ async def async_setup(hass: HomeAssistant, _: ConfigType) -> bool:
def start_schedule(_event: Event) -> None:
"""Start the send schedule after the started event."""
# Wait 15 min after started
async_call_later(hass, 900, analytics.send_analytics)
async_call_later(
hass,
900,
HassJob(
analytics.send_analytics,
name="analytics schedule",
cancel_on_shutdown=True,
),
)
# Send every day
async_track_time_interval(
hass, analytics.send_analytics, INTERVAL, name="analytics daily"
hass,
analytics.send_analytics,
INTERVAL,
name="analytics daily",
cancel_on_shutdown=True,
)
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, start_schedule)

View File

@ -1,4 +1,4 @@
"""Support for functionality to interact with Android TV/Fire TV devices."""
"""Support for functionality to interact with Android/Fire TV devices."""
from __future__ import annotations
from collections.abc import Mapping
@ -135,11 +135,11 @@ async def async_connect_androidtv(
if not aftv.available:
# Determine the name that will be used for the device in the log
if config[CONF_DEVICE_CLASS] == DEVICE_ANDROIDTV:
device_name = "Android TV device"
device_name = "Android device"
elif config[CONF_DEVICE_CLASS] == DEVICE_FIRETV:
device_name = "Fire TV device"
else:
device_name = "Android TV / Fire TV device"
device_name = "Android / Fire TV device"
error_message = f"Could not connect to {device_name} at {address} {adb_log}"
return None, error_message
@ -148,7 +148,7 @@ async def async_connect_androidtv(
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Android TV platform."""
"""Set up Android Debug Bridge platform."""
state_det_rules = entry.options.get(CONF_STATE_DETECTION_RULES)
if CONF_ADB_SERVER_IP not in entry.data:
@ -167,7 +167,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
raise ConfigEntryNotReady(error_message)
async def async_close_connection(event):
"""Close Android TV connection on HA Stop."""
"""Close Android Debug Bridge connection on HA Stop."""
await aftv.adb_close()
entry.async_on_unload(

View File

@ -1,4 +1,4 @@
"""Config flow to configure the Android TV integration."""
"""Config flow to configure the Android Debug Bridge integration."""
from __future__ import annotations
import logging
@ -114,13 +114,14 @@ class AndroidTVFlowHandler(ConfigFlow, domain=DOMAIN):
async def _async_check_connection(
self, user_input: dict[str, Any]
) -> tuple[str | None, str | None]:
"""Attempt to connect the Android TV."""
"""Attempt to connect the Android device."""
try:
aftv, error_message = await async_connect_androidtv(self.hass, user_input)
except Exception: # pylint: disable=broad-except
_LOGGER.exception(
"Unknown error connecting with Android TV at %s", user_input[CONF_HOST]
"Unknown error connecting with Android device at %s",
user_input[CONF_HOST],
)
return RESULT_UNKNOWN, None
@ -130,7 +131,7 @@ class AndroidTVFlowHandler(ConfigFlow, domain=DOMAIN):
dev_prop = aftv.device_properties
_LOGGER.info(
"Android TV at %s: %s = %r, %s = %r",
"Android device at %s: %s = %r, %s = %r",
user_input[CONF_HOST],
PROP_ETHMAC,
dev_prop.get(PROP_ETHMAC),
@ -184,7 +185,7 @@ class AndroidTVFlowHandler(ConfigFlow, domain=DOMAIN):
class OptionsFlowHandler(OptionsFlowWithConfigEntry):
"""Handle an option flow for Android TV."""
"""Handle an option flow for Android Debug Bridge."""
def __init__(self, config_entry: ConfigEntry) -> None:
"""Initialize options flow."""

View File

@ -1,4 +1,4 @@
"""Android TV component constants."""
"""Android Debug Bridge component constants."""
DOMAIN = "androidtv"
ANDROID_DEV = DOMAIN

View File

@ -1,6 +1,6 @@
{
"domain": "androidtv",
"name": "Android TV",
"name": "Android Debug Bridge",
"codeowners": ["@JeffLIrion", "@ollo69"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/androidtv",

View File

@ -1,4 +1,4 @@
"""Support for functionality to interact with Android TV / Fire TV devices."""
"""Support for functionality to interact with Android / Fire TV devices."""
from __future__ import annotations
from collections.abc import Awaitable, Callable, Coroutine
@ -87,7 +87,7 @@ async def async_setup_entry(
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Android TV entity."""
"""Set up the Android Debug Bridge entity."""
aftv = hass.data[DOMAIN][entry.entry_id][ANDROID_DEV]
device_class = aftv.DEVICE_CLASS
device_type = (
@ -201,7 +201,7 @@ def adb_decorator(
class ADBDevice(MediaPlayerEntity):
"""Representation of an Android TV or Fire TV device."""
"""Representation of an Android or Fire TV device."""
_attr_device_class = MediaPlayerDeviceClass.TV
@ -214,7 +214,7 @@ class ADBDevice(MediaPlayerEntity):
entry_id,
entry_data,
):
"""Initialize the Android TV / Fire TV device."""
"""Initialize the Android / Fire TV device."""
self.aftv = aftv
self._attr_name = name
self._attr_unique_id = unique_id
@ -384,7 +384,7 @@ class ADBDevice(MediaPlayerEntity):
@adb_decorator()
async def adb_command(self, command):
"""Send an ADB command to an Android TV / Fire TV device."""
"""Send an ADB command to an Android / Fire TV device."""
if key := KEYS.get(command):
await self.aftv.adb_shell(f"input keyevent {key}")
return
@ -422,13 +422,13 @@ class ADBDevice(MediaPlayerEntity):
persistent_notification.async_create(
self.hass,
msg,
title="Android TV",
title="Android Debug Bridge",
)
_LOGGER.info("%s", msg)
@adb_decorator()
async def service_download(self, device_path, local_path):
"""Download a file from your Android TV / Fire TV device to your Home Assistant instance."""
"""Download a file from your Android / Fire TV device to your Home Assistant instance."""
if not self.hass.config.is_allowed_path(local_path):
_LOGGER.warning("'%s' is not secure to load data from!", local_path)
return
@ -437,7 +437,7 @@ class ADBDevice(MediaPlayerEntity):
@adb_decorator()
async def service_upload(self, device_path, local_path):
"""Upload a file from your Home Assistant instance to an Android TV / Fire TV device."""
"""Upload a file from your Home Assistant instance to an Android / Fire TV device."""
if not self.hass.config.is_allowed_path(local_path):
_LOGGER.warning("'%s' is not secure to load data from!", local_path)
return
@ -446,7 +446,7 @@ class ADBDevice(MediaPlayerEntity):
class AndroidTVDevice(ADBDevice):
"""Representation of an Android TV device."""
"""Representation of an Android device."""
_attr_supported_features = (
MediaPlayerEntityFeature.PAUSE

View File

@ -1,8 +1,8 @@
# Describes the format for available Android TV and Fire TV services
# Describes the format for available Android and Fire TV services
adb_command:
name: ADB command
description: Send an ADB command to an Android TV / Fire TV device.
description: Send an ADB command to an Android / Fire TV device.
target:
entity:
integration: androidtv
@ -17,7 +17,7 @@ adb_command:
text:
download:
name: Download
description: Download a file from your Android TV / Fire TV device to your Home Assistant instance.
description: Download a file from your Android / Fire TV device to your Home Assistant instance.
target:
entity:
integration: androidtv
@ -25,7 +25,7 @@ download:
fields:
device_path:
name: Device path
description: The filepath on the Android TV / Fire TV device.
description: The filepath on the Android / Fire TV device.
required: true
example: "/storage/emulated/0/Download/example.txt"
selector:
@ -39,7 +39,7 @@ download:
text:
upload:
name: Upload
description: Upload a file from your Home Assistant instance to an Android TV / Fire TV device.
description: Upload a file from your Home Assistant instance to an Android / Fire TV device.
target:
entity:
integration: androidtv
@ -47,7 +47,7 @@ upload:
fields:
device_path:
name: Device path
description: The filepath on the Android TV / Fire TV device.
description: The filepath on the Android / Fire TV device.
required: true
example: "/storage/emulated/0/Download/example.txt"
selector:

View File

@ -38,7 +38,7 @@
}
},
"apps": {
"title": "Configure Android TV Apps",
"title": "Configure Android Apps",
"description": "Configure application id {app_id}",
"data": {
"app_name": "Application Name",
@ -47,7 +47,7 @@
}
},
"rules": {
"title": "Configure Android TV state detection rules",
"title": "Configure Android state detection rules",
"description": "Configure detection rule for application id {rule_id}",
"data": {
"rule_id": "Application ID",

View File

@ -0,0 +1,67 @@
"""The Android TV Remote integration."""
from __future__ import annotations
from androidtvremote2 import (
AndroidTVRemote,
CannotConnect,
ConnectionClosed,
InvalidAuth,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, EVENT_HOMEASSISTANT_STOP, Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from .const import DOMAIN
from .helpers import create_api
PLATFORMS: list[Platform] = [Platform.REMOTE]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Android TV Remote from a config entry."""
api = create_api(hass, entry.data[CONF_HOST])
try:
await api.async_connect()
except InvalidAuth as exc:
# The Android TV is hard reset or the certificate and key files were deleted.
raise ConfigEntryAuthFailed from exc
except (CannotConnect, ConnectionClosed) as exc:
# The Android TV is network unreachable. Raise exception and let Home Assistant retry
# later. If device gets a new IP address the zeroconf flow will update the config.
raise ConfigEntryNotReady from exc
def reauth_needed() -> None:
"""Start a reauth flow if Android TV is hard reset while reconnecting."""
entry.async_start_reauth(hass)
# Start a task (canceled in disconnect) to keep reconnecting if device becomes
# network unreachable. If device gets a new IP address the zeroconf flow will
# update the config entry data and reload the config entry.
api.keep_reconnecting(reauth_needed)
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = api
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
@callback
def on_hass_stop(event) -> None:
"""Stop push updates when hass stops."""
api.disconnect()
entry.async_on_unload(
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, on_hass_stop)
)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
api: AndroidTVRemote = hass.data[DOMAIN].pop(entry.entry_id)
api.disconnect()
return unload_ok

View File

@ -0,0 +1,187 @@
"""Config flow for Android TV Remote integration."""
from __future__ import annotations
from collections.abc import Mapping
from typing import Any
from androidtvremote2 import (
AndroidTVRemote,
CannotConnect,
ConnectionClosed,
InvalidAuth,
)
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.components import zeroconf
from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers.device_registry import format_mac
from .const import DOMAIN
from .helpers import create_api
STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required("host"): str,
}
)
STEP_PAIR_DATA_SCHEMA = vol.Schema(
{
vol.Required("pin"): str,
}
)
class AndroidTVRemoteConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Android TV Remote."""
VERSION = 1
def __init__(self) -> None:
"""Initialize a new AndroidTVRemoteConfigFlow."""
self.api: AndroidTVRemote | None = None
self.reauth_entry: config_entries.ConfigEntry | None = None
self.host: str | None = None
self.name: str | None = None
self.mac: str | None = None
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}
if user_input is not None:
self.host = user_input["host"]
assert self.host
api = create_api(self.hass, self.host)
try:
self.name, self.mac = await api.async_get_name_and_mac()
assert self.mac
await self.async_set_unique_id(format_mac(self.mac))
self._abort_if_unique_id_configured(updates={CONF_HOST: self.host})
return await self._async_start_pair()
except (CannotConnect, ConnectionClosed):
# Likely invalid IP address or device is network unreachable. Stay
# in the user step allowing the user to enter a different host.
errors["base"] = "cannot_connect"
return self.async_show_form(
step_id="user",
data_schema=STEP_USER_DATA_SCHEMA,
errors=errors,
)
async def _async_start_pair(self) -> FlowResult:
"""Start pairing with the Android TV. Navigate to the pair flow to enter the PIN shown on screen."""
assert self.host
self.api = create_api(self.hass, self.host)
await self.api.async_generate_cert_if_missing()
await self.api.async_start_pairing()
return await self.async_step_pair()
async def async_step_pair(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle the pair step."""
errors: dict[str, str] = {}
if user_input is not None:
try:
pin = user_input["pin"]
assert self.api
await self.api.async_finish_pairing(pin)
if self.reauth_entry:
await self.hass.config_entries.async_reload(
self.reauth_entry.entry_id
)
return self.async_abort(reason="reauth_successful")
assert self.name
return self.async_create_entry(
title=self.name,
data={
CONF_HOST: self.host,
CONF_NAME: self.name,
CONF_MAC: self.mac,
},
)
except InvalidAuth:
# Invalid PIN. Stay in the pair step allowing the user to enter
# a different PIN.
errors["base"] = "invalid_auth"
except ConnectionClosed:
# Either user canceled pairing on the Android TV itself (most common)
# or device doesn't respond to the specified host (device was unplugged,
# network was unplugged, or device got a new IP address).
# Attempt to pair again.
try:
return await self._async_start_pair()
except (CannotConnect, ConnectionClosed):
# Device doesn't respond to the specified host. Abort.
# If we are in the user flow we could go back to the user step to allow
# them to enter a new IP address but we cannot do that for the zeroconf
# flow. Simpler to abort for both flows.
return self.async_abort(reason="cannot_connect")
return self.async_show_form(
step_id="pair",
data_schema=STEP_PAIR_DATA_SCHEMA,
description_placeholders={CONF_NAME: self.name},
errors=errors,
)
async def async_step_zeroconf(
self, discovery_info: zeroconf.ZeroconfServiceInfo
) -> FlowResult:
"""Handle zeroconf discovery."""
self.host = discovery_info.host
self.name = discovery_info.name.removesuffix("._androidtvremote2._tcp.local.")
self.mac = discovery_info.properties.get("bt")
assert self.mac
await self.async_set_unique_id(format_mac(self.mac))
self._abort_if_unique_id_configured(
updates={CONF_HOST: self.host, CONF_NAME: self.name}
)
self.context.update({"title_placeholders": {CONF_NAME: self.name}})
return await self.async_step_zeroconf_confirm()
async def async_step_zeroconf_confirm(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle a flow initiated by zeroconf."""
if user_input is not None:
try:
return await self._async_start_pair()
except (CannotConnect, ConnectionClosed):
# Device became network unreachable after discovery.
# Abort and let discovery find it again later.
return self.async_abort(reason="cannot_connect")
return self.async_show_form(
step_id="zeroconf_confirm",
description_placeholders={CONF_NAME: self.name},
)
async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult:
"""Handle configuration by re-auth."""
self.host = entry_data[CONF_HOST]
self.name = entry_data[CONF_NAME]
self.mac = entry_data[CONF_MAC]
self.reauth_entry = self.hass.config_entries.async_get_entry(
self.context["entry_id"]
)
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Dialog that informs the user that reauth is required."""
errors: dict[str, str] = {}
if user_input is not None:
try:
return await self._async_start_pair()
except (CannotConnect, ConnectionClosed):
# Device is network unreachable. Abort.
errors["base"] = "cannot_connect"
return self.async_show_form(
step_id="reauth_confirm",
description_placeholders={CONF_NAME: self.name},
errors=errors,
)

View File

@ -0,0 +1,6 @@
"""Constants for the Android TV Remote integration."""
from __future__ import annotations
from typing import Final
DOMAIN: Final = "androidtv_remote"

View File

@ -0,0 +1,29 @@
"""Diagnostics support for Android TV Remote."""
from __future__ import annotations
from typing import Any
from androidtvremote2 import AndroidTVRemote
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_MAC
from homeassistant.core import HomeAssistant
from .const import DOMAIN
TO_REDACT = {CONF_HOST, CONF_MAC}
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: ConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
api: AndroidTVRemote = hass.data[DOMAIN].pop(entry.entry_id)
return async_redact_data(
{
"api_device_info": api.device_info,
"config_entry_data": entry.data,
},
TO_REDACT,
)

View File

@ -0,0 +1,18 @@
"""Helper functions for Android TV Remote integration."""
from __future__ import annotations
from androidtvremote2 import AndroidTVRemote
from homeassistant.core import HomeAssistant
from homeassistant.helpers.storage import STORAGE_DIR
def create_api(hass: HomeAssistant, host: str) -> AndroidTVRemote:
"""Create an AndroidTVRemote instance."""
return AndroidTVRemote(
client_name="Home Assistant",
certfile=hass.config.path(STORAGE_DIR, "androidtv_remote_cert.pem"),
keyfile=hass.config.path(STORAGE_DIR, "androidtv_remote_key.pem"),
host=host,
loop=hass.loop,
)

View File

@ -0,0 +1,13 @@
{
"domain": "androidtv_remote",
"name": "Android TV Remote",
"codeowners": ["@tronikos"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/androidtv_remote",
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["androidtvremote2"],
"quality_scale": "platinum",
"requirements": ["androidtvremote2==0.0.7"],
"zeroconf": ["_androidtvremote2._tcp.local."]
}

View File

@ -0,0 +1,154 @@
"""Remote control support for Android TV Remote."""
from __future__ import annotations
import asyncio
from collections.abc import Iterable
import logging
from typing import Any
from androidtvremote2 import AndroidTVRemote, ConnectionClosed
from homeassistant.components.remote import (
ATTR_ACTIVITY,
ATTR_DELAY_SECS,
ATTR_HOLD_SECS,
ATTR_NUM_REPEATS,
DEFAULT_DELAY_SECS,
DEFAULT_HOLD_SECS,
DEFAULT_NUM_REPEATS,
RemoteEntity,
RemoteEntityFeature,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN
PARALLEL_UPDATES = 0
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Android TV remote entity based on a config entry."""
api: AndroidTVRemote = hass.data[DOMAIN][config_entry.entry_id]
async_add_entities([AndroidTVRemoteEntity(api, config_entry)])
class AndroidTVRemoteEntity(RemoteEntity):
"""Representation of an Android TV Remote."""
_attr_has_entity_name = True
_attr_should_poll = False
def __init__(self, api: AndroidTVRemote, config_entry: ConfigEntry) -> None:
"""Initialize device."""
self._api = api
self._host = config_entry.data[CONF_HOST]
self._name = config_entry.data[CONF_NAME]
self._attr_unique_id = config_entry.unique_id
self._attr_supported_features = RemoteEntityFeature.ACTIVITY
self._attr_is_on = api.is_on
self._attr_current_activity = api.current_app
device_info = api.device_info
assert config_entry.unique_id
assert device_info
self._attr_device_info = DeviceInfo(
connections={(CONNECTION_NETWORK_MAC, config_entry.data[CONF_MAC])},
identifiers={(DOMAIN, config_entry.unique_id)},
name=self._name,
manufacturer=device_info["manufacturer"],
model=device_info["model"],
)
@callback
def is_on_updated(is_on: bool) -> None:
self._attr_is_on = is_on
self.async_write_ha_state()
@callback
def current_app_updated(current_app: str) -> None:
self._attr_current_activity = current_app
self.async_write_ha_state()
@callback
def is_available_updated(is_available: bool) -> None:
if is_available:
_LOGGER.info(
"Reconnected to %s at %s",
self._name,
self._host,
)
else:
_LOGGER.warning(
"Disconnected from %s at %s",
self._name,
self._host,
)
self._attr_available = is_available
self.async_write_ha_state()
api.add_is_on_updated_callback(is_on_updated)
api.add_current_app_updated_callback(current_app_updated)
api.add_is_available_updated_callback(is_available_updated)
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the Android TV on."""
if not self.is_on:
self._send_key_command("POWER")
activity = kwargs.get(ATTR_ACTIVITY, "")
if activity:
self._send_launch_app_command(activity)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the Android TV off."""
if self.is_on:
self._send_key_command("POWER")
async def async_send_command(self, command: Iterable[str], **kwargs: Any) -> None:
"""Send commands to one device."""
num_repeats = kwargs.get(ATTR_NUM_REPEATS, DEFAULT_NUM_REPEATS)
delay_secs = kwargs.get(ATTR_DELAY_SECS, DEFAULT_DELAY_SECS)
hold_secs = kwargs.get(ATTR_HOLD_SECS, DEFAULT_HOLD_SECS)
for _ in range(num_repeats):
for single_command in command:
if hold_secs:
self._send_key_command(single_command, "START_LONG")
await asyncio.sleep(hold_secs)
self._send_key_command(single_command, "END_LONG")
else:
self._send_key_command(single_command, "SHORT")
await asyncio.sleep(delay_secs)
def _send_key_command(self, key_code: str, direction: str = "SHORT") -> None:
"""Send a key press to Android TV.
This does not block; it buffers the data and arranges for it to be sent out asynchronously.
"""
try:
self._api.send_key_command(key_code, direction)
except ConnectionClosed as exc:
raise HomeAssistantError(
"Connection to Android TV device is closed"
) from exc
def _send_launch_app_command(self, app_link: str) -> None:
"""Launch an app on Android TV.
This does not block; it buffers the data and arranges for it to be sent out asynchronously.
"""
try:
self._api.send_launch_app_command(app_link)
except ConnectionClosed as exc:
raise HomeAssistantError(
"Connection to Android TV device is closed"
) from exc

View File

@ -0,0 +1,38 @@
{
"config": {
"flow_title": "{name}",
"step": {
"user": {
"description": "Enter the IP address of the Android TV you want to add to Home Assistant. It will turn on and a pairing code will be displayed on it that you will need to enter in the next screen.",
"data": {
"host": "[%key:common::config_flow::data::host%]"
}
},
"zeroconf_confirm": {
"title": "Discovered Android TV",
"description": "Do you want to add the Android TV ({name}) to Home Assistant? It will turn on and a pairing code will be displayed on it that you will need to enter in the next screen."
},
"pair": {
"description": "Enter the pairing code displayed on the Android TV ({name}).",
"data": {
"pin": "[%key:common::config_flow::data::pin%]"
}
},
"reauth_confirm": {
"title": "[%key:common::config_flow::title::reauth%]",
"description": "You need to pair again with the Android TV ({name})."
}
},
"error": {
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
}
}
}

View File

@ -0,0 +1,86 @@
"""The Anova integration."""
from __future__ import annotations
import logging
from anova_wifi import (
AnovaApi,
AnovaPrecisionCooker,
AnovaPrecisionCookerSensor,
InvalidLogin,
NoDevicesFound,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import aiohttp_client
from .const import DOMAIN
from .coordinator import AnovaCoordinator
from .models import AnovaData
from .util import serialize_device_list
PLATFORMS = [Platform.SENSOR]
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Anova from a config entry."""
api = AnovaApi(
aiohttp_client.async_get_clientsession(hass),
entry.data[CONF_USERNAME],
entry.data[CONF_PASSWORD],
)
try:
await api.authenticate()
except InvalidLogin as err:
_LOGGER.error(
"Login was incorrect - please log back in through the config flow. %s", err
)
return False
assert api.jwt
api.existing_devices = [
AnovaPrecisionCooker(
aiohttp_client.async_get_clientsession(hass),
device[0],
device[1],
api.jwt,
)
for device in entry.data["devices"]
]
try:
new_devices = await api.get_devices()
except NoDevicesFound:
# get_devices raises an exception if no devices are online
new_devices = []
devices = api.existing_devices
if new_devices:
hass.config_entries.async_update_entry(
entry,
data={
**entry.data,
**{"devices": serialize_device_list(devices)},
},
)
coordinators = [AnovaCoordinator(hass, device) for device in devices]
for coordinator in coordinators:
await coordinator.async_config_entry_first_refresh()
firmware_version = coordinator.data["sensors"][
AnovaPrecisionCookerSensor.FIRMWARE_VERSION
]
coordinator.async_setup(str(firmware_version))
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = AnovaData(
api_jwt=api.jwt, precision_cookers=devices, coordinators=coordinators
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok

View File

@ -0,0 +1,61 @@
"""Config flow for Anova."""
from __future__ import annotations
from anova_wifi import AnovaApi, InvalidLogin, NoDevicesFound
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers import aiohttp_client
from .const import DOMAIN
from .util import serialize_device_list
class AnovaConfligFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Sets up a config flow for Anova."""
VERSION = 1
async def async_step_user(
self, user_input: dict[str, str] | None = None
) -> FlowResult:
"""Handle a flow initiated by the user."""
errors: dict[str, str] = {}
if user_input is not None:
api = AnovaApi(
aiohttp_client.async_get_clientsession(self.hass),
user_input[CONF_USERNAME],
user_input[CONF_PASSWORD],
)
await self.async_set_unique_id(user_input[CONF_USERNAME].lower())
self._abort_if_unique_id_configured()
try:
await api.authenticate()
devices = await api.get_devices()
except InvalidLogin:
errors["base"] = "invalid_auth"
except NoDevicesFound:
errors["base"] = "no_devices_found"
except Exception: # pylint: disable=broad-except
errors["base"] = "unknown"
else:
# We store device list in config flow in order to persist found devices on restart, as the Anova api get_devices does not return any devices that are offline.
device_list = serialize_device_list(devices)
return self.async_create_entry(
title="Anova",
data={
CONF_USERNAME: api.username,
CONF_PASSWORD: api.password,
"devices": device_list,
},
)
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str}
),
errors=errors,
)

View File

@ -0,0 +1,6 @@
"""Constants for the Anova integration."""
DOMAIN = "anova"
ANOVA_CLIENT = "anova_api_client"
ANOVA_FIRMWARE_VERSION = "anova_firmware_version"

View File

@ -0,0 +1,55 @@
"""Support for Anova Coordinators."""
from datetime import timedelta
import logging
from anova_wifi import AnovaOffline, AnovaPrecisionCooker
import async_timeout
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
class AnovaCoordinator(DataUpdateCoordinator):
"""Anova custom coordinator."""
data: dict[str, dict[str, str | int | float]]
def __init__(
self,
hass: HomeAssistant,
anova_device: AnovaPrecisionCooker,
) -> None:
"""Set up Anova Coordinator."""
super().__init__(
hass,
name="Anova Precision Cooker",
logger=_LOGGER,
update_interval=timedelta(seconds=30),
)
assert self.config_entry is not None
self._device_unique_id = anova_device.device_key
self.anova_device = anova_device
self.device_info: DeviceInfo | None = None
@callback
def async_setup(self, firmware_version: str) -> None:
"""Set the firmware version info."""
self.device_info = DeviceInfo(
identifiers={(DOMAIN, self._device_unique_id)},
name="Anova Precision Cooker",
manufacturer="Anova",
model="Precision Cooker",
sw_version=firmware_version,
)
async def _async_update_data(self) -> dict[str, dict[str, str | int | float]]:
try:
async with async_timeout.timeout(5):
return await self.anova_device.update()
except AnovaOffline as err:
raise UpdateFailed(err) from err

View File

@ -0,0 +1,30 @@
"""Base entity for the Anova integration."""
from __future__ import annotations
from homeassistant.helpers.entity import Entity, EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .coordinator import AnovaCoordinator
class AnovaEntity(CoordinatorEntity[AnovaCoordinator], Entity):
"""Defines a Anova entity."""
def __init__(self, coordinator: AnovaCoordinator) -> None:
"""Initialize the Anova entity."""
super().__init__(coordinator)
self.device = coordinator.anova_device
self._attr_device_info = coordinator.device_info
self._attr_has_entity_name = True
class AnovaDescriptionEntity(AnovaEntity, Entity):
"""Defines a Anova entity that uses a description."""
def __init__(
self, coordinator: AnovaCoordinator, description: EntityDescription
) -> None:
"""Initialize the entity and declare unique id based on description key."""
super().__init__(coordinator)
self.entity_description = description
self._attr_unique_id = f"{coordinator._device_unique_id}_{description.key}"

View File

@ -0,0 +1,10 @@
{
"domain": "anova",
"name": "Anova",
"codeowners": ["@Lash-L"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/anova",
"iot_class": "cloud_polling",
"loggers": ["anova_wifi"],
"requirements": ["anova-wifi==0.8.0"]
}

View File

@ -0,0 +1,15 @@
"""Dataclass models for the Anova integration."""
from dataclasses import dataclass
from anova_wifi import AnovaPrecisionCooker
from .coordinator import AnovaCoordinator
@dataclass
class AnovaData:
"""Data for the Anova integration."""
api_jwt: str
precision_cookers: list[AnovaPrecisionCooker]
coordinators: list[AnovaCoordinator]

View File

@ -0,0 +1,97 @@
"""Support for Anova Sensors."""
from __future__ import annotations
from anova_wifi import AnovaPrecisionCookerSensor
from homeassistant import config_entries
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.const import UnitOfTemperature, UnitOfTime
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import StateType
from .const import DOMAIN
from .entity import AnovaDescriptionEntity
from .models import AnovaData
SENSOR_DESCRIPTIONS: list[SensorEntityDescription] = [
SensorEntityDescription(
key=AnovaPrecisionCookerSensor.COOK_TIME,
state_class=SensorStateClass.TOTAL_INCREASING,
native_unit_of_measurement=UnitOfTime.SECONDS,
icon="mdi:clock-outline",
translation_key="cook_time",
),
SensorEntityDescription(
key=AnovaPrecisionCookerSensor.STATE, translation_key="state"
),
SensorEntityDescription(
key=AnovaPrecisionCookerSensor.MODE, translation_key="mode"
),
SensorEntityDescription(
key=AnovaPrecisionCookerSensor.TARGET_TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
icon="mdi:thermometer",
translation_key="target_temperature",
),
SensorEntityDescription(
key=AnovaPrecisionCookerSensor.COOK_TIME_REMAINING,
native_unit_of_measurement=UnitOfTime.SECONDS,
icon="mdi:clock-outline",
translation_key="cook_time_remaining",
),
SensorEntityDescription(
key=AnovaPrecisionCookerSensor.HEATER_TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
icon="mdi:thermometer",
translation_key="heater_temperature",
),
SensorEntityDescription(
key=AnovaPrecisionCookerSensor.TRIAC_TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
icon="mdi:thermometer",
translation_key="triac_temperature",
),
SensorEntityDescription(
key=AnovaPrecisionCookerSensor.WATER_TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
icon="mdi:thermometer",
translation_key="water_temperature",
),
]
async def async_setup_entry(
hass: HomeAssistant,
entry: config_entries.ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Anova device."""
anova_data: AnovaData = hass.data[DOMAIN][entry.entry_id]
async_add_entities(
AnovaSensor(coordinator, description)
for coordinator in anova_data.coordinators
for description in SENSOR_DESCRIPTIONS
)
class AnovaSensor(AnovaDescriptionEntity, SensorEntity):
"""A sensor using Anova coordinator."""
@property
def native_value(self) -> StateType:
"""Return the state."""
return self.coordinator.data["sensors"][self.entity_description.key]

View File

@ -0,0 +1,51 @@
{
"config": {
"step": {
"user": {
"data": {
"username": "[%key:common::config_flow::data::email%]",
"password": "[%key:common::config_flow::data::password%]"
}
},
"confirm": {
"description": "[%key:common::config_flow::description::confirm_setup%]"
}
},
"abort": {
"no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]"
},
"error": {
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"unknown": "[%key:common::config_flow::error::unknown%]",
"no_devices_found": "No devices were found. Make sure you have at least one Anova device online"
}
},
"entity": {
"sensor": {
"cook_time": {
"name": "Cook time"
},
"state": {
"name": "State"
},
"mode": {
"name": "Mode"
},
"target_temperature": {
"name": "Target temperature"
},
"cook_time_remaining": {
"name": "Cook time remaining"
},
"heater_temperature": {
"name": "Heater temperature"
},
"triac_temperature": {
"name": "Triac temperature"
},
"water_temperature": {
"name": "Water temperature"
}
}
}
}

View File

@ -0,0 +1,8 @@
"""Anova utilities."""
from anova_wifi import AnovaPrecisionCooker
def serialize_device_list(devices: list[AnovaPrecisionCooker]) -> list[tuple[str, str]]:
"""Turn the device list into a serializable list that can be reconstructed."""
return [(device.device_key, device.type) for device in devices]

View File

@ -2,7 +2,7 @@
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"no_status": "No status is reported from [%key:common::config_flow::data::host%]"
"no_status": "No status is reported from host"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"

View File

@ -324,18 +324,29 @@ class AppleTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
all_identifiers = set(self.atv.all_identifiers)
discovered_ip_address = str(self.atv.address)
for entry in self._async_current_entries():
if not all_identifiers.intersection(
existing_identifiers = set(
entry.data.get(CONF_IDENTIFIERS, [entry.unique_id])
):
)
if not all_identifiers.intersection(existing_identifiers):
continue
if entry.data.get(CONF_ADDRESS) != discovered_ip_address:
combined_identifiers = existing_identifiers | all_identifiers
if entry.data.get(
CONF_ADDRESS
) != discovered_ip_address or combined_identifiers != set(
entry.data.get(CONF_IDENTIFIERS, [])
):
self.hass.config_entries.async_update_entry(
entry,
data={**entry.data, CONF_ADDRESS: discovered_ip_address},
)
self.hass.async_create_task(
self.hass.config_entries.async_reload(entry.entry_id)
data={
**entry.data,
CONF_ADDRESS: discovered_ip_address,
CONF_IDENTIFIERS: list(combined_identifiers),
},
)
if entry.source != config_entries.SOURCE_IGNORE:
self.hass.async_create_task(
self.hass.config_entries.async_reload(entry.entry_id)
)
if not allow_exist:
raise DeviceAlreadyConfigured()

View File

@ -75,7 +75,7 @@ class AuthorizationServer:
token_url: str
class ApplicationCredentialsStorageCollection(collection.StorageCollection):
class ApplicationCredentialsStorageCollection(collection.DictStorageCollection):
"""Application credential collection stored in storage."""
CREATE_SCHEMA = vol.Schema(CREATE_FIELDS)
@ -94,7 +94,7 @@ class ApplicationCredentialsStorageCollection(collection.StorageCollection):
return f"{info[CONF_DOMAIN]}.{info[CONF_CLIENT_ID]}"
async def _update_data(
self, data: dict[str, str], update_data: dict[str, str]
self, item: dict[str, str], update_data: dict[str, str]
) -> dict[str, str]:
"""Return a new updated data object."""
raise ValueError("Updates not supported")
@ -144,13 +144,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
id_manager = collection.IDManager()
storage_collection = ApplicationCredentialsStorageCollection(
Store(hass, STORAGE_VERSION, STORAGE_KEY),
logging.getLogger(f"{__name__}.storage_collection"),
id_manager,
)
await storage_collection.async_load()
hass.data[DOMAIN][DATA_STORAGE] = storage_collection
collection.StorageCollectionWebsocket(
collection.DictStorageCollectionWebsocket(
storage_collection, DOMAIN, DOMAIN, CREATE_FIELDS, UPDATE_FIELDS
).async_setup(hass)

View File

@ -6,7 +6,7 @@
"documentation": "https://www.home-assistant.io/integrations/arcam_fmj",
"iot_class": "local_polling",
"loggers": ["arcam"],
"requirements": ["arcam-fmj==1.2.1"],
"requirements": ["arcam-fmj==1.3.0"],
"ssdp": [
{
"deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1",

View File

@ -0,0 +1,80 @@
"""The Assist pipeline integration."""
from __future__ import annotations
from collections.abc import AsyncIterable
from homeassistant.components import stt
from homeassistant.core import Context, HomeAssistant
from homeassistant.helpers.typing import ConfigType
from .const import DOMAIN
from .error import PipelineNotFound
from .pipeline import (
Pipeline,
PipelineEvent,
PipelineEventCallback,
PipelineEventType,
PipelineInput,
PipelineRun,
PipelineStage,
async_create_default_pipeline,
async_get_pipeline,
async_get_pipelines,
async_setup_pipeline_store,
)
from .websocket_api import async_register_websocket_api
__all__ = (
"DOMAIN",
"async_create_default_pipeline",
"async_get_pipelines",
"async_setup",
"async_pipeline_from_audio_stream",
"Pipeline",
"PipelineEvent",
"PipelineEventType",
)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Assist pipeline integration."""
await async_setup_pipeline_store(hass)
async_register_websocket_api(hass)
return True
async def async_pipeline_from_audio_stream(
hass: HomeAssistant,
context: Context,
event_callback: PipelineEventCallback,
stt_metadata: stt.SpeechMetadata,
stt_stream: AsyncIterable[bytes],
pipeline_id: str | None = None,
conversation_id: str | None = None,
tts_audio_output: str | None = None,
) -> None:
"""Create an audio pipeline from an audio stream."""
pipeline = async_get_pipeline(hass, pipeline_id=pipeline_id)
if pipeline is None:
raise PipelineNotFound(
"pipeline_not_found", f"Pipeline {pipeline_id} not found"
)
pipeline_input = PipelineInput(
conversation_id=conversation_id,
stt_metadata=stt_metadata,
stt_stream=stt_stream,
run=PipelineRun(
hass,
context=context,
pipeline=pipeline,
start_stage=PipelineStage.STT,
end_stage=PipelineStage.TTS,
event_callback=event_callback,
tts_audio_output=tts_audio_output,
),
)
await pipeline_input.validate()
await pipeline_input.execute()

View File

@ -0,0 +1,2 @@
"""Constants for the Assist pipeline integration."""
DOMAIN = "assist_pipeline"

View File

@ -0,0 +1,30 @@
"""Assist pipeline errors."""
from homeassistant.exceptions import HomeAssistantError
class PipelineError(HomeAssistantError):
"""Base class for pipeline errors."""
def __init__(self, code: str, message: str) -> None:
"""Set error message."""
self.code = code
self.message = message
super().__init__(f"Pipeline error code={code}, message={message}")
class PipelineNotFound(PipelineError):
"""Unspecified pipeline picked."""
class SpeechToTextError(PipelineError):
"""Error in speech to text portion of pipeline."""
class IntentRecognitionError(PipelineError):
"""Error in intent recognition portion of pipeline."""
class TextToSpeechError(PipelineError):
"""Error in text to speech portion of pipeline."""

View File

@ -0,0 +1,10 @@
{
"domain": "assist_pipeline",
"name": "Assist pipeline",
"codeowners": ["@balloob", "@synesthesiam"],
"dependencies": ["conversation", "stt", "tts"],
"documentation": "https://www.home-assistant.io/integrations/assist_pipeline",
"iot_class": "local_push",
"quality_scale": "internal",
"requirements": ["webrtcvad==2.0.10"]
}

View File

@ -0,0 +1,974 @@
"""Classes for voice assistant pipelines."""
from __future__ import annotations
import asyncio
from collections.abc import AsyncIterable, Callable, Iterable
from dataclasses import asdict, dataclass, field
import logging
from typing import Any
import voluptuous as vol
from homeassistant.backports.enum import StrEnum
from homeassistant.components import conversation, media_source, stt, tts, websocket_api
from homeassistant.components.tts.media_source import (
generate_media_source_id as tts_generate_media_source_id,
)
from homeassistant.core import Context, HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.collection import (
CollectionError,
ItemNotFound,
SerializedStorageCollection,
StorageCollection,
StorageCollectionWebsocket,
)
from homeassistant.helpers.singleton import singleton
from homeassistant.helpers.storage import Store
from homeassistant.util import (
dt as dt_util,
language as language_util,
ulid as ulid_util,
)
from homeassistant.util.limited_size_dict import LimitedSizeDict
from .const import DOMAIN
from .error import (
IntentRecognitionError,
PipelineError,
SpeechToTextError,
TextToSpeechError,
)
_LOGGER = logging.getLogger(__name__)
STORAGE_KEY = f"{DOMAIN}.pipelines"
STORAGE_VERSION = 1
ENGINE_LANGUAGE_PAIRS = (
("stt_engine", "stt_language"),
("tts_engine", "tts_language"),
)
def validate_language(data: dict[str, Any]) -> Any:
"""Validate language settings."""
for engine, language in ENGINE_LANGUAGE_PAIRS:
if data[engine] is not None and data[language] is None:
raise vol.Invalid(f"Need language {language} for {engine} {data[engine]}")
return data
PIPELINE_FIELDS = {
vol.Required("conversation_engine"): str,
vol.Required("conversation_language"): str,
vol.Required("language"): str,
vol.Required("name"): str,
vol.Required("stt_engine"): vol.Any(str, None),
vol.Required("stt_language"): vol.Any(str, None),
vol.Required("tts_engine"): vol.Any(str, None),
vol.Required("tts_language"): vol.Any(str, None),
vol.Required("tts_voice"): vol.Any(str, None),
}
STORED_PIPELINE_RUNS = 10
SAVE_DELAY = 10
async def _async_resolve_default_pipeline_settings(
hass: HomeAssistant,
stt_engine_id: str | None,
tts_engine_id: str | None,
) -> dict[str, str | None]:
"""Resolve settings for a default pipeline.
The default pipeline will use the homeassistant conversation agent and the
default stt / tts engines if none are specified.
"""
conversation_language = "en"
pipeline_language = "en"
pipeline_name = "Home Assistant"
stt_engine = None
stt_language = None
tts_engine = None
tts_language = None
tts_voice = None
# Find a matching language supported by the Home Assistant conversation agent
conversation_languages = language_util.matches(
hass.config.language,
await conversation.async_get_conversation_languages(
hass, conversation.HOME_ASSISTANT_AGENT
),
country=hass.config.country,
)
if conversation_languages:
pipeline_language = hass.config.language
conversation_language = conversation_languages[0]
if stt_engine_id is None:
stt_engine_id = stt.async_default_engine(hass)
if stt_engine_id is not None:
stt_engine = stt.async_get_speech_to_text_engine(hass, stt_engine_id)
if stt_engine is None:
stt_engine_id = None
if stt_engine:
stt_languages = language_util.matches(
pipeline_language,
stt_engine.supported_languages,
country=hass.config.country,
)
if stt_languages:
stt_language = stt_languages[0]
else:
_LOGGER.debug(
"Speech to text engine '%s' does not support language '%s'",
stt_engine_id,
pipeline_language,
)
stt_engine_id = None
if tts_engine_id is None:
tts_engine_id = tts.async_default_engine(hass)
if tts_engine_id is not None:
tts_engine = tts.get_engine_instance(hass, tts_engine_id)
if tts_engine is None:
tts_engine_id = None
if tts_engine:
tts_languages = language_util.matches(
pipeline_language,
tts_engine.supported_languages,
country=hass.config.country,
)
if tts_languages:
tts_language = tts_languages[0]
tts_voices = tts_engine.async_get_supported_voices(tts_language)
if tts_voices:
tts_voice = tts_voices[0].voice_id
else:
_LOGGER.debug(
"Text to speech engine '%s' does not support language '%s'",
tts_engine_id,
pipeline_language,
)
tts_engine_id = None
if stt_engine_id == "cloud" and tts_engine_id == "cloud":
pipeline_name = "Home Assistant Cloud"
return {
"conversation_engine": conversation.HOME_ASSISTANT_AGENT,
"conversation_language": conversation_language,
"language": hass.config.language,
"name": pipeline_name,
"stt_engine": stt_engine_id,
"stt_language": stt_language,
"tts_engine": tts_engine_id,
"tts_language": tts_language,
"tts_voice": tts_voice,
}
async def _async_create_default_pipeline(
hass: HomeAssistant, pipeline_store: PipelineStorageCollection
) -> Pipeline:
"""Create a default pipeline.
The default pipeline will use the homeassistant conversation agent and the
default stt / tts engines.
"""
pipeline_settings = await _async_resolve_default_pipeline_settings(hass, None, None)
return await pipeline_store.async_create_item(pipeline_settings)
async def async_create_default_pipeline(
hass: HomeAssistant, stt_engine_id: str, tts_engine_id: str
) -> Pipeline | None:
"""Create a pipeline with default settings.
The default pipeline will use the homeassistant conversation agent and the
specified stt / tts engines.
"""
pipeline_data: PipelineData = hass.data[DOMAIN]
pipeline_store = pipeline_data.pipeline_store
pipeline_settings = await _async_resolve_default_pipeline_settings(
hass, stt_engine_id, tts_engine_id
)
if (
pipeline_settings["stt_engine"] != stt_engine_id
or pipeline_settings["tts_engine"] != tts_engine_id
):
return None
return await pipeline_store.async_create_item(pipeline_settings)
@callback
def async_get_pipeline(
hass: HomeAssistant, pipeline_id: str | None = None
) -> Pipeline | None:
"""Get a pipeline by id or the preferred pipeline."""
pipeline_data: PipelineData = hass.data[DOMAIN]
if pipeline_id is None:
# A pipeline was not specified, use the preferred one
pipeline_id = pipeline_data.pipeline_store.async_get_preferred_item()
return pipeline_data.pipeline_store.data.get(pipeline_id)
@callback
def async_get_pipelines(hass: HomeAssistant) -> Iterable[Pipeline]:
"""Get all pipelines."""
pipeline_data: PipelineData = hass.data[DOMAIN]
return pipeline_data.pipeline_store.data.values()
class PipelineEventType(StrEnum):
"""Event types emitted during a pipeline run."""
RUN_START = "run-start"
RUN_END = "run-end"
STT_START = "stt-start"
STT_END = "stt-end"
INTENT_START = "intent-start"
INTENT_END = "intent-end"
TTS_START = "tts-start"
TTS_END = "tts-end"
ERROR = "error"
@dataclass(frozen=True)
class PipelineEvent:
"""Events emitted during a pipeline run."""
type: PipelineEventType
data: dict[str, Any] | None = None
timestamp: str = field(default_factory=lambda: dt_util.utcnow().isoformat())
PipelineEventCallback = Callable[[PipelineEvent], None]
@dataclass(frozen=True)
class Pipeline:
"""A voice assistant pipeline."""
conversation_engine: str
conversation_language: str
language: str
name: str
stt_engine: str | None
stt_language: str | None
tts_engine: str | None
tts_language: str | None
tts_voice: str | None
id: str = field(default_factory=ulid_util.ulid)
def to_json(self) -> dict[str, Any]:
"""Return a JSON serializable representation for storage."""
return {
"conversation_engine": self.conversation_engine,
"conversation_language": self.conversation_language,
"id": self.id,
"language": self.language,
"name": self.name,
"stt_engine": self.stt_engine,
"stt_language": self.stt_language,
"tts_engine": self.tts_engine,
"tts_language": self.tts_language,
"tts_voice": self.tts_voice,
}
class PipelineStage(StrEnum):
"""Stages of a pipeline."""
STT = "stt"
INTENT = "intent"
TTS = "tts"
PIPELINE_STAGE_ORDER = [
PipelineStage.STT,
PipelineStage.INTENT,
PipelineStage.TTS,
]
class PipelineRunValidationError(Exception):
"""Error when a pipeline run is not valid."""
class InvalidPipelineStagesError(PipelineRunValidationError):
"""Error when given an invalid combination of start/end stages."""
def __init__(
self,
start_stage: PipelineStage,
end_stage: PipelineStage,
) -> None:
"""Set error message."""
super().__init__(
f"Invalid stage combination: start={start_stage}, end={end_stage}"
)
@dataclass
class PipelineRun:
"""Running context for a pipeline."""
hass: HomeAssistant
context: Context
pipeline: Pipeline
start_stage: PipelineStage
end_stage: PipelineStage
event_callback: PipelineEventCallback
language: str = None # type: ignore[assignment]
runner_data: Any | None = None
stt_provider: stt.SpeechToTextEntity | stt.Provider | None = None
intent_agent: str | None = None
tts_engine: str | None = None
tts_audio_output: str | None = None
id: str = field(default_factory=ulid_util.ulid)
tts_options: dict | None = field(init=False, default=None)
def __post_init__(self) -> None:
"""Set language for pipeline."""
self.language = self.pipeline.language or self.hass.config.language
# stt -> intent -> tts
if PIPELINE_STAGE_ORDER.index(self.end_stage) < PIPELINE_STAGE_ORDER.index(
self.start_stage
):
raise InvalidPipelineStagesError(self.start_stage, self.end_stage)
pipeline_data: PipelineData = self.hass.data[DOMAIN]
if self.pipeline.id not in pipeline_data.pipeline_runs:
pipeline_data.pipeline_runs[self.pipeline.id] = LimitedSizeDict(
size_limit=STORED_PIPELINE_RUNS
)
pipeline_data.pipeline_runs[self.pipeline.id][self.id] = PipelineRunDebug()
@callback
def process_event(self, event: PipelineEvent) -> None:
"""Log an event and call listener."""
self.event_callback(event)
pipeline_data: PipelineData = self.hass.data[DOMAIN]
if self.id not in pipeline_data.pipeline_runs[self.pipeline.id]:
# This run has been evicted from the logged pipeline runs already
return
pipeline_data.pipeline_runs[self.pipeline.id][self.id].events.append(event)
def start(self) -> None:
"""Emit run start event."""
data = {
"pipeline": self.pipeline.id,
"language": self.language,
}
if self.runner_data is not None:
data["runner_data"] = self.runner_data
self.process_event(PipelineEvent(PipelineEventType.RUN_START, data))
def end(self) -> None:
"""Emit run end event."""
self.process_event(
PipelineEvent(
PipelineEventType.RUN_END,
)
)
async def prepare_speech_to_text(self, metadata: stt.SpeechMetadata) -> None:
"""Prepare speech to text."""
stt_provider: stt.SpeechToTextEntity | stt.Provider | None = None
# pipeline.stt_engine can't be None or this function is not called
stt_provider = stt.async_get_speech_to_text_engine(
self.hass,
self.pipeline.stt_engine, # type: ignore[arg-type]
)
if stt_provider is None:
engine = self.pipeline.stt_engine
raise SpeechToTextError(
code="stt-provider-missing",
message=f"No speech to text provider for: {engine}",
)
metadata.language = self.pipeline.stt_language or self.language
if not stt_provider.check_metadata(metadata):
raise SpeechToTextError(
code="stt-provider-unsupported-metadata",
message=(
f"Provider {stt_provider.name} does not support input speech "
f"to text metadata {metadata}"
),
)
self.stt_provider = stt_provider
async def speech_to_text(
self,
metadata: stt.SpeechMetadata,
stream: AsyncIterable[bytes],
) -> str:
"""Run speech to text portion of pipeline. Returns the spoken text."""
if self.stt_provider is None:
raise RuntimeError("Speech to text was not prepared")
if isinstance(self.stt_provider, stt.Provider):
engine = self.stt_provider.name
else:
engine = self.stt_provider.entity_id
self.process_event(
PipelineEvent(
PipelineEventType.STT_START,
{
"engine": engine,
"metadata": asdict(metadata),
},
)
)
try:
# Transcribe audio stream
result = await self.stt_provider.async_process_audio_stream(
metadata, stream
)
except Exception as src_error:
_LOGGER.exception("Unexpected error during speech to text")
raise SpeechToTextError(
code="stt-stream-failed",
message="Unexpected error during speech to text",
) from src_error
_LOGGER.debug("speech-to-text result %s", result)
if result.result != stt.SpeechResultState.SUCCESS:
raise SpeechToTextError(
code="stt-stream-failed",
message="Speech to text failed",
)
if not result.text:
raise SpeechToTextError(
code="stt-no-text-recognized", message="No text recognized"
)
self.process_event(
PipelineEvent(
PipelineEventType.STT_END,
{
"stt_output": {
"text": result.text,
}
},
)
)
return result.text
async def prepare_recognize_intent(self) -> None:
"""Prepare recognizing an intent."""
agent_info = conversation.async_get_agent_info(
self.hass,
# If no conversation engine is set, use the Home Assistant agent
# (the conversation integration default is currently the last one set)
self.pipeline.conversation_engine or conversation.HOME_ASSISTANT_AGENT,
)
if agent_info is None:
engine = self.pipeline.conversation_engine or "default"
raise IntentRecognitionError(
code="intent-not-supported",
message=f"Intent recognition engine {engine} is not found",
)
self.intent_agent = agent_info.id
async def recognize_intent(
self, intent_input: str, conversation_id: str | None
) -> str:
"""Run intent recognition portion of pipeline. Returns text to speak."""
if self.intent_agent is None:
raise RuntimeError("Recognize intent was not prepared")
self.process_event(
PipelineEvent(
PipelineEventType.INTENT_START,
{
"engine": self.intent_agent,
"language": self.pipeline.conversation_language,
"intent_input": intent_input,
},
)
)
try:
conversation_result = await conversation.async_converse(
hass=self.hass,
text=intent_input,
conversation_id=conversation_id,
context=self.context,
language=self.pipeline.conversation_language,
agent_id=self.intent_agent,
)
except Exception as src_error:
_LOGGER.exception("Unexpected error during intent recognition")
raise IntentRecognitionError(
code="intent-failed",
message="Unexpected error during intent recognition",
) from src_error
_LOGGER.debug("conversation result %s", conversation_result)
self.process_event(
PipelineEvent(
PipelineEventType.INTENT_END,
{"intent_output": conversation_result.as_dict()},
)
)
speech: str = conversation_result.response.speech.get("plain", {}).get(
"speech", ""
)
return speech
async def prepare_text_to_speech(self) -> None:
"""Prepare text to speech."""
engine = self.pipeline.tts_engine
tts_options = {}
if self.pipeline.tts_voice is not None:
tts_options[tts.ATTR_VOICE] = self.pipeline.tts_voice
if self.tts_audio_output is not None:
tts_options[tts.ATTR_AUDIO_OUTPUT] = self.tts_audio_output
try:
# pipeline.tts_engine can't be None or this function is not called
if not await tts.async_support_options(
self.hass,
engine, # type: ignore[arg-type]
self.pipeline.tts_language,
tts_options,
):
raise TextToSpeechError(
code="tts-not-supported",
message=(
f"Text to speech engine {engine} "
f"does not support language {self.pipeline.tts_language} or options {tts_options}"
),
)
except HomeAssistantError as err:
raise TextToSpeechError(
code="tts-not-supported",
message=f"Text to speech engine '{engine}' not found",
) from err
self.tts_engine = engine
self.tts_options = tts_options
async def text_to_speech(self, tts_input: str) -> str:
"""Run text to speech portion of pipeline. Returns URL of TTS audio."""
if self.tts_engine is None:
raise RuntimeError("Text to speech was not prepared")
self.process_event(
PipelineEvent(
PipelineEventType.TTS_START,
{
"engine": self.tts_engine,
"language": self.pipeline.tts_language,
"voice": self.pipeline.tts_voice,
"tts_input": tts_input,
},
)
)
try:
# Synthesize audio and get URL
tts_media_id = tts_generate_media_source_id(
self.hass,
tts_input,
engine=self.tts_engine,
language=self.pipeline.tts_language,
options=self.tts_options,
)
tts_media = await media_source.async_resolve_media(
self.hass,
tts_media_id,
None,
)
except Exception as src_error:
_LOGGER.exception("Unexpected error during text to speech")
raise TextToSpeechError(
code="tts-failed",
message="Unexpected error during text to speech",
) from src_error
_LOGGER.debug("TTS result %s", tts_media)
self.process_event(
PipelineEvent(
PipelineEventType.TTS_END,
{
"tts_output": {
"media_id": tts_media_id,
**asdict(tts_media),
}
},
)
)
return tts_media.url
@dataclass
class PipelineInput:
"""Input to a pipeline run."""
run: PipelineRun
stt_metadata: stt.SpeechMetadata | None = None
"""Metadata of stt input audio. Required when start_stage = stt."""
stt_stream: AsyncIterable[bytes] | None = None
"""Input audio for stt. Required when start_stage = stt."""
intent_input: str | None = None
"""Input for conversation agent. Required when start_stage = intent."""
tts_input: str | None = None
"""Input for text to speech. Required when start_stage = tts."""
conversation_id: str | None = None
async def execute(self) -> None:
"""Run pipeline."""
self.run.start()
current_stage = self.run.start_stage
try:
# Speech to text
intent_input = self.intent_input
if current_stage == PipelineStage.STT:
assert self.stt_metadata is not None
assert self.stt_stream is not None
intent_input = await self.run.speech_to_text(
self.stt_metadata,
self.stt_stream,
)
current_stage = PipelineStage.INTENT
if self.run.end_stage != PipelineStage.STT:
tts_input = self.tts_input
if current_stage == PipelineStage.INTENT:
assert intent_input is not None
tts_input = await self.run.recognize_intent(
intent_input, self.conversation_id
)
current_stage = PipelineStage.TTS
if self.run.end_stage != PipelineStage.INTENT:
if current_stage == PipelineStage.TTS:
assert tts_input is not None
await self.run.text_to_speech(tts_input)
except PipelineError as err:
self.run.process_event(
PipelineEvent(
PipelineEventType.ERROR,
{"code": err.code, "message": err.message},
)
)
return
self.run.end()
async def validate(self) -> None:
"""Validate pipeline input against start stage."""
if self.run.start_stage == PipelineStage.STT:
if self.run.pipeline.stt_engine is None:
raise PipelineRunValidationError(
"the pipeline does not support speech to text"
)
if self.stt_metadata is None:
raise PipelineRunValidationError(
"stt_metadata is required for speech to text"
)
if self.stt_stream is None:
raise PipelineRunValidationError(
"stt_stream is required for speech to text"
)
elif self.run.start_stage == PipelineStage.INTENT:
if self.intent_input is None:
raise PipelineRunValidationError(
"intent_input is required for intent recognition"
)
elif self.run.start_stage == PipelineStage.TTS:
if self.tts_input is None:
raise PipelineRunValidationError(
"tts_input is required for text to speech"
)
if self.run.end_stage == PipelineStage.TTS:
if self.run.pipeline.tts_engine is None:
raise PipelineRunValidationError(
"the pipeline does not support text to speech"
)
start_stage_index = PIPELINE_STAGE_ORDER.index(self.run.start_stage)
prepare_tasks = []
if start_stage_index <= PIPELINE_STAGE_ORDER.index(PipelineStage.STT):
# self.stt_metadata can't be None or we'd raise above
prepare_tasks.append(self.run.prepare_speech_to_text(self.stt_metadata)) # type: ignore[arg-type]
if start_stage_index <= PIPELINE_STAGE_ORDER.index(PipelineStage.INTENT):
prepare_tasks.append(self.run.prepare_recognize_intent())
if start_stage_index <= PIPELINE_STAGE_ORDER.index(PipelineStage.TTS):
prepare_tasks.append(self.run.prepare_text_to_speech())
if prepare_tasks:
await asyncio.gather(*prepare_tasks)
class PipelinePreferred(CollectionError):
"""Raised when attempting to delete the preferred pipelen."""
def __init__(self, item_id: str) -> None:
"""Initialize pipeline preferred error."""
super().__init__(f"Item {item_id} preferred.")
self.item_id = item_id
class SerializedPipelineStorageCollection(SerializedStorageCollection):
"""Serialized pipeline storage collection."""
preferred_item: str
class PipelineStorageCollection(
StorageCollection[Pipeline, SerializedPipelineStorageCollection]
):
"""Pipeline storage collection."""
_preferred_item: str
async def _async_load_data(self) -> SerializedPipelineStorageCollection | None:
"""Load the data."""
if not (data := await super()._async_load_data()):
pipeline = await _async_create_default_pipeline(self.hass, self)
self._preferred_item = pipeline.id
return data
self._preferred_item = data["preferred_item"]
return data
async def _process_create_data(self, data: dict) -> dict:
"""Validate the config is valid."""
validated_data: dict = validate_language(data)
return validated_data
@callback
def _get_suggested_id(self, info: dict) -> str:
"""Suggest an ID based on the config."""
return ulid_util.ulid()
async def _update_data(self, item: Pipeline, update_data: dict) -> Pipeline:
"""Return a new updated item."""
update_data = validate_language(update_data)
return Pipeline(id=item.id, **update_data)
def _create_item(self, item_id: str, data: dict) -> Pipeline:
"""Create an item from validated config."""
return Pipeline(id=item_id, **data)
def _deserialize_item(self, data: dict) -> Pipeline:
"""Create an item from its serialized representation."""
return Pipeline(**data)
def _serialize_item(self, item_id: str, item: Pipeline) -> dict:
"""Return the serialized representation of an item for storing."""
return item.to_json()
async def async_delete_item(self, item_id: str) -> None:
"""Delete item."""
if self._preferred_item == item_id:
raise PipelinePreferred(item_id)
await super().async_delete_item(item_id)
@callback
def async_get_preferred_item(self) -> str:
"""Get the id of the preferred item."""
return self._preferred_item
@callback
def async_set_preferred_item(self, item_id: str) -> None:
"""Set the preferred pipeline."""
if item_id not in self.data:
raise ItemNotFound(item_id)
self._preferred_item = item_id
self._async_schedule_save()
@callback
def _data_to_save(self) -> SerializedPipelineStorageCollection:
"""Return JSON-compatible date for storing to file."""
base_data = super()._base_data_to_save()
return {
"items": base_data["items"],
"preferred_item": self._preferred_item,
}
class PipelineStorageCollectionWebsocket(
StorageCollectionWebsocket[PipelineStorageCollection]
):
"""Class to expose storage collection management over websocket."""
@callback
def async_setup(
self,
hass: HomeAssistant,
*,
create_list: bool = True,
create_create: bool = True,
) -> None:
"""Set up the websocket commands."""
super().async_setup(hass, create_list=create_list, create_create=create_create)
websocket_api.async_register_command(
hass,
f"{self.api_prefix}/get",
self.ws_get_item,
websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend(
{
vol.Required("type"): f"{self.api_prefix}/get",
vol.Optional(self.item_id_key): str,
}
),
)
websocket_api.async_register_command(
hass,
f"{self.api_prefix}/set_preferred",
websocket_api.require_admin(
websocket_api.async_response(self.ws_set_preferred_item)
),
websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend(
{
vol.Required("type"): f"{self.api_prefix}/set_preferred",
vol.Required(self.item_id_key): str,
}
),
)
async def ws_delete_item(
self, hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict
) -> None:
"""Delete an item."""
try:
await super().ws_delete_item(hass, connection, msg)
except PipelinePreferred as exc:
connection.send_error(
msg["id"], websocket_api.const.ERR_NOT_ALLOWED, str(exc)
)
@callback
def ws_get_item(
self, hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict
) -> None:
"""Get an item."""
item_id = msg.get(self.item_id_key)
if item_id is None:
item_id = self.storage_collection.async_get_preferred_item()
if item_id not in self.storage_collection.data:
connection.send_error(
msg["id"],
websocket_api.const.ERR_NOT_FOUND,
f"Unable to find {self.item_id_key} {item_id}",
)
return
connection.send_result(msg["id"], self.storage_collection.data[item_id])
@callback
def ws_list_item(
self, hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict
) -> None:
"""List items."""
connection.send_result(
msg["id"],
{
"pipelines": self.storage_collection.async_items(),
"preferred_pipeline": self.storage_collection.async_get_preferred_item(),
},
)
async def ws_set_preferred_item(
self,
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Set the preferred item."""
try:
self.storage_collection.async_set_preferred_item(msg[self.item_id_key])
except ItemNotFound:
connection.send_error(
msg["id"], websocket_api.const.ERR_NOT_FOUND, "unknown item"
)
return
connection.send_result(msg["id"])
@dataclass
class PipelineData:
"""Store and debug data stored in hass.data."""
pipeline_runs: dict[str, LimitedSizeDict[str, PipelineRunDebug]]
pipeline_store: PipelineStorageCollection
@dataclass
class PipelineRunDebug:
"""Debug data for a pipelinerun."""
events: list[PipelineEvent] = field(default_factory=list, init=False)
timestamp: str = field(
default_factory=lambda: dt_util.utcnow().isoformat(),
init=False,
)
@singleton(DOMAIN)
async def async_setup_pipeline_store(hass: HomeAssistant) -> PipelineData:
"""Set up the pipeline storage collection."""
pipeline_store = PipelineStorageCollection(
Store(hass, STORAGE_VERSION, STORAGE_KEY)
)
await pipeline_store.async_load()
PipelineStorageCollectionWebsocket(
pipeline_store,
f"{DOMAIN}/pipeline",
"pipeline",
PIPELINE_FIELDS,
PIPELINE_FIELDS,
).async_setup(hass)
return PipelineData({}, pipeline_store)

View File

@ -0,0 +1,95 @@
"""Select entities for a pipeline."""
from __future__ import annotations
from collections.abc import Iterable
from homeassistant.components.select import SelectEntity, SelectEntityDescription
from homeassistant.const import EntityCategory, Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import collection, entity_registry as er, restore_state
from .const import DOMAIN
from .pipeline import PipelineStorageCollection
OPTION_PREFERRED = "preferred"
@callback
def get_chosen_pipeline(
hass: HomeAssistant, domain: str, unique_id_prefix: str
) -> str | None:
"""Get the chosen pipeline for a domain."""
ent_reg = er.async_get(hass)
pipeline_entity_id = ent_reg.async_get_entity_id(
Platform.SELECT, domain, f"{unique_id_prefix}-pipeline"
)
if pipeline_entity_id is None:
return None
state = hass.states.get(pipeline_entity_id)
if state is None or state.state == OPTION_PREFERRED:
return None
pipeline_store: PipelineStorageCollection = hass.data[DOMAIN].pipeline_store
return next(
(item.id for item in pipeline_store.async_items() if item.name == state.state),
None,
)
class AssistPipelineSelect(SelectEntity, restore_state.RestoreEntity):
"""Entity to represent a pipeline selector."""
entity_description = SelectEntityDescription(
key="pipeline",
translation_key="pipeline",
entity_category=EntityCategory.CONFIG,
)
_attr_should_poll = False
_attr_current_option = OPTION_PREFERRED
_attr_options = [OPTION_PREFERRED]
def __init__(self, hass: HomeAssistant, unique_id_prefix: str) -> None:
"""Initialize a pipeline selector."""
self._attr_unique_id = f"{unique_id_prefix}-pipeline"
self.hass = hass
self._update_options()
async def async_added_to_hass(self) -> None:
"""When entity is added to Home Assistant."""
await super().async_added_to_hass()
pipeline_store: PipelineStorageCollection = self.hass.data[
DOMAIN
].pipeline_store
pipeline_store.async_add_change_set_listener(self._pipelines_updated)
state = await self.async_get_last_state()
if state is not None and state.state in self.options:
self._attr_current_option = state.state
async def async_select_option(self, option: str) -> None:
"""Select an option."""
self._attr_current_option = option
self.async_write_ha_state()
async def _pipelines_updated(
self, change_sets: Iterable[collection.CollectionChangeSet]
) -> None:
"""Handle pipeline update."""
self._update_options()
self.async_write_ha_state()
@callback
def _update_options(self) -> None:
"""Handle pipeline update."""
pipeline_store: PipelineStorageCollection = self.hass.data[
DOMAIN
].pipeline_store
options = [OPTION_PREFERRED]
options.extend(sorted(item.name for item in pipeline_store.async_items()))
self._attr_options = options
if self._attr_current_option not in options:
self._attr_current_option = OPTION_PREFERRED

View File

@ -0,0 +1,17 @@
{
"entity": {
"binary_sensor": {
"assist_in_progress": {
"name": "Assist in progress"
}
},
"select": {
"pipeline": {
"name": "Assist pipeline",
"state": {
"preferred": "Preferred"
}
}
}
}
}

View File

@ -0,0 +1,128 @@
"""Voice activity detection."""
from dataclasses import dataclass, field
import webrtcvad
_SAMPLE_RATE = 16000
@dataclass
class VoiceCommandSegmenter:
"""Segments an audio stream into voice commands using webrtcvad."""
vad_mode: int = 3
"""Aggressiveness in filtering out non-speech. 3 is the most aggressive."""
vad_frames: int = 480 # 30 ms
"""Must be 10, 20, or 30 ms at 16Khz."""
speech_seconds: float = 0.3
"""Seconds of speech before voice command has started."""
silence_seconds: float = 0.5
"""Seconds of silence after voice command has ended."""
timeout_seconds: float = 15.0
"""Maximum number of seconds before stopping with timeout=True."""
reset_seconds: float = 1.0
"""Seconds before reset start/stop time counters."""
in_command: bool = False
"""True if inside voice command."""
_speech_seconds_left: float = 0.0
"""Seconds left before considering voice command as started."""
_silence_seconds_left: float = 0.0
"""Seconds left before considering voice command as stopped."""
_timeout_seconds_left: float = 0.0
"""Seconds left before considering voice command timed out."""
_reset_seconds_left: float = 0.0
"""Seconds left before resetting start/stop time counters."""
_vad: webrtcvad.Vad = None
_audio_buffer: bytes = field(default_factory=bytes)
_bytes_per_chunk: int = 480 * 2 # 16-bit samples
_seconds_per_chunk: float = 0.03 # 30 ms
def __post_init__(self) -> None:
"""Initialize VAD."""
self._vad = webrtcvad.Vad(self.vad_mode)
self._bytes_per_chunk = self.vad_frames * 2
self._seconds_per_chunk = self.vad_frames / _SAMPLE_RATE
self.reset()
def reset(self) -> None:
"""Reset all counters and state."""
self._audio_buffer = b""
self._speech_seconds_left = self.speech_seconds
self._silence_seconds_left = self.silence_seconds
self._timeout_seconds_left = self.timeout_seconds
self._reset_seconds_left = self.reset_seconds
self.in_command = False
def process(self, samples: bytes) -> bool:
"""Process a 16-bit 16Khz mono audio samples.
Returns False when command is done.
"""
self._audio_buffer += samples
# Process in 10, 20, or 30 ms chunks.
num_chunks = len(self._audio_buffer) // self._bytes_per_chunk
for chunk_idx in range(num_chunks):
chunk_offset = chunk_idx * self._bytes_per_chunk
chunk = self._audio_buffer[
chunk_offset : chunk_offset + self._bytes_per_chunk
]
if not self._process_chunk(chunk):
self.reset()
return False
if num_chunks > 0:
# Remove from buffer
self._audio_buffer = self._audio_buffer[
num_chunks * self._bytes_per_chunk :
]
return True
def _process_chunk(self, chunk: bytes) -> bool:
"""Process a single chunk of 16-bit 16Khz mono audio.
Returns False when command is done.
"""
is_speech = self._vad.is_speech(chunk, _SAMPLE_RATE)
self._timeout_seconds_left -= self._seconds_per_chunk
if self._timeout_seconds_left <= 0:
return False
if not self.in_command:
if is_speech:
self._reset_seconds_left = self.reset_seconds
self._speech_seconds_left -= self._seconds_per_chunk
if self._speech_seconds_left <= 0:
# Inside voice command
self.in_command = True
else:
# Reset if enough silence
self._reset_seconds_left -= self._seconds_per_chunk
if self._reset_seconds_left <= 0:
self._speech_seconds_left = self.speech_seconds
else:
if not is_speech:
self._reset_seconds_left = self.reset_seconds
self._silence_seconds_left -= self._seconds_per_chunk
if self._silence_seconds_left <= 0:
return False
else:
# Reset if enough speech
self._reset_seconds_left -= self._seconds_per_chunk
if self._reset_seconds_left <= 0:
self._silence_seconds_left = self.silence_seconds
return True

View File

@ -0,0 +1,333 @@
"""Assist pipeline Websocket API."""
import asyncio
# Suppressing disable=deprecated-module is needed for Python 3.11
import audioop # pylint: disable=deprecated-module
from collections.abc import AsyncGenerator, Callable
import logging
from typing import Any
import async_timeout
import voluptuous as vol
from homeassistant.components import conversation, stt, tts, websocket_api
from homeassistant.const import MATCH_ALL
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv
from homeassistant.util import language as language_util
from .const import DOMAIN
from .pipeline import (
PipelineData,
PipelineError,
PipelineEvent,
PipelineEventType,
PipelineInput,
PipelineRun,
PipelineStage,
async_get_pipeline,
)
from .vad import VoiceCommandSegmenter
DEFAULT_TIMEOUT = 30
_LOGGER = logging.getLogger(__name__)
@callback
def async_register_websocket_api(hass: HomeAssistant) -> None:
"""Register the websocket API."""
websocket_api.async_register_command(hass, websocket_run)
websocket_api.async_register_command(hass, websocket_list_languages)
websocket_api.async_register_command(hass, websocket_list_runs)
websocket_api.async_register_command(hass, websocket_get_run)
@websocket_api.websocket_command(
vol.All(
websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend(
{
vol.Required("type"): "assist_pipeline/run",
# pylint: disable-next=unnecessary-lambda
vol.Required("start_stage"): lambda val: PipelineStage(val),
# pylint: disable-next=unnecessary-lambda
vol.Required("end_stage"): lambda val: PipelineStage(val),
vol.Optional("input"): dict,
vol.Optional("pipeline"): str,
vol.Optional("conversation_id"): vol.Any(str, None),
vol.Optional("timeout"): vol.Any(float, int),
},
),
cv.key_value_schemas(
"start_stage",
{
PipelineStage.STT: vol.Schema(
{vol.Required("input"): {vol.Required("sample_rate"): int}},
extra=vol.ALLOW_EXTRA,
),
PipelineStage.INTENT: vol.Schema(
{vol.Required("input"): {"text": str}},
extra=vol.ALLOW_EXTRA,
),
PipelineStage.TTS: vol.Schema(
{vol.Required("input"): {"text": str}},
extra=vol.ALLOW_EXTRA,
),
},
),
),
)
@websocket_api.async_response
async def websocket_run(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Run a pipeline."""
pipeline_id = msg.get("pipeline")
pipeline = async_get_pipeline(hass, pipeline_id=pipeline_id)
if pipeline is None:
connection.send_error(
msg["id"],
"pipeline-not-found",
f"Pipeline not found: id={pipeline_id}",
)
return
timeout = msg.get("timeout", DEFAULT_TIMEOUT)
start_stage = PipelineStage(msg["start_stage"])
end_stage = PipelineStage(msg["end_stage"])
handler_id: int | None = None
unregister_handler: Callable[[], None] | None = None
# Arguments to PipelineInput
input_args: dict[str, Any] = {
"conversation_id": msg.get("conversation_id"),
}
if start_stage == PipelineStage.STT:
# Audio pipeline that will receive audio as binary websocket messages
audio_queue: "asyncio.Queue[bytes]" = asyncio.Queue()
incoming_sample_rate = msg["input"]["sample_rate"]
async def stt_stream() -> AsyncGenerator[bytes, None]:
state = None
segmenter = VoiceCommandSegmenter()
# Yield until we receive an empty chunk
while chunk := await audio_queue.get():
chunk, state = audioop.ratecv(
chunk, 2, 1, incoming_sample_rate, 16000, state
)
if not segmenter.process(chunk):
# Voice command is finished
break
yield chunk
def handle_binary(
_hass: HomeAssistant,
_connection: websocket_api.ActiveConnection,
data: bytes,
) -> None:
# Forward to STT audio stream
audio_queue.put_nowait(data)
handler_id, unregister_handler = connection.async_register_binary_handler(
handle_binary
)
# Audio input must be raw PCM at 16Khz with 16-bit mono samples
input_args["stt_metadata"] = stt.SpeechMetadata(
language=pipeline.stt_language or pipeline.language,
format=stt.AudioFormats.WAV,
codec=stt.AudioCodecs.PCM,
bit_rate=stt.AudioBitRates.BITRATE_16,
sample_rate=stt.AudioSampleRates.SAMPLERATE_16000,
channel=stt.AudioChannels.CHANNEL_MONO,
)
input_args["stt_stream"] = stt_stream()
elif start_stage == PipelineStage.INTENT:
# Input to conversation agent
input_args["intent_input"] = msg["input"]["text"]
elif start_stage == PipelineStage.TTS:
# Input to text to speech system
input_args["tts_input"] = msg["input"]["text"]
input_args["run"] = PipelineRun(
hass,
context=connection.context(msg),
pipeline=pipeline,
start_stage=start_stage,
end_stage=end_stage,
event_callback=lambda event: connection.send_event(msg["id"], event),
runner_data={
"stt_binary_handler_id": handler_id,
"timeout": timeout,
},
)
pipeline_input = PipelineInput(**input_args)
try:
await pipeline_input.validate()
except PipelineError as error:
# Report more specific error when possible
connection.send_error(msg["id"], error.code, error.message)
return
# Confirm subscription
connection.send_result(msg["id"])
run_task = hass.async_create_task(pipeline_input.execute())
# Cancel pipeline if user unsubscribes
connection.subscriptions[msg["id"]] = run_task.cancel
try:
# Task contains a timeout
async with async_timeout.timeout(timeout):
await run_task
except asyncio.TimeoutError:
pipeline_input.run.process_event(
PipelineEvent(
PipelineEventType.ERROR,
{"code": "timeout", "message": "Timeout running pipeline"},
)
)
finally:
if unregister_handler is not None:
# Unregister binary handler
unregister_handler()
@callback
@websocket_api.require_admin
@websocket_api.websocket_command(
{
vol.Required("type"): "assist_pipeline/pipeline_debug/list",
vol.Required("pipeline_id"): str,
}
)
def websocket_list_runs(
hass: HomeAssistant,
connection: websocket_api.connection.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""List pipeline runs for which debug data is available."""
pipeline_data: PipelineData = hass.data[DOMAIN]
pipeline_id = msg["pipeline_id"]
if pipeline_id not in pipeline_data.pipeline_runs:
connection.send_result(msg["id"], {"pipeline_runs": []})
return
pipeline_runs = pipeline_data.pipeline_runs[pipeline_id]
connection.send_result(
msg["id"],
{
"pipeline_runs": [
{"pipeline_run_id": id, "timestamp": pipeline_run.timestamp}
for id, pipeline_run in pipeline_runs.items()
]
},
)
@callback
@websocket_api.require_admin
@websocket_api.websocket_command(
{
vol.Required("type"): "assist_pipeline/pipeline_debug/get",
vol.Required("pipeline_id"): str,
vol.Required("pipeline_run_id"): str,
}
)
def websocket_get_run(
hass: HomeAssistant,
connection: websocket_api.connection.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Get debug data for a pipeline run."""
pipeline_data: PipelineData = hass.data[DOMAIN]
pipeline_id = msg["pipeline_id"]
pipeline_run_id = msg["pipeline_run_id"]
if pipeline_id not in pipeline_data.pipeline_runs:
connection.send_error(
msg["id"],
websocket_api.const.ERR_NOT_FOUND,
f"pipeline_id {pipeline_id} not found",
)
return
pipeline_runs = pipeline_data.pipeline_runs[pipeline_id]
if pipeline_run_id not in pipeline_runs:
connection.send_error(
msg["id"],
websocket_api.const.ERR_NOT_FOUND,
f"pipeline_run_id {pipeline_run_id} not found",
)
return
connection.send_result(
msg["id"],
{"events": pipeline_runs[pipeline_run_id].events},
)
@callback
@websocket_api.websocket_command(
{
vol.Required("type"): "assist_pipeline/language/list",
}
)
@websocket_api.async_response
async def websocket_list_languages(
hass: HomeAssistant,
connection: websocket_api.connection.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""List languages which are supported by a complete pipeline.
This will return a list of languages which are supported by at least one stt, tts
and conversation engine respectively.
"""
conv_language_tags = await conversation.async_get_conversation_languages(hass)
stt_language_tags = stt.async_get_speech_to_text_languages(hass)
tts_language_tags = tts.async_get_text_to_speech_languages(hass)
pipeline_languages: set[str] | None = None
if conv_language_tags and conv_language_tags != MATCH_ALL:
languages = set()
for language_tag in conv_language_tags:
dialect = language_util.Dialect.parse(language_tag)
languages.add(dialect.language)
pipeline_languages = languages
if stt_language_tags:
languages = set()
for language_tag in stt_language_tags:
dialect = language_util.Dialect.parse(language_tag)
languages.add(dialect.language)
if pipeline_languages is not None:
pipeline_languages &= languages
else:
pipeline_languages = languages
if tts_language_tags:
languages = set()
for language_tag in tts_language_tags:
dialect = language_util.Dialect.parse(language_tag)
languages.add(dialect.language)
if pipeline_languages is not None:
pipeline_languages &= languages
else:
pipeline_languages = languages
connection.send_result(
msg["id"],
{"languages": pipeline_languages},
)

View File

@ -11,7 +11,7 @@
"password": "[%key:common::config_flow::data::password%]",
"ssh_key": "Path to your SSH key file (instead of password)",
"protocol": "Communication protocol to use",
"port": "[%key:common::config_flow::data::port%] (leave empty for protocol default)",
"port": "Port (leave empty for protocol default)",
"mode": "[%key:common::config_flow::data::mode%]"
}
}

View File

@ -4,7 +4,7 @@ from __future__ import annotations
import logging
from typing import Any
from atenpdu import AtenPE, AtenPEError
from atenpdu import AtenPE, AtenPEError # pylint: disable=import-error
import voluptuous as vol
from homeassistant.components.switch import (

View File

@ -3,6 +3,7 @@ import asyncio
import logging
from aiohttp import ClientError
from yalexs.util import get_latest_activity
from homeassistant.core import callback
from homeassistant.helpers.debounce import Debouncer
@ -169,12 +170,11 @@ class ActivityStream(AugustSubscriberMixin):
device_id = activity.device_id
activity_type = activity.activity_type
device_activities = self._latest_activities.setdefault(device_id, {})
lastest_activity = device_activities.get(activity_type)
# Ignore activities that are older than the latest one
# Ignore activities that are older than the latest one unless it is a non
# locking or unlocking activity with the exact same start time.
if (
lastest_activity
and lastest_activity.activity_start_time >= activity.activity_start_time
get_latest_activity(activity, device_activities.get(activity_type))
!= activity
):
continue

View File

@ -5,7 +5,7 @@ from typing import Any
from aiohttp import ClientResponseError
from yalexs.activity import SOURCE_PUBNUB, ActivityType
from yalexs.lock import LockStatus
from yalexs.util import update_lock_detail_from_activity
from yalexs.util import get_latest_activity, update_lock_detail_from_activity
from homeassistant.components.lock import ATTR_CHANGED_BY, LockEntity
from homeassistant.config_entries import ConfigEntry
@ -90,17 +90,26 @@ class AugustLock(AugustEntityMixin, RestoreEntity, LockEntity):
@callback
def _update_from_data(self):
"""Get the latest state of the sensor and update activity."""
lock_activity = self._data.activity_stream.get_latest_device_activity(
self._device_id,
{ActivityType.LOCK_OPERATION, ActivityType.LOCK_OPERATION_WITHOUT_OPERATOR},
activity_stream = self._data.activity_stream
device_id = self._device_id
if lock_activity := activity_stream.get_latest_device_activity(
device_id,
{ActivityType.LOCK_OPERATION},
):
self._attr_changed_by = lock_activity.operated_by
lock_activity_without_operator = activity_stream.get_latest_device_activity(
device_id,
{ActivityType.LOCK_OPERATION_WITHOUT_OPERATOR},
)
if lock_activity is not None:
self._attr_changed_by = lock_activity.operated_by
update_lock_detail_from_activity(self._detail, lock_activity)
# If the source is pubnub the lock must be online since its a live update
if lock_activity.source == SOURCE_PUBNUB:
if latest_activity := get_latest_activity(
lock_activity_without_operator, lock_activity
):
if latest_activity.source == SOURCE_PUBNUB:
# If the source is pubnub the lock must be online since its a live update
self._detail.set_online(True)
update_lock_detail_from_activity(self._detail, latest_activity)
bridge_activity = self._data.activity_stream.get_latest_device_activity(
self._device_id, {ActivityType.BRIDGE_OPERATION}

View File

@ -28,5 +28,5 @@
"documentation": "https://www.home-assistant.io/integrations/august",
"iot_class": "cloud_push",
"loggers": ["pubnub", "yalexs"],
"requirements": ["yalexs==1.2.7", "yalexs-ble==2.1.14"]
"requirements": ["yalexs==1.3.3", "yalexs-ble==2.1.16"]
}

View File

@ -659,7 +659,7 @@ class AutomationEntity(ToggleEntity, RestoreEntity):
)
@dataclass
@dataclass(slots=True)
class AutomationEntityConfig:
"""Container for prepared automation entity configuration."""

View File

@ -51,7 +51,6 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
if config_entry.version != 3:
# Home Assistant 2023.2
config_entry.version = 3
hass.config_entries.async_update_entry(config_entry)
_LOGGER.info("Migration to version %s successful", config_entry.version)

View File

@ -4,8 +4,13 @@ from __future__ import annotations
import json
import logging
# pylint: disable-next=import-error, no-name-in-module
from azure.servicebus import ServiceBusMessage
# pylint: disable-next=import-error, no-name-in-module
from azure.servicebus.aio import ServiceBusClient, ServiceBusSender
# pylint: disable-next=import-error, no-name-in-module
from azure.servicebus.exceptions import (
MessagingEntityNotFoundError,
ServiceBusConnectionError,

View File

@ -23,8 +23,10 @@ from homeassistant.util.json import json_loads_object
from .const import DOMAIN, EXCLUDE_FROM_BACKUP, LOGGER
BUF_SIZE = 2**20 * 4 # 4MB
@dataclass
@dataclass(slots=True)
class Backup:
"""Backup class."""
@ -99,7 +101,7 @@ class BackupManager:
backups: dict[str, Backup] = {}
for backup_path in self.backup_dir.glob("*.tar"):
try:
with tarfile.open(backup_path, "r:") as backup_file:
with tarfile.open(backup_path, "r:", bufsize=BUF_SIZE) as backup_file:
if data_file := backup_file.extractfile("./backup.json"):
data = json_loads_object(data_file.read())
backup = Backup(
@ -227,7 +229,7 @@ class BackupManager:
self.backup_dir.mkdir()
with TemporaryDirectory() as tmp_dir, SecureTarFile(
tar_file_path, "w", gzip=False
tar_file_path, "w", gzip=False, bufsize=BUF_SIZE
) as tar_file:
tmp_dir_path = Path(tmp_dir)
save_json(
@ -237,6 +239,7 @@ class BackupManager:
with SecureTarFile(
tmp_dir_path.joinpath("./homeassistant.tar.gz").as_posix(),
"w",
bufsize=BUF_SIZE,
) as core_tar:
atomic_contents_add(
tar_file=core_tar,

View File

@ -7,5 +7,5 @@
"integration_type": "system",
"iot_class": "calculated",
"quality_scale": "internal",
"requirements": ["securetar==2022.2.0"]
"requirements": ["securetar==2023.3.0"]
}

View File

@ -39,7 +39,11 @@ async def async_setup_entry(
class BAFFan(BAFEntity, FanEntity):
"""BAF ceiling fan component."""
_attr_supported_features = FanEntityFeature.SET_SPEED | FanEntityFeature.DIRECTION
_attr_supported_features = (
FanEntityFeature.SET_SPEED
| FanEntityFeature.DIRECTION
| FanEntityFeature.PRESET_MODE
)
_attr_preset_modes = [PRESET_MODE_AUTO]
_attr_speed_count = SPEED_COUNT

View File

@ -84,7 +84,7 @@ class BleBoxConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
) -> FlowResult:
"""Handle zeroconf discovery."""
hass = self.hass
ipaddress = host_port(discovery_info.__dict__)
ipaddress = (discovery_info.host, discovery_info.port)
self.device_config["host"] = discovery_info.host
self.device_config["port"] = discovery_info.port

View File

@ -31,7 +31,7 @@ from homeassistant.config_entries import (
ConfigEntry,
)
from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP
from homeassistant.core import HomeAssistant, callback as hass_callback
from homeassistant.core import Event, HassJob, HomeAssistant, callback as hass_callback
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr, discovery_flow
from homeassistant.helpers.debounce import Debouncer
@ -198,10 +198,18 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
function=_async_rediscover_adapters,
)
async def _async_shutdown_debouncer(_: Event) -> None:
"""Shutdown debouncer."""
await discovery_debouncer.async_shutdown()
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_shutdown_debouncer)
async def _async_call_debouncer(now: datetime.datetime) -> None:
"""Call the debouncer at a later time."""
await discovery_debouncer.async_call()
call_debouncer_job = HassJob(_async_call_debouncer, cancel_on_shutdown=True)
def _async_trigger_discovery() -> None:
# There are so many bluetooth adapter models that
# we check the bus whenever a usb device is plugged in
@ -220,7 +228,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async_call_later(
hass,
BLUETOOTH_DISCOVERY_COOLDOWN_SECONDS + LINUX_FIRMWARE_LOAD_FALLBACK_SECONDS,
_async_call_debouncer,
call_debouncer_job,
)
cancel = usb.async_register_scan_request_callback(hass, _async_trigger_discovery)

View File

@ -169,3 +169,9 @@ class ActiveBluetoothDataUpdateCoordinator(
# possible after a device comes online or back in range, if a poll is due
if self.needs_poll(service_info):
self.hass.async_create_task(self._debounced_poll.async_call())
@callback
def _async_stop(self) -> None:
"""Cancel debouncer and stop the callbacks."""
self._debounced_poll.async_cancel()
super()._async_stop()

View File

@ -158,3 +158,9 @@ class ActiveBluetoothProcessorCoordinator(
# possible after a device comes online or back in range, if a poll is due
if self.needs_poll(service_info):
self.hass.async_create_task(self._debounced_poll.async_call())
@callback
def _async_stop(self) -> None:
"""Cancel debouncer and stop the callbacks."""
self._debounced_poll.async_cancel()
super()._async_stop()

View File

@ -39,7 +39,7 @@ MONOTONIC_TIME: Final = monotonic_time_coarse
_LOGGER = logging.getLogger(__name__)
@dataclass
@dataclass(slots=True)
class BluetoothScannerDevice:
"""Data for a bluetooth device from a given scanner."""
@ -309,43 +309,28 @@ class BaseHaRemoteScanner(BaseHaScanner):
# merges the dicts on PropertiesChanged
prev_device = prev_discovery[0]
prev_advertisement = prev_discovery[1]
if (
local_name
and prev_device.name
and len(prev_device.name) > len(local_name)
):
local_name = prev_device.name
if service_uuids and service_uuids != prev_advertisement.service_uuids:
service_uuids = list(
set(service_uuids + prev_advertisement.service_uuids)
)
elif not service_uuids:
service_uuids = prev_advertisement.service_uuids
if service_data and service_data != prev_advertisement.service_data:
service_data = {**prev_advertisement.service_data, **service_data}
elif not service_data:
service_data = prev_advertisement.service_data
if (
manufacturer_data
and manufacturer_data != prev_advertisement.manufacturer_data
):
manufacturer_data = {
**prev_advertisement.manufacturer_data,
**manufacturer_data,
}
elif not manufacturer_data:
manufacturer_data = prev_advertisement.manufacturer_data
prev_service_uuids = prev_advertisement.service_uuids
prev_service_data = prev_advertisement.service_data
prev_manufacturer_data = prev_advertisement.manufacturer_data
prev_name = prev_device.name
advertisement_data = AdvertisementData(
local_name=None if local_name == "" else local_name,
manufacturer_data=manufacturer_data,
service_data=service_data,
service_uuids=service_uuids,
rssi=rssi,
tx_power=NO_RSSI_VALUE if tx_power is None else tx_power,
platform_data=(),
)
if prev_discovery:
if local_name and prev_name and len(prev_name) > len(local_name):
local_name = prev_name
if service_uuids and service_uuids != prev_service_uuids:
service_uuids = list(set(service_uuids + prev_service_uuids))
elif not service_uuids:
service_uuids = prev_service_uuids
if service_data and service_data != prev_service_data:
service_data = prev_service_data | service_data
elif not service_data:
service_data = prev_service_data
if manufacturer_data and manufacturer_data != prev_manufacturer_data:
manufacturer_data = prev_manufacturer_data | manufacturer_data
elif not manufacturer_data:
manufacturer_data = prev_manufacturer_data
#
# Bleak updates the BLEDevice via create_or_update_device.
# We need to do the same to ensure integrations that already
@ -366,6 +351,16 @@ class BaseHaRemoteScanner(BaseHaScanner):
details=self._details | details,
rssi=rssi, # deprecated, will be removed in newer bleak
)
advertisement_data = AdvertisementData(
local_name=None if local_name == "" else local_name,
manufacturer_data=manufacturer_data,
service_data=service_data,
service_uuids=service_uuids,
tx_power=NO_RSSI_VALUE if tx_power is None else tx_power,
rssi=rssi,
platform_data=(),
)
self._discovered_device_advertisement_datas[address] = (
device,
advertisement_data,
@ -373,12 +368,12 @@ class BaseHaRemoteScanner(BaseHaScanner):
self._discovered_device_timestamps[address] = now
self._new_info_callback(
BluetoothServiceInfoBleak(
name=advertisement_data.local_name or device.name or device.address,
address=device.address,
name=local_name or address,
address=address,
rssi=rssi,
manufacturer_data=advertisement_data.manufacturer_data,
service_data=advertisement_data.service_data,
service_uuids=advertisement_data.service_uuids,
manufacturer_data=manufacturer_data,
service_data=service_data,
service_uuids=service_uuids,
source=self.source,
device=device,
advertisement=advertisement_data,

View File

@ -15,11 +15,11 @@
],
"quality_scale": "internal",
"requirements": [
"bleak==0.20.1",
"bleak==0.20.2",
"bleak-retry-connector==3.0.2",
"bluetooth-adapters==0.15.3",
"bluetooth-auto-recovery==1.0.3",
"bluetooth-data-tools==0.3.1",
"dbus-fast==1.84.2"
"bluetooth-auto-recovery==1.1.1",
"bluetooth-data-tools==0.4.0",
"dbus-fast==1.85.0"
]
}

View File

@ -61,7 +61,7 @@ class BluetoothCallbackMatcherWithCallback(
"""Callback matcher for the bluetooth integration that stores the callback."""
@dataclass(frozen=False)
@dataclass(slots=True, frozen=False)
class IntegrationMatchHistory:
"""Track which fields have been seen."""

View File

@ -20,7 +20,7 @@ MANAGER: BluetoothManager | None = None
MONOTONIC_TIME: Final = monotonic_time_coarse
@dataclass
@dataclass(slots=True)
class HaBluetoothConnector:
"""Data for how to connect a BLEDevice from a given scanner."""

View File

@ -20,7 +20,7 @@ if TYPE_CHECKING:
from . import BluetoothChange, BluetoothScanningMode, BluetoothServiceInfoBleak
@dataclasses.dataclass(frozen=True)
@dataclasses.dataclass(slots=True, frozen=True)
class PassiveBluetoothEntityKey:
"""Key for a passive bluetooth entity.
@ -36,7 +36,7 @@ class PassiveBluetoothEntityKey:
_T = TypeVar("_T")
@dataclasses.dataclass(frozen=True)
@dataclasses.dataclass(slots=True, frozen=True)
class PassiveBluetoothDataUpdate(Generic[_T]):
"""Generic bluetooth data."""

View File

@ -34,7 +34,7 @@ if TYPE_CHECKING:
from .manager import BluetoothManager
@dataclass
@dataclass(slots=True)
class _HaWrappedBleakBackend:
"""Wrap bleak backend to make it usable by Home Assistant."""
@ -251,8 +251,10 @@ class HaBleakClientWrapper(BleakClient):
assert models.MANAGER is not None
manager = models.MANAGER
wrapped_backend = self._async_get_best_available_backend_and_device(manager)
device = wrapped_backend.device
scanner = wrapped_backend.scanner
self._backend = wrapped_backend.client(
wrapped_backend.device,
device,
disconnected_callback=self._make_disconnected_callback(
self.__disconnected_callback
),
@ -261,8 +263,9 @@ class HaBleakClientWrapper(BleakClient):
)
if debug_logging := _LOGGER.isEnabledFor(logging.DEBUG):
# Only lookup the description if we are going to log it
description = ble_device_description(wrapped_backend.device)
rssi = wrapped_backend.device.rssi
description = ble_device_description(device)
_, adv = scanner.discovered_devices_and_advertisement_data[device.address]
rssi = adv.rssi
_LOGGER.debug("%s: Connecting (last rssi: %s)", description, rssi)
connected = None
try:
@ -271,11 +274,11 @@ class HaBleakClientWrapper(BleakClient):
# If we failed to connect and its a local adapter (no source)
# we release the connection slot
if not connected:
self.__connect_failures[wrapped_backend.scanner] = (
self.__connect_failures.get(wrapped_backend.scanner, 0) + 1
self.__connect_failures[scanner] = (
self.__connect_failures.get(scanner, 0) + 1
)
if not wrapped_backend.source:
manager.async_release_connection_slot(wrapped_backend.device)
manager.async_release_connection_slot(device)
if debug_logging:
_LOGGER.debug("%s: Connected (last rssi: %s)", description, rssi)

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