This commit is contained in:
Franck Nijhof
2023-06-07 15:39:47 +02:00
committed by GitHub
1551 changed files with 60230 additions and 15325 deletions

View File

@@ -20,6 +20,8 @@ base_platforms: &base_platforms
- homeassistant/components/camera/**
- homeassistant/components/climate/**
- homeassistant/components/cover/**
- homeassistant/components/date/**
- homeassistant/components/datetime/**
- homeassistant/components/device_tracker/**
- homeassistant/components/diagnostics/**
- homeassistant/components/fan/**
@@ -39,6 +41,7 @@ base_platforms: &base_platforms
- homeassistant/components/stt/**
- homeassistant/components/switch/**
- homeassistant/components/text/**
- homeassistant/components/time/**
- homeassistant/components/tts/**
- homeassistant/components/update/**
- homeassistant/components/vacuum/**
@@ -137,7 +140,6 @@ other: &other
requirements: &requirements
- .github/workflows/**
- homeassistant/package_constraints.txt
- script/pip_check
- requirements*.txt
- pyproject.toml

View File

@@ -227,6 +227,7 @@ omit =
homeassistant/components/dunehd/__init__.py
homeassistant/components/dunehd/media_player.py
homeassistant/components/dwd_weather_warnings/const.py
homeassistant/components/dwd_weather_warnings/coordinator.py
homeassistant/components/dwd_weather_warnings/sensor.py
homeassistant/components/dweet/*
homeassistant/components/ebox/sensor.py
@@ -327,9 +328,11 @@ omit =
homeassistant/components/ezviz/binary_sensor.py
homeassistant/components/ezviz/camera.py
homeassistant/components/ezviz/coordinator.py
homeassistant/components/ezviz/number.py
homeassistant/components/ezviz/entity.py
homeassistant/components/ezviz/sensor.py
homeassistant/components/ezviz/switch.py
homeassistant/components/ezviz/update.py
homeassistant/components/faa_delays/__init__.py
homeassistant/components/faa_delays/binary_sensor.py
homeassistant/components/familyhub/camera.py
@@ -417,7 +420,6 @@ omit =
homeassistant/components/gitlab_ci/sensor.py
homeassistant/components/gitter/sensor.py
homeassistant/components/glances/sensor.py
homeassistant/components/goalfeed/*
homeassistant/components/goodwe/__init__.py
homeassistant/components/goodwe/button.py
homeassistant/components/goodwe/coordinator.py
@@ -788,6 +790,7 @@ omit =
homeassistant/components/nibe_heatpump/select.py
homeassistant/components/nibe_heatpump/sensor.py
homeassistant/components/nibe_heatpump/switch.py
homeassistant/components/nibe_heatpump/water_heater.py
homeassistant/components/niko_home_control/light.py
homeassistant/components/nilu/air_quality.py
homeassistant/components/nissan_leaf/*
@@ -1292,10 +1295,10 @@ omit =
homeassistant/components/toon/switch.py
homeassistant/components/torque/sensor.py
homeassistant/components/totalconnect/__init__.py
homeassistant/components/totalconnect/binary_sensor.py
homeassistant/components/touchline/climate.py
homeassistant/components/tplink_lte/*
homeassistant/components/tplink_omada/__init__.py
homeassistant/components/tplink_omada/binary_sensor.py
homeassistant/components/tplink_omada/controller.py
homeassistant/components/tplink_omada/coordinator.py
homeassistant/components/tplink_omada/entity.py
@@ -1535,6 +1538,8 @@ omit =
homeassistant/components/zwave_me/sensor.py
homeassistant/components/zwave_me/siren.py
homeassistant/components/zwave_me/switch.py
homeassistant/components/electrasmart/climate.py
homeassistant/components/electrasmart/__init__.py
[report]
# Regexes for lines to exclude from consideration

View File

@@ -104,8 +104,8 @@ To help with the load of incoming pull requests:
Below, some useful links you could explore:
-->
[dev-checklist]: https://developers.home-assistant.io/docs/en/development_checklist.html
[manifest-docs]: https://developers.home-assistant.io/docs/en/creating_integration_manifest.html
[quality-scale]: https://developers.home-assistant.io/docs/en/next/integration_quality_scale_index.html
[dev-checklist]: https://developers.home-assistant.io/docs/development_checklist/
[manifest-docs]: https://developers.home-assistant.io/docs/creating_integration_manifest/
[quality-scale]: https://developers.home-assistant.io/docs/integration_quality_scale_index/
[docs-repository]: https://github.com/home-assistant/home-assistant.io
[perfect-pr]: https://developers.home-assistant.io/docs/review-process/#creating-the-perfect-pr

View File

@@ -29,7 +29,7 @@ jobs:
fetch-depth: 0
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@v4.6.0
uses: actions/setup-python@v4.6.1
with:
python-version: ${{ env.DEFAULT_PYTHON }}
@@ -62,7 +62,8 @@ jobs:
build_python:
name: Build PyPi package
needs: init
environment: ${{ needs.init.outputs.channel }}
needs: ["init", "build_base"]
runs-on: ubuntu-latest
if: github.repository_owner == 'home-assistant' && needs.init.outputs.publish == 'true'
steps:
@@ -70,7 +71,7 @@ jobs:
uses: actions/checkout@v3.5.2
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@v4.6.0
uses: actions/setup-python@v4.6.1
with:
python-version: ${{ env.DEFAULT_PYTHON }}
@@ -123,7 +124,7 @@ jobs:
uses: dawidd6/action-download-artifact@v2
with:
github_token: ${{secrets.GITHUB_TOKEN}}
repo: home-assistant/intents
repo: home-assistant/intents-package
branch: main
workflow: nightly.yaml
workflow_conclusion: success
@@ -131,7 +132,7 @@ jobs:
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
if: needs.init.outputs.channel == 'dev'
uses: actions/setup-python@v4.6.0
uses: actions/setup-python@v4.6.1
with:
python-version: ${{ env.DEFAULT_PYTHON }}
@@ -140,7 +141,7 @@ jobs:
shell: bash
run: |
python3 -m pip install packaging tomli
python3 -m pip install --use-deprecated=legacy-resolver .
python3 -m pip install .
version="$(python3 script/version_bump.py nightly)"
if [[ "$(ls home_assistant_frontend*.whl)" =~ ^home_assistant_frontend-(.*)-py3-none-any.whl$ ]]; then
@@ -174,6 +175,18 @@ jobs:
python -m script.gen_requirements_all
fi
- name: Adjustments for armhf
if: matrix.arch == 'armhf'
run: |
# 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" requirements_all.txt
sed -i "s|noaa-coops|# noaa-coops|g" requirements_all.txt
sed -i "s|pyezviz|# pyezviz|g" requirements_all.txt
sed -i "s|pykrakenapi|# pykrakenapi|g" requirements_all.txt
- name: Download Translations
run: python3 -m script.translations download
env:
@@ -287,6 +300,7 @@ jobs:
publish_ha:
name: Publish version files
environment: ${{ needs.init.outputs.channel }}
if: github.repository_owner == 'home-assistant'
needs: ["init", "build_machine"]
runs-on: ubuntu-latest
@@ -320,6 +334,7 @@ jobs:
publish_container:
name: Publish meta container for ${{ matrix.registry }}
environment: ${{ needs.init.outputs.channel }}
if: github.repository_owner == 'home-assistant'
needs: ["init", "build_base"]
runs-on: ubuntu-latest

View File

@@ -32,7 +32,7 @@ env:
CACHE_VERSION: 5
PIP_CACHE_VERSION: 4
MYPY_CACHE_VERSION: 4
HA_SHORT_VERSION: 2023.5
HA_SHORT_VERSION: 2023.6
DEFAULT_PYTHON: "3.10"
ALL_PYTHON_VERSIONS: "['3.10', '3.11']"
# 10.3 is the oldest supported version
@@ -209,7 +209,7 @@ jobs:
uses: actions/checkout@v3.5.2
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python
uses: actions/setup-python@v4.6.0
uses: actions/setup-python@v4.6.1
with:
python-version: ${{ env.DEFAULT_PYTHON }}
check-latest: true
@@ -253,7 +253,7 @@ jobs:
- name: Check out code from GitHub
uses: actions/checkout@v3.5.2
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@v4.6.0
uses: actions/setup-python@v4.6.1
id: python
with:
python-version: ${{ env.DEFAULT_PYTHON }}
@@ -299,7 +299,7 @@ jobs:
- name: Check out code from GitHub
uses: actions/checkout@v3.5.2
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@v4.6.0
uses: actions/setup-python@v4.6.1
id: python
with:
python-version: ${{ env.DEFAULT_PYTHON }}
@@ -348,7 +348,7 @@ jobs:
- name: Check out code from GitHub
uses: actions/checkout@v3.5.2
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@v4.6.0
uses: actions/setup-python@v4.6.1
id: python
with:
python-version: ${{ env.DEFAULT_PYTHON }}
@@ -386,7 +386,7 @@ jobs:
- name: Check out code from GitHub
uses: actions/checkout@v3.5.2
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@v4.6.0
uses: actions/setup-python@v4.6.1
id: python
with:
python-version: ${{ env.DEFAULT_PYTHON }}
@@ -494,7 +494,7 @@ jobs:
uses: actions/checkout@v3.5.2
- name: Set up Python ${{ matrix.python-version }}
id: python
uses: actions/setup-python@v4.6.0
uses: actions/setup-python@v4.6.1
with:
python-version: ${{ matrix.python-version }}
check-latest: true
@@ -544,8 +544,8 @@ jobs:
. venv/bin/activate
python --version
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 --cache-dir=$PIP_CACHE -r requirements_all.txt
pip install --cache-dir=$PIP_CACHE -r requirements_test.txt
pip install -e .
hassfest:
@@ -562,7 +562,7 @@ jobs:
uses: actions/checkout@v3.5.2
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python
uses: actions/setup-python@v4.6.0
uses: actions/setup-python@v4.6.1
with:
python-version: ${{ env.DEFAULT_PYTHON }}
check-latest: true
@@ -594,7 +594,7 @@ jobs:
uses: actions/checkout@v3.5.2
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python
uses: actions/setup-python@v4.6.0
uses: actions/setup-python@v4.6.1
with:
python-version: ${{ env.DEFAULT_PYTHON }}
check-latest: true
@@ -627,7 +627,7 @@ jobs:
uses: actions/checkout@v3.5.2
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python
uses: actions/setup-python@v4.6.0
uses: actions/setup-python@v4.6.1
with:
python-version: ${{ env.DEFAULT_PYTHON }}
check-latest: true
@@ -671,7 +671,7 @@ jobs:
uses: actions/checkout@v3.5.2
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python
uses: actions/setup-python@v4.6.0
uses: actions/setup-python@v4.6.1
with:
python-version: ${{ env.DEFAULT_PYTHON }}
check-latest: true
@@ -719,42 +719,6 @@ jobs:
python --version
mypy homeassistant/components/${{ needs.info.outputs.integrations_glob }}
pip-check:
runs-on: ubuntu-22.04
if: |
github.event.inputs.pylint-only != 'true'
&& github.event.inputs.mypy-only != 'true'
needs:
- info
- base
strategy:
fail-fast: false
matrix:
python-version: ${{ fromJson(needs.info.outputs.python_versions) }}
name: Run pip check ${{ matrix.python-version }}
steps:
- name: Check out code from GitHub
uses: actions/checkout@v3.5.2
- name: Set up Python ${{ matrix.python-version }}
id: python
uses: actions/setup-python@v4.6.0
with:
python-version: ${{ matrix.python-version }}
check-latest: true
- name: Restore full Python ${{ matrix.python-version }} virtual environment
id: cache-venv
uses: actions/cache/restore@v3.3.1
with:
path: venv
fail-on-cache-miss: true
key: >-
${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{
needs.info.outputs.python_cache_key }}
- name: Run pip check
run: |
. venv/bin/activate
./script/pip_check $PIP_CACHE
pytest:
runs-on: ubuntu-22.04
if: |
@@ -790,7 +754,7 @@ jobs:
uses: actions/checkout@v3.5.2
- name: Set up Python ${{ matrix.python-version }}
id: python
uses: actions/setup-python@v4.6.0
uses: actions/setup-python@v4.6.1
with:
python-version: ${{ matrix.python-version }}
check-latest: true
@@ -916,7 +880,7 @@ jobs:
uses: actions/checkout@v3.5.2
- name: Set up Python ${{ matrix.python-version }}
id: python
uses: actions/setup-python@v4.6.0
uses: actions/setup-python@v4.6.1
with:
python-version: ${{ matrix.python-version }}
check-latest: true
@@ -1024,7 +988,7 @@ jobs:
uses: actions/checkout@v3.5.2
- name: Set up Python ${{ matrix.python-version }}
id: python
uses: actions/setup-python@v4.6.0
uses: actions/setup-python@v4.6.1
with:
python-version: ${{ matrix.python-version }}
check-latest: true

View File

@@ -22,7 +22,7 @@ jobs:
uses: actions/checkout@v3.5.2
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@v4.6.0
uses: actions/setup-python@v4.6.1
with:
python-version: ${{ env.DEFAULT_PYTHON }}

View File

@@ -141,7 +141,6 @@ 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}
@@ -187,7 +186,6 @@ jobs:
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"
@@ -202,7 +200,6 @@ jobs:
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.txtab"
@@ -266,12 +263,6 @@ jobs:
# 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
@@ -334,7 +325,6 @@ jobs:
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"
@@ -349,7 +339,6 @@ jobs:
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.txtab"

View File

@@ -49,6 +49,7 @@ homeassistant.components.air_quality.*
homeassistant.components.airly.*
homeassistant.components.airvisual.*
homeassistant.components.airzone.*
homeassistant.components.airzone_cloud.*
homeassistant.components.aladdin_connect.*
homeassistant.components.alarm_control_panel.*
homeassistant.components.alert.*
@@ -86,6 +87,7 @@ homeassistant.components.camera.*
homeassistant.components.canary.*
homeassistant.components.clickatell.*
homeassistant.components.clicksend.*
homeassistant.components.cloud.*
homeassistant.components.configurator.*
homeassistant.components.cover.*
homeassistant.components.cpuspeed.*
@@ -105,6 +107,7 @@ homeassistant.components.dormakaba_dkey.*
homeassistant.components.dsmr.*
homeassistant.components.dunehd.*
homeassistant.components.efergy.*
homeassistant.components.electrasmart.*
homeassistant.components.elgato.*
homeassistant.components.elkm1.*
homeassistant.components.emulated_hue.*
@@ -166,10 +169,12 @@ homeassistant.components.homekit_controller.utils
homeassistant.components.homewizard.*
homeassistant.components.http.*
homeassistant.components.huawei_lte.*
homeassistant.components.hydrawise.*
homeassistant.components.hyperion.*
homeassistant.components.ibeacon.*
homeassistant.components.image_processing.*
homeassistant.components.image_upload.*
homeassistant.components.imap.*
homeassistant.components.input_button.*
homeassistant.components.input_select.*
homeassistant.components.integration.*
@@ -177,6 +182,7 @@ homeassistant.components.iqvia.*
homeassistant.components.isy994.*
homeassistant.components.jellyfin.*
homeassistant.components.jewish_calendar.*
homeassistant.components.jvc_projector.*
homeassistant.components.kaleidescape.*
homeassistant.components.knx.*
homeassistant.components.kraken.*
@@ -230,6 +236,7 @@ homeassistant.components.oncue.*
homeassistant.components.onewire.*
homeassistant.components.open_meteo.*
homeassistant.components.openexchangerates.*
homeassistant.components.opensky.*
homeassistant.components.openuv.*
homeassistant.components.otbr.*
homeassistant.components.overkiz.*
@@ -284,6 +291,7 @@ homeassistant.components.smhi.*
homeassistant.components.snooz.*
homeassistant.components.sonarr.*
homeassistant.components.speedtestdotnet.*
homeassistant.components.sql.*
homeassistant.components.ssdp.*
homeassistant.components.statistics.*
homeassistant.components.steamist.*

41
.vscode/tasks.json vendored
View File

@@ -103,7 +103,7 @@
{
"label": "Install all Requirements",
"type": "shell",
"command": "pip3 install --use-deprecated=legacy-resolver -r requirements_all.txt",
"command": "pip3 install -r requirements_all.txt",
"group": {
"kind": "build",
"isDefault": true
@@ -117,7 +117,7 @@
{
"label": "Install all Test Requirements",
"type": "shell",
"command": "pip3 install --use-deprecated=legacy-resolver -r requirements_test_all.txt",
"command": "pip3 install -r requirements_test_all.txt",
"group": {
"kind": "build",
"isDefault": true
@@ -137,6 +137,26 @@
"kind": "build",
"isDefault": true
}
},
{
"label": "Run scaffold",
"detail": "Add new functionality to a integration using a scaffold.",
"type": "shell",
"command": "python3 -m script.scaffold ${input:scaffoldName} --integration ${input:integrationName}",
"group": {
"kind": "build",
"isDefault": true
}
},
{
"label": "Create new integration",
"detail": "Use the scaffold to create a new integration.",
"type": "shell",
"command": "python3 -m script.scaffold integration",
"group": {
"kind": "build",
"isDefault": true
}
}
],
"inputs": [
@@ -144,6 +164,23 @@
"id": "integrationName",
"type": "promptString",
"description": "For which integration should the task run?"
},
{
"id": "scaffoldName",
"type": "pickString",
"options": [
"backup",
"config_flow",
"config_flow_discovery",
"config_flow_helper",
"config_flow_oauth2",
"device_action",
"device_condition",
"device_trigger",
"reproduce_state",
"significant_change"
],
"description": "Which scaffold should be run?"
}
]
}

View File

@@ -59,6 +59,8 @@ build.json @home-assistant/supervisor
/tests/components/airvisual_pro/ @bachya
/homeassistant/components/airzone/ @Noltari
/tests/components/airzone/ @Noltari
/homeassistant/components/airzone_cloud/ @Noltari
/tests/components/airzone_cloud/ @Noltari
/homeassistant/components/aladdin_connect/ @mkmer
/tests/components/aladdin_connect/ @mkmer
/homeassistant/components/alarm_control_panel/ @home-assistant/core
@@ -80,8 +82,8 @@ 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/androidtv_remote/ @tronikos @Drafteed
/tests/components/androidtv_remote/ @tronikos @Drafteed
/homeassistant/components/anova/ @Lash-L
/tests/components/anova/ @Lash-L
/homeassistant/components/anthemav/ @hyralex
@@ -211,6 +213,8 @@ build.json @home-assistant/supervisor
/tests/components/color_extractor/ @GenericStudent
/homeassistant/components/comfoconnect/ @michaelarnauts
/tests/components/comfoconnect/ @michaelarnauts
/homeassistant/components/command_line/ @gjohansson-ST
/tests/components/command_line/ @gjohansson-ST
/homeassistant/components/compensation/ @Petro31
/tests/components/compensation/ @Petro31
/homeassistant/components/config/ @home-assistant/core
@@ -234,6 +238,10 @@ build.json @home-assistant/supervisor
/homeassistant/components/cups/ @fabaff
/homeassistant/components/daikin/ @fredrike
/tests/components/daikin/ @fredrike
/homeassistant/components/date/ @home-assistant/core
/tests/components/date/ @home-assistant/core
/homeassistant/components/datetime/ @home-assistant/core
/tests/components/datetime/ @home-assistant/core
/homeassistant/components/debugpy/ @frenck
/tests/components/debugpy/ @frenck
/homeassistant/components/deconz/ @Kane610
@@ -288,6 +296,7 @@ build.json @home-assistant/supervisor
/homeassistant/components/dunehd/ @bieniu
/tests/components/dunehd/ @bieniu
/homeassistant/components/dwd_weather_warnings/ @runningman84 @stephan192 @Hummel95 @andarotajo
/tests/components/dwd_weather_warnings/ @runningman84 @stephan192 @Hummel95 @andarotajo
/homeassistant/components/dynalite/ @ziv1234
/tests/components/dynalite/ @ziv1234
/homeassistant/components/eafm/ @Jc2k
@@ -306,6 +315,8 @@ build.json @home-assistant/supervisor
/homeassistant/components/egardia/ @jeroenterheerdt
/homeassistant/components/eight_sleep/ @mezz64 @raman325
/tests/components/eight_sleep/ @mezz64 @raman325
/homeassistant/components/electrasmart/ @jafar-atili
/tests/components/electrasmart/ @jafar-atili
/homeassistant/components/elgato/ @frenck
/tests/components/elgato/ @frenck
/homeassistant/components/elkm1/ @gwww @bdraco
@@ -448,6 +459,8 @@ build.json @home-assistant/supervisor
/homeassistant/components/google_assistant_sdk/ @tronikos
/tests/components/google_assistant_sdk/ @tronikos
/homeassistant/components/google_cloud/ @lufton
/homeassistant/components/google_generative_ai_conversation/ @tronikos
/tests/components/google_generative_ai_conversation/ @tronikos
/homeassistant/components/google_mail/ @tkdrob
/tests/components/google_mail/ @tkdrob
/homeassistant/components/google_sheets/ @tkdrob
@@ -532,7 +545,7 @@ build.json @home-assistant/supervisor
/tests/components/hunterdouglas_powerview/ @bdraco @kingy444 @trullock
/homeassistant/components/hvv_departures/ @vigonotion
/tests/components/hvv_departures/ @vigonotion
/homeassistant/components/hydrawise/ @ptcryan
/homeassistant/components/hydrawise/ @dknowles2 @ptcryan
/homeassistant/components/hyperion/ @dermotduffy
/tests/components/hyperion/ @dermotduffy
/homeassistant/components/ialarm/ @RyuzakiKK
@@ -606,6 +619,8 @@ build.json @home-assistant/supervisor
/tests/components/juicenet/ @jesserockz
/homeassistant/components/justnimbus/ @kvanzuijlen
/tests/components/justnimbus/ @kvanzuijlen
/homeassistant/components/jvc_projector/ @SteveEasley
/tests/components/jvc_projector/ @SteveEasley
/homeassistant/components/kaiterra/ @Michsior14
/homeassistant/components/kaleidescape/ @SteveEasley
/tests/components/kaleidescape/ @SteveEasley
@@ -640,6 +655,8 @@ build.json @home-assistant/supervisor
/tests/components/lametric/ @robbiet480 @frenck @bachya
/homeassistant/components/landisgyr_heat_meter/ @vpathuis
/tests/components/landisgyr_heat_meter/ @vpathuis
/homeassistant/components/lastfm/ @joostlek
/tests/components/lastfm/ @joostlek
/homeassistant/components/launch_library/ @ludeeus @DurgNomis-drol
/tests/components/launch_library/ @ludeeus @DurgNomis-drol
/homeassistant/components/laundrify/ @xLarry
@@ -861,6 +878,7 @@ build.json @home-assistant/supervisor
/homeassistant/components/opengarage/ @danielhiversen
/tests/components/opengarage/ @danielhiversen
/homeassistant/components/openhome/ @bazwilliams
/homeassistant/components/opensky/ @joostlek
/homeassistant/components/opentherm_gw/ @mvn23
/tests/components/opentherm_gw/ @mvn23
/homeassistant/components/openuv/ @bachya
@@ -955,8 +973,8 @@ build.json @home-assistant/supervisor
/tests/components/radarr/ @tkdrob
/homeassistant/components/radio_browser/ @frenck
/tests/components/radio_browser/ @frenck
/homeassistant/components/radiotherm/ @bdraco @vinnyfuria
/tests/components/radiotherm/ @bdraco @vinnyfuria
/homeassistant/components/radiotherm/ @vinnyfuria
/tests/components/radiotherm/ @vinnyfuria
/homeassistant/components/rainbird/ @konikvranik @allenporter
/tests/components/rainbird/ @konikvranik @allenporter
/homeassistant/components/raincloud/ @vanstinator
@@ -998,8 +1016,8 @@ build.json @home-assistant/supervisor
/tests/components/ridwell/ @bachya
/homeassistant/components/risco/ @OnFreund
/tests/components/risco/ @OnFreund
/homeassistant/components/rituals_perfume_genie/ @milanmeu
/tests/components/rituals_perfume_genie/ @milanmeu
/homeassistant/components/rituals_perfume_genie/ @milanmeu @frenck
/tests/components/rituals_perfume_genie/ @milanmeu @frenck
/homeassistant/components/rmvtransport/ @cgtobi
/tests/components/rmvtransport/ @cgtobi
/homeassistant/components/roborock/ @humbertogontijo @Lash-L
@@ -1094,8 +1112,8 @@ build.json @home-assistant/supervisor
/homeassistant/components/sky_hub/ @rogerselwyn
/homeassistant/components/skybell/ @tkdrob
/tests/components/skybell/ @tkdrob
/homeassistant/components/slack/ @bachya @tkdrob
/tests/components/slack/ @bachya @tkdrob
/homeassistant/components/slack/ @tkdrob
/tests/components/slack/ @tkdrob
/homeassistant/components/sleepiq/ @mfugate1 @kbickar
/tests/components/sleepiq/ @mfugate1 @kbickar
/homeassistant/components/slide/ @ualex73
@@ -1145,8 +1163,8 @@ build.json @home-assistant/supervisor
/homeassistant/components/splunk/ @Bre77
/homeassistant/components/spotify/ @frenck
/tests/components/spotify/ @frenck
/homeassistant/components/sql/ @dgomes @gjohansson-ST @dougiteixeira
/tests/components/sql/ @dgomes @gjohansson-ST @dougiteixeira
/homeassistant/components/sql/ @gjohansson-ST @dougiteixeira
/tests/components/sql/ @gjohansson-ST @dougiteixeira
/homeassistant/components/squeezebox/ @rajlaud
/tests/components/squeezebox/ @rajlaud
/homeassistant/components/srp_energy/ @briglx
@@ -1188,8 +1206,8 @@ build.json @home-assistant/supervisor
/tests/components/switchbee/ @jafar-atili
/homeassistant/components/switchbot/ @bdraco @danielhiversen @RenierM26 @murtas @Eloston @dsypniewski
/tests/components/switchbot/ @bdraco @danielhiversen @RenierM26 @murtas @Eloston @dsypniewski
/homeassistant/components/switcher_kis/ @tomerfi @thecode
/tests/components/switcher_kis/ @tomerfi @thecode
/homeassistant/components/switcher_kis/ @thecode
/tests/components/switcher_kis/ @thecode
/homeassistant/components/switchmate/ @danielhiversen @qiz-li
/homeassistant/components/syncthing/ @zhulik
/tests/components/syncthing/ @zhulik
@@ -1200,8 +1218,8 @@ build.json @home-assistant/supervisor
/homeassistant/components/synology_srm/ @aerialls
/homeassistant/components/system_bridge/ @timmo001
/tests/components/system_bridge/ @timmo001
/homeassistant/components/tado/ @michaelarnauts
/tests/components/tado/ @michaelarnauts
/homeassistant/components/tado/ @michaelarnauts @chiefdragon
/tests/components/tado/ @michaelarnauts @chiefdragon
/homeassistant/components/tag/ @balloob @dmulcahey
/tests/components/tag/ @balloob @dmulcahey
/homeassistant/components/tailscale/ @frenck
@@ -1235,6 +1253,8 @@ build.json @home-assistant/supervisor
/tests/components/tile/ @bachya
/homeassistant/components/tilt_ble/ @apt-itude
/tests/components/tilt_ble/ @apt-itude
/homeassistant/components/time/ @home-assistant/core
/tests/components/time/ @home-assistant/core
/homeassistant/components/time_date/ @fabaff
/tests/components/time_date/ @fabaff
/homeassistant/components/tmb/ @alemuro
@@ -1402,6 +1422,8 @@ build.json @home-assistant/supervisor
/tests/components/yolink/ @matrixd2
/homeassistant/components/youless/ @gjong
/tests/components/youless/ @gjong
/homeassistant/components/youtube/ @joostlek
/tests/components/youtube/ @joostlek
/homeassistant/components/zamg/ @killer0071234
/tests/components/zamg/ @killer0071234
/homeassistant/components/zengge/ @emontnemery

View File

@@ -18,7 +18,6 @@ RUN \
--no-index \
--only-binary=:all: \
--find-links "${WHEELS_LINKS}" \
--use-deprecated=legacy-resolver \
-r homeassistant/requirements.txt
COPY requirements_all.txt home_assistant_frontend-* home_assistant_intents-* homeassistant/
@@ -43,7 +42,6 @@ RUN \
--no-index \
--only-binary=:all: \
--find-links "${WHEELS_LINKS}" \
--use-deprecated=legacy-resolver \
-r homeassistant/requirements_all.txt
## Setup Home Assistant Core
@@ -54,7 +52,6 @@ RUN \
--no-index \
--only-binary=:all: \
--find-links "${WHEELS_LINKS}" \
--use-deprecated=legacy-resolver \
-e ./homeassistant \
&& python3 -m compileall \
homeassistant/homeassistant

View File

@@ -45,9 +45,9 @@ WORKDIR /workspaces
# Install Python dependencies from requirements
COPY requirements.txt ./
COPY homeassistant/package_constraints.txt homeassistant/package_constraints.txt
RUN pip3 install -r requirements.txt --use-deprecated=legacy-resolver
RUN pip3 install -r requirements.txt
COPY requirements_test.txt requirements_test_pre_commit.txt ./
RUN pip3 install -r requirements_test.txt --use-deprecated=legacy-resolver
RUN pip3 install -r requirements_test.txt
RUN rm -rf requirements.txt requirements_test.txt requirements_test_pre_commit.txt homeassistant/
# Set the default shell to bash instead of sh

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.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
aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2023.05.0
armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2023.05.0
armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2023.05.0
amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2023.05.0
i386: ghcr.io/home-assistant/i386-homeassistant-base:2023.05.0
codenotary:
signer: notary@home-assistant.io
base_image: notary@home-assistant.io

View File

@@ -93,7 +93,7 @@ class _PyJWTWithVerify(PyJWT):
# nothing slips through.
assert "exp" in payload, "exp claim is required"
assert "iat" in payload, "iat claim is required"
self._validate_claims( # type: ignore[no-untyped-call]
self._validate_claims(
payload=payload,
options=merged_options,
issuer=issuer,
@@ -102,7 +102,7 @@ class _PyJWTWithVerify(PyJWT):
return payload
_jwt = _PyJWTWithVerify() # type: ignore[no-untyped-call]
_jwt = _PyJWTWithVerify()
verify_and_decode = _jwt.verify_and_decode
unverified_hs256_token_decode = lru_cache(maxsize=JWT_TOKEN_CACHE_SIZE)(
partial(

View File

@@ -46,7 +46,7 @@ CONFIG_SCHEMA = AUTH_PROVIDER_SCHEMA.extend(
[
vol.Or(
cv.uuid4_hex,
vol.Schema({vol.Required(CONF_GROUP): cv.uuid4_hex}),
vol.Schema({vol.Required(CONF_GROUP): str}),
)
],
)

View File

@@ -19,6 +19,7 @@ import yarl
from . import config as conf_util, config_entries, core, loader
from .components import http
from .const import (
FORMAT_DATETIME,
REQUIRED_NEXT_PYTHON_HA_RELEASE,
REQUIRED_NEXT_PYTHON_VER,
SIGNAL_BOOTSTRAP_INTEGRATIONS,
@@ -31,6 +32,7 @@ from .helpers import (
entity_registry,
issue_registry,
recorder,
restore_state,
template,
)
from .helpers.dispatcher import async_dispatcher_send
@@ -247,6 +249,7 @@ async def load_registries(hass: core.HomeAssistant) -> None:
issue_registry.async_load(hass),
hass.async_add_executor_job(_cache_uname_processor),
template.async_load_custom_templates(hass),
restore_state.async_load(hass),
)
@@ -347,7 +350,6 @@ def async_enable_logging(
fmt = (
"%(asctime)s.%(msecs)03d %(levelname)s (%(threadName)s) [%(name)s] %(message)s"
)
datefmt = "%Y-%m-%d %H:%M:%S"
if not log_no_color:
try:
@@ -362,7 +364,7 @@ def async_enable_logging(
logging.getLogger().handlers[0].setFormatter(
ColoredFormatter(
colorfmt,
datefmt=datefmt,
datefmt=FORMAT_DATETIME,
reset=True,
log_colors={
"DEBUG": "cyan",
@@ -378,7 +380,12 @@ def async_enable_logging(
# If the above initialization failed for any reason, setup the default
# formatting. If the above succeeds, this will result in a no-op.
logging.basicConfig(format=fmt, datefmt=datefmt, level=logging.INFO)
logging.basicConfig(format=fmt, datefmt=FORMAT_DATETIME, level=logging.INFO)
# Capture warnings.warn(...) and friends messages in logs.
# The standard destination for them is stderr, which may end up unnoticed.
# This way they're where other messages are, and can be filtered as usual.
logging.captureWarnings(True)
# Suppress overly verbose logs from libraries that aren't helpful
logging.getLogger("requests").setLevel(logging.WARNING)
@@ -430,7 +437,7 @@ def async_enable_logging(
_LOGGER.error("Error rolling over log file: %s", err)
err_handler.setLevel(logging.INFO if verbose else logging.WARNING)
err_handler.setFormatter(logging.Formatter(fmt, datefmt=datefmt))
err_handler.setFormatter(logging.Formatter(fmt, datefmt=FORMAT_DATETIME))
logger = logging.getLogger("")
logger.addHandler(err_handler)

View File

@@ -0,0 +1,5 @@
{
"domain": "airzone",
"name": "Airzone",
"integrations": ["airzone", "airzone_cloud"]
}

View File

@@ -6,6 +6,7 @@
"google_assistant_sdk",
"google_cloud",
"google_domains",
"google_generative_ai_conversation",
"google_mail",
"google_maps",
"google_pubsub",
@@ -16,6 +17,7 @@
"google",
"nest",
"cast",
"dialogflow"
"dialogflow",
"youtube"
]
}

View File

@@ -1,5 +1,5 @@
{
"domain": "yale",
"name": "Yale",
"integrations": ["august", "yale_smart_alarm", "yalexs_ble"]
"integrations": ["august", "yale_smart_alarm", "yalexs_ble", "yale_home"]
}

View File

@@ -121,12 +121,12 @@ class AccuWeatherDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
async def _async_update_data(self) -> dict[str, Any]:
"""Update data via library."""
forecast: list[dict[str, Any]] = []
try:
async with timeout(10):
current = await self.accuweather.async_get_current_conditions()
forecast = (
await self.accuweather.async_get_forecast() if self.forecast else {}
)
if self.forecast:
forecast = await self.accuweather.async_get_daily_forecast()
except (
ApiError,
ClientConnectorError,

View File

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

View File

@@ -16,6 +16,7 @@ from homeassistant.const import (
CONCENTRATION_PARTS_PER_CUBIC_METER,
PERCENTAGE,
UV_INDEX,
UnitOfIrradiance,
UnitOfLength,
UnitOfSpeed,
UnitOfTemperature,
@@ -104,6 +105,16 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = (
native_unit_of_measurement=UnitOfTime.HOURS,
value_fn=lambda data: cast(float, data),
),
AccuWeatherSensorDescription(
key="LongPhraseDay",
name="Condition day",
value_fn=lambda data: cast(str, data),
),
AccuWeatherSensorDescription(
key="LongPhraseNight",
name="Condition night",
value_fn=lambda data: cast(str, data),
),
AccuWeatherSensorDescription(
key="Mold",
icon="mdi:blur",
@@ -154,6 +165,22 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = (
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
value_fn=lambda data: cast(float, data[ATTR_VALUE]),
),
AccuWeatherSensorDescription(
key="SolarIrradianceDay",
icon="mdi:weather-sunny",
name="Solar irradiance day",
entity_registry_enabled_default=False,
native_unit_of_measurement=UnitOfIrradiance.WATTS_PER_SQUARE_METER,
value_fn=lambda data: cast(float, data[ATTR_VALUE]),
),
AccuWeatherSensorDescription(
key="SolarIrradianceNight",
icon="mdi:weather-sunny",
name="Solar irradiance night",
entity_registry_enabled_default=False,
native_unit_of_measurement=UnitOfIrradiance.WATTS_PER_SQUARE_METER,
value_fn=lambda data: cast(float, data[ATTR_VALUE]),
),
AccuWeatherSensorDescription(
key="ThunderstormProbabilityDay",
icon="mdi:weather-lightning",

View File

@@ -1,8 +1,7 @@
"""Support for the AccuWeather service."""
from __future__ import annotations
from statistics import mean
from typing import Any, cast
from typing import cast
from homeassistant.components.weather import (
ATTR_FORECAST_CONDITION,
@@ -120,15 +119,10 @@ class AccuWeatherEntity(
ATTR_FORECAST_TIME: utc_from_timestamp(item["EpochDate"]).isoformat(),
ATTR_FORECAST_NATIVE_TEMP: item["TemperatureMax"]["Value"],
ATTR_FORECAST_NATIVE_TEMP_LOW: item["TemperatureMin"]["Value"],
ATTR_FORECAST_NATIVE_PRECIPITATION: self._calc_precipitation(item),
ATTR_FORECAST_PRECIPITATION_PROBABILITY: round(
mean(
[
item["PrecipitationProbabilityDay"],
item["PrecipitationProbabilityNight"],
]
)
),
ATTR_FORECAST_NATIVE_PRECIPITATION: item["TotalLiquidDay"]["Value"],
ATTR_FORECAST_PRECIPITATION_PROBABILITY: item[
"PrecipitationProbabilityDay"
],
ATTR_FORECAST_NATIVE_WIND_SPEED: item["WindDay"]["Speed"]["Value"],
ATTR_FORECAST_WIND_BEARING: item["WindDay"]["Direction"]["Degrees"],
ATTR_FORECAST_CONDITION: [
@@ -137,18 +131,3 @@ class AccuWeatherEntity(
}
for item in self.coordinator.data[ATTR_FORECAST]
]
@staticmethod
def _calc_precipitation(day: dict[str, Any]) -> float:
"""Return sum of the precipitation."""
precip_sum = 0
precip_types = ["Rain", "Snow", "Ice"]
for precip in precip_types:
precip_sum = sum(
[
precip_sum,
day[f"{precip}Day"]["Value"],
day[f"{precip}Night"]["Value"],
]
)
return round(precip_sum, 1)

View File

@@ -26,6 +26,7 @@ from aemet_opendata.const import (
AEMET_ATTR_STATION_DATE,
AEMET_ATTR_STATION_HUMIDITY,
AEMET_ATTR_STATION_LOCATION,
AEMET_ATTR_STATION_PRESSURE,
AEMET_ATTR_STATION_PRESSURE_SEA,
AEMET_ATTR_STATION_TEMPERATURE,
AEMET_ATTR_STORM_PROBABILITY,
@@ -318,6 +319,8 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator):
pressure = format_float(
station_data[AEMET_ATTR_STATION_PRESSURE_SEA]
)
elif AEMET_ATTR_STATION_PRESSURE in station_data:
pressure = format_float(station_data[AEMET_ATTR_STATION_PRESSURE])
if AEMET_ATTR_STATION_TEMPERATURE in station_data:
temperature = format_float(
station_data[AEMET_ATTR_STATION_TEMPERATURE]

View File

@@ -150,10 +150,14 @@ class AirthingsHeaterEnergySensor(CoordinatorEntity, SensorEntity):
self._attr_unique_id = f"{airthings_device.device_id}_{entity_description.key}"
self._id = airthings_device.device_id
self._attr_device_info = DeviceInfo(
configuration_url="https://dashboard.airthings.com/",
configuration_url=(
"https://dashboard.airthings.com/devices/"
f"{airthings_device.device_id}"
),
identifiers={(DOMAIN, airthings_device.device_id)},
name=airthings_device.name,
manufacturer="Airthings",
model=airthings_device.device_type.replace("_", " ").lower().title(),
)
@property

View File

@@ -65,24 +65,28 @@ SENSORS_MAPPING_TEMPLATE: dict[str, SensorEntityDescription] = {
key="temperature",
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
name="Temperature",
),
"humidity": SensorEntityDescription(
key="humidity",
device_class=SensorDeviceClass.HUMIDITY,
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
name="Humidity",
),
"pressure": SensorEntityDescription(
key="pressure",
device_class=SensorDeviceClass.PRESSURE,
native_unit_of_measurement=UnitOfPressure.MBAR,
state_class=SensorStateClass.MEASUREMENT,
name="Pressure",
),
"battery": SensorEntityDescription(
key="battery",
device_class=SensorDeviceClass.BATTERY,
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
name="Battery",
),
@@ -90,12 +94,13 @@ SENSORS_MAPPING_TEMPLATE: dict[str, SensorEntityDescription] = {
key="co2",
device_class=SensorDeviceClass.CO2,
native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION,
state_class=SensorStateClass.MEASUREMENT,
name="co2",
),
"voc": SensorEntityDescription(
key="voc",
device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS,
native_unit_of_measurement=CONCENTRATION_PARTS_PER_BILLION,
state_class=SensorStateClass.MEASUREMENT,
name="VOC",
icon="mdi:cloud",
),
@@ -103,6 +108,7 @@ SENSORS_MAPPING_TEMPLATE: dict[str, SensorEntityDescription] = {
key="illuminance",
device_class=SensorDeviceClass.ILLUMINANCE,
native_unit_of_measurement=LIGHT_LUX,
state_class=SensorStateClass.MEASUREMENT,
name="Illuminance",
),
}
@@ -150,7 +156,6 @@ class AirthingsSensor(
):
"""Airthings BLE sensors for the device."""
_attr_state_class = SensorStateClass.MEASUREMENT
_attr_has_entity_name = True
def __init__(

View File

@@ -5,16 +5,24 @@ from typing import Any, Final
from aioairzone.common import OperationAction, OperationMode
from aioairzone.const import (
API_COOL_SET_POINT,
API_HEAT_SET_POINT,
API_MODE,
API_ON,
API_SET_POINT,
API_SPEED,
AZD_ACTION,
AZD_COOL_TEMP_SET,
AZD_DOUBLE_SET_POINT,
AZD_HEAT_TEMP_SET,
AZD_HUMIDITY,
AZD_MASTER,
AZD_MODE,
AZD_MODES,
AZD_NAME,
AZD_ON,
AZD_SPEED,
AZD_SPEEDS,
AZD_TEMP,
AZD_TEMP_MAX,
AZD_TEMP_MIN,
@@ -24,6 +32,12 @@ from aioairzone.const import (
)
from homeassistant.components.climate import (
ATTR_TARGET_TEMP_HIGH,
ATTR_TARGET_TEMP_LOW,
FAN_AUTO,
FAN_HIGH,
FAN_LOW,
FAN_MEDIUM,
ClimateEntity,
ClimateEntityFeature,
HVACAction,
@@ -39,6 +53,22 @@ from .const import API_TEMPERATURE_STEP, DOMAIN, TEMP_UNIT_LIB_TO_HASS
from .coordinator import AirzoneUpdateCoordinator
from .entity import AirzoneZoneEntity
BASE_FAN_SPEEDS: Final[dict[int, str]] = {
0: FAN_AUTO,
1: FAN_LOW,
}
FAN_SPEED_MAPS: Final[dict[int, dict[int, str]]] = {
2: BASE_FAN_SPEEDS
| {
2: FAN_HIGH,
},
3: BASE_FAN_SPEEDS
| {
2: FAN_MEDIUM,
3: FAN_HIGH,
},
}
HVAC_ACTION_LIB_TO_HASS: Final[dict[OperationAction, HVACAction]] = {
OperationAction.COOLING: HVACAction.COOLING,
OperationAction.DRYING: HVACAction.DRYING,
@@ -84,6 +114,9 @@ async def async_setup_entry(
class AirzoneClimate(AirzoneZoneEntity, ClimateEntity):
"""Define an Airzone sensor."""
_speeds: dict[int, str] = {}
_speeds_reverse: dict[str, int] = {}
def __init__(
self,
coordinator: AirzoneUpdateCoordinator,
@@ -98,16 +131,45 @@ class AirzoneClimate(AirzoneZoneEntity, ClimateEntity):
self._attr_unique_id = f"{self._attr_unique_id}_{system_zone_id}"
self._attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE
self._attr_target_temperature_step = API_TEMPERATURE_STEP
self._attr_max_temp = self.get_airzone_value(AZD_TEMP_MAX)
self._attr_min_temp = self.get_airzone_value(AZD_TEMP_MIN)
self._attr_temperature_unit = TEMP_UNIT_LIB_TO_HASS[
self.get_airzone_value(AZD_TEMP_UNIT)
]
self._attr_hvac_modes = [
HVAC_MODE_LIB_TO_HASS[mode] for mode in self.get_airzone_value(AZD_MODES)
]
if (
self.get_airzone_value(AZD_SPEED) is not None
and self.get_airzone_value(AZD_SPEEDS) is not None
):
self._set_fan_speeds()
if self.get_airzone_value(AZD_DOUBLE_SET_POINT):
self._attr_supported_features |= (
ClimateEntityFeature.TARGET_TEMPERATURE_RANGE
)
self._async_update_attrs()
def _set_fan_speeds(self) -> None:
self._attr_supported_features |= ClimateEntityFeature.FAN_MODE
speeds = self.get_airzone_value(AZD_SPEEDS)
max_speed = max(speeds)
if _speeds := FAN_SPEED_MAPS.get(max_speed):
self._speeds = _speeds
else:
for speed in speeds:
if speed == 0:
self._speeds[speed] = FAN_AUTO
else:
self._speeds[speed] = f"{int(round((speed * 100) / max_speed, 0))}%"
self._speeds[1] = FAN_LOW
self._speeds[int(round((max_speed + 1) / 2, 0))] = FAN_MEDIUM
self._speeds[max_speed] = FAN_HIGH
self._speeds_reverse = {v: k for k, v in self._speeds.items()}
self._attr_fan_modes = list(self._speeds_reverse)
async def async_turn_on(self) -> None:
"""Turn the entity on."""
params = {
@@ -122,6 +184,13 @@ class AirzoneClimate(AirzoneZoneEntity, ClimateEntity):
}
await self._async_update_hvac_params(params)
async def async_set_fan_mode(self, fan_mode: str) -> None:
"""Set fan mode."""
params = {
API_SPEED: self._speeds_reverse.get(fan_mode),
}
await self._async_update_hvac_params(params)
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set hvac mode."""
params = {}
@@ -141,9 +210,12 @@ class AirzoneClimate(AirzoneZoneEntity, ClimateEntity):
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperature."""
params = {
API_SET_POINT: kwargs.get(ATTR_TEMPERATURE),
}
params = {}
if ATTR_TEMPERATURE in kwargs:
params[API_SET_POINT] = kwargs[ATTR_TEMPERATURE]
if ATTR_TARGET_TEMP_LOW in kwargs and ATTR_TARGET_TEMP_HIGH in kwargs:
params[API_COOL_SET_POINT] = kwargs[ATTR_TARGET_TEMP_LOW]
params[API_HEAT_SET_POINT] = kwargs[ATTR_TARGET_TEMP_HIGH]
await self._async_update_hvac_params(params)
@callback
@@ -166,4 +238,15 @@ class AirzoneClimate(AirzoneZoneEntity, ClimateEntity):
]
else:
self._attr_hvac_mode = HVACMode.OFF
self._attr_max_temp = self.get_airzone_value(AZD_TEMP_MAX)
self._attr_min_temp = self.get_airzone_value(AZD_TEMP_MIN)
self._attr_target_temperature = self.get_airzone_value(AZD_TEMP_SET)
if self.supported_features & ClimateEntityFeature.FAN_MODE:
self._attr_fan_mode = self._speeds.get(self.get_airzone_value(AZD_SPEED))
if self.supported_features & ClimateEntityFeature.TARGET_TEMPERATURE_RANGE:
self._attr_target_temperature_high = self.get_airzone_value(
AZD_HEAT_TEMP_SET
)
self._attr_target_temperature_low = self.get_airzone_value(
AZD_COOL_TEMP_SET
)

View File

@@ -7,6 +7,7 @@ from typing import Any
from aioairzone.const import (
API_SYSTEM_ID,
API_ZONE_ID,
AZD_AVAILABLE,
AZD_FIRMWARE,
AZD_FULL_NAME,
AZD_ID,
@@ -66,6 +67,11 @@ class AirzoneSystemEntity(AirzoneEntity):
)
self._attr_unique_id = entry.unique_id or entry.entry_id
@property
def available(self) -> bool:
"""Return system availability."""
return super().available and self.get_airzone_value(AZD_AVAILABLE)
def get_airzone_value(self, key: str) -> Any:
"""Return system value by key."""
value = None
@@ -130,6 +136,11 @@ class AirzoneZoneEntity(AirzoneEntity):
)
self._attr_unique_id = entry.unique_id or entry.entry_id
@property
def available(self) -> bool:
"""Return zone availability."""
return super().available and self.get_airzone_value(AZD_AVAILABLE)
def get_airzone_value(self, key: str) -> Any:
"""Return zone value by key."""
value = None

View File

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

View File

@@ -1,7 +1,7 @@
"""Support for the Airzone sensors."""
from __future__ import annotations
from dataclasses import dataclass, replace
from dataclasses import dataclass
from typing import Any, Final
from aioairzone.common import GrilleAngle, SleepTimeout
@@ -41,14 +41,14 @@ class AirzoneSelectDescription(SelectEntityDescription, AirzoneSelectDescription
GRILLE_ANGLE_DICT: Final[dict[str, int]] = {
"90º": GrilleAngle.DEG_90,
"50º": GrilleAngle.DEG_50,
"45º": GrilleAngle.DEG_45,
"40º": GrilleAngle.DEG_40,
"90deg": GrilleAngle.DEG_90,
"50deg": GrilleAngle.DEG_50,
"45deg": GrilleAngle.DEG_45,
"40deg": GrilleAngle.DEG_40,
}
SLEEP_DICT: Final[dict[str, int]] = {
"Off": SleepTimeout.SLEEP_OFF,
"off": SleepTimeout.SLEEP_OFF,
"30m": SleepTimeout.SLEEP_30,
"60m": SleepTimeout.SLEEP_60,
"90m": SleepTimeout.SLEEP_90,
@@ -61,21 +61,27 @@ ZONE_SELECT_TYPES: Final[tuple[AirzoneSelectDescription, ...]] = (
entity_category=EntityCategory.CONFIG,
key=AZD_COLD_ANGLE,
name="Cold Angle",
options=list(GRILLE_ANGLE_DICT),
options_dict=GRILLE_ANGLE_DICT,
translation_key="grille_angles",
),
AirzoneSelectDescription(
api_param=API_HEAT_ANGLE,
entity_category=EntityCategory.CONFIG,
key=AZD_HEAT_ANGLE,
name="Heat Angle",
options=list(GRILLE_ANGLE_DICT),
options_dict=GRILLE_ANGLE_DICT,
translation_key="grille_angles",
),
AirzoneSelectDescription(
api_param=API_SLEEP,
entity_category=EntityCategory.CONFIG,
key=AZD_SLEEP,
name="Sleep",
options=list(SLEEP_DICT),
options_dict=SLEEP_DICT,
translation_key="sleep_times",
),
)
@@ -91,14 +97,10 @@ async def async_setup_entry(
for system_zone_id, zone_data in coordinator.data[AZD_ZONES].items():
for description in ZONE_SELECT_TYPES:
if description.key in zone_data:
_desc = replace(
description,
options=list(description.options_dict.keys()),
)
entities.append(
AirzoneZoneSelect(
coordinator,
_desc,
description,
entry,
system_zone_id,
zone_data,

View File

@@ -23,5 +23,25 @@
}
}
}
},
"entity": {
"select": {
"grille_angles": {
"state": {
"90deg": "90°",
"50deg": "50°",
"45deg": "45°",
"40deg": "40°"
}
},
"sleep_times": {
"state": {
"off": "[%key:common::state::off%]",
"30m": "30 minutes",
"60m": "60 minutes",
"90m": "90 minutes"
}
}
}
}
}

View File

@@ -0,0 +1,48 @@
"""The Airzone Cloud integration."""
from __future__ import annotations
from aioairzone_cloud.cloudapi import AirzoneCloudApi
from aioairzone_cloud.common import ConnectionOptions
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ID, CONF_PASSWORD, CONF_USERNAME, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import aiohttp_client
from .const import DOMAIN
from .coordinator import AirzoneUpdateCoordinator
PLATFORMS: list[Platform] = [Platform.SENSOR]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Airzone Cloud from a config entry."""
options = ConnectionOptions(
entry.data[CONF_USERNAME],
entry.data[CONF_PASSWORD],
)
airzone = AirzoneCloudApi(aiohttp_client.async_get_clientsession(hass), options)
await airzone.login()
inst_list = await airzone.list_installations()
for inst in inst_list:
if inst.get_id() == entry.data[CONF_ID]:
airzone.select_installation(inst)
await airzone.update_installation(inst)
coordinator = AirzoneUpdateCoordinator(hass, airzone)
await coordinator.async_config_entry_first_refresh()
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
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,116 @@
"""Config flow for Airzone Cloud."""
from __future__ import annotations
from typing import Any
from aioairzone_cloud.cloudapi import AirzoneCloudApi
from aioairzone_cloud.common import ConnectionOptions
from aioairzone_cloud.const import AZD_ID, AZD_NAME, AZD_WEBSERVERS
from aioairzone_cloud.exceptions import AirzoneCloudError, LoginError
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.const import CONF_ID, CONF_PASSWORD, CONF_USERNAME
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers import aiohttp_client
from homeassistant.helpers.selector import (
SelectOptionDict,
SelectSelector,
SelectSelectorConfig,
SelectSelectorMode,
)
from .const import DOMAIN
class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle config flow for an Airzone Cloud device."""
airzone: AirzoneCloudApi
async def async_step_inst_pick(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle the installation selection."""
errors = {}
options: dict[str, str] = {}
inst_desc = None
inst_id = None
if user_input is not None:
inst_id = user_input[CONF_ID]
try:
inst_list = await self.airzone.list_installations()
except AirzoneCloudError:
errors["base"] = "cannot_connect"
else:
for inst in inst_list:
_data = inst.data()
_id = _data[AZD_ID]
options[_id] = f"{_data[AZD_NAME]} {_data[AZD_WEBSERVERS][0]} ({_id})"
if _id is not None and _id == inst_id:
inst_desc = options[_id]
if user_input is not None and inst_desc is not None:
await self.async_set_unique_id(inst_id)
self._abort_if_unique_id_configured()
user_input[CONF_USERNAME] = self.airzone.options.username
user_input[CONF_PASSWORD] = self.airzone.options.password
return self.async_create_entry(title=inst_desc, data=user_input)
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{
vol.Required(CONF_ID): SelectSelector(
SelectSelectorConfig(
options=[
SelectOptionDict(value=k, label=v)
for k, v in options.items()
],
mode=SelectSelectorMode.DROPDOWN,
)
),
}
),
errors=errors,
)
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle the initial step."""
errors = {}
if user_input is not None:
if CONF_ID in user_input:
return await self.async_step_inst_pick(user_input)
self.airzone = AirzoneCloudApi(
aiohttp_client.async_get_clientsession(self.hass),
ConnectionOptions(
user_input[CONF_USERNAME],
user_input[CONF_PASSWORD],
),
)
try:
await self.airzone.login()
except (AirzoneCloudError, LoginError):
errors["base"] = "cannot_connect"
else:
return await self.async_step_inst_pick()
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,8 @@
"""Constants for the Airzone Cloud integration."""
from typing import Final
DOMAIN: Final[str] = "airzone_cloud"
MANUFACTURER: Final[str] = "Airzone"
AIOAIRZONE_CLOUD_TIMEOUT_SEC: Final[int] = 30

View File

@@ -0,0 +1,43 @@
"""The Airzone Cloud integration coordinator."""
from __future__ import annotations
from datetime import timedelta
import logging
from typing import Any
from aioairzone_cloud.cloudapi import AirzoneCloudApi
from aioairzone_cloud.exceptions import AirzoneCloudError
import async_timeout
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import AIOAIRZONE_CLOUD_TIMEOUT_SEC, DOMAIN
SCAN_INTERVAL = timedelta(seconds=60)
_LOGGER = logging.getLogger(__name__)
class AirzoneUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"""Class to manage fetching data from the Airzone Cloud device."""
def __init__(self, hass: HomeAssistant, airzone: AirzoneCloudApi) -> None:
"""Initialize."""
self.airzone = airzone
super().__init__(
hass,
_LOGGER,
name=DOMAIN,
update_interval=SCAN_INTERVAL,
)
async def _async_update_data(self) -> dict[str, Any]:
"""Update data via library."""
async with async_timeout.timeout(AIOAIRZONE_CLOUD_TIMEOUT_SEC):
try:
await self.airzone.update()
except AirzoneCloudError as error:
raise UpdateFailed(error) from error
return self.airzone.data()

View File

@@ -0,0 +1,144 @@
"""Support for the Airzone Cloud diagnostics."""
from __future__ import annotations
from collections.abc import Mapping
from typing import Any
from aioairzone_cloud.const import (
API_CITY,
API_GROUP_ID,
API_LOCATION_ID,
API_OLD_ID,
API_PIN,
API_STAT_AP_MAC,
API_STAT_SSID,
API_USER_ID,
AZD_WIFI_MAC,
RAW_DEVICES_STATUS,
RAW_INSTALLATIONS,
RAW_WEBSERVERS,
)
from homeassistant.components.diagnostics.util import async_redact_data
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from .const import DOMAIN
from .coordinator import AirzoneUpdateCoordinator
TO_REDACT_API = [
API_CITY,
API_GROUP_ID,
API_LOCATION_ID,
API_OLD_ID,
API_PIN,
API_STAT_AP_MAC,
API_STAT_SSID,
API_USER_ID,
]
TO_REDACT_CONFIG = [
CONF_PASSWORD,
CONF_USERNAME,
]
TO_REDACT_COORD = [
AZD_WIFI_MAC,
]
def gather_ids(api_data: dict[str, Any]) -> dict[str, Any]:
"""Return dict with IDs."""
ids: dict[str, Any] = {}
dev_idx = 1
for dev_id in api_data[RAW_DEVICES_STATUS]:
if dev_id not in ids:
ids[dev_id] = f"device{dev_idx}"
dev_idx += 1
inst_idx = 1
for inst_id in api_data[RAW_INSTALLATIONS]:
if inst_id not in ids:
ids[inst_id] = f"installation{inst_idx}"
inst_idx += 1
ws_idx = 1
for ws_id in api_data[RAW_WEBSERVERS]:
if ws_id not in ids:
ids[ws_id] = f"webserver{ws_idx}"
ws_idx += 1
return ids
def redact_keys(data: Any, ids: dict[str, Any]) -> Any:
"""Redact sensitive keys in a dict."""
if not isinstance(data, (Mapping, list)):
return data
if isinstance(data, list):
return [redact_keys(val, ids) for val in data]
redacted = {**data}
keys = list(redacted)
for key in keys:
if key in ids:
redacted[ids[key]] = redacted.pop(key)
elif isinstance(redacted[key], Mapping):
redacted[key] = redact_keys(redacted[key], ids)
elif isinstance(redacted[key], list):
redacted[key] = [redact_keys(item, ids) for item in redacted[key]]
return redacted
def redact_values(data: Any, ids: dict[str, Any]) -> Any:
"""Redact sensitive values in a dict."""
if not isinstance(data, (Mapping, list)):
if data in ids:
return ids[data]
return data
if isinstance(data, list):
return [redact_values(val, ids) for val in data]
redacted = {**data}
for key, value in redacted.items():
if value is None:
continue
if isinstance(value, Mapping):
redacted[key] = redact_values(value, ids)
elif isinstance(value, list):
redacted[key] = [redact_values(item, ids) for item in value]
elif value in ids:
redacted[key] = ids[value]
return redacted
def redact_all(
data: dict[str, Any], ids: dict[str, Any], to_redact: list[str]
) -> dict[str, Any]:
"""Redact sensitive data."""
_data = redact_keys(data, ids)
_data = redact_values(_data, ids)
return async_redact_data(_data, to_redact)
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, config_entry: ConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
coordinator: AirzoneUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id]
raw_data = coordinator.airzone.raw_data()
ids = gather_ids(raw_data)
return {
"api_data": redact_all(raw_data, ids, TO_REDACT_API),
"config_entry": redact_all(config_entry.as_dict(), ids, TO_REDACT_CONFIG),
"coord_data": redact_all(coordinator.data, ids, TO_REDACT_COORD),
}

View File

@@ -0,0 +1,129 @@
"""Entity classes for the Airzone Cloud integration."""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Any
from aioairzone_cloud.const import (
AZD_AIDOOS,
AZD_AVAILABLE,
AZD_FIRMWARE,
AZD_NAME,
AZD_SYSTEM_ID,
AZD_WEBSERVER,
AZD_WEBSERVERS,
AZD_ZONES,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, MANUFACTURER
from .coordinator import AirzoneUpdateCoordinator
class AirzoneEntity(CoordinatorEntity[AirzoneUpdateCoordinator], ABC):
"""Define an Airzone Cloud entity."""
@property
def available(self) -> bool:
"""Return Airzone Cloud entity availability."""
return super().available and self.get_airzone_value(AZD_AVAILABLE)
@abstractmethod
def get_airzone_value(self, key: str) -> Any:
"""Return Airzone Cloud entity value by key."""
class AirzoneAidooEntity(AirzoneEntity):
"""Define an Airzone Cloud Aidoo entity."""
def __init__(
self,
coordinator: AirzoneUpdateCoordinator,
entry: ConfigEntry,
aidoo_id: str,
aidoo_data: dict[str, Any],
) -> None:
"""Initialize."""
super().__init__(coordinator)
self.aidoo_id = aidoo_id
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, aidoo_id)},
manufacturer=MANUFACTURER,
name=aidoo_data[AZD_NAME],
via_device=(DOMAIN, aidoo_data[AZD_WEBSERVER]),
)
def get_airzone_value(self, key: str) -> Any:
"""Return Aidoo value by key."""
value = None
if aidoo := self.coordinator.data[AZD_AIDOOS].get(self.aidoo_id):
value = aidoo.get(key)
return value
class AirzoneWebServerEntity(AirzoneEntity):
"""Define an Airzone Cloud WebServer entity."""
def __init__(
self,
coordinator: AirzoneUpdateCoordinator,
entry: ConfigEntry,
ws_id: str,
ws_data: dict[str, Any],
) -> None:
"""Initialize."""
super().__init__(coordinator)
self.ws_id = ws_id
self._attr_device_info = DeviceInfo(
connections={(dr.CONNECTION_NETWORK_MAC, ws_id)},
identifiers={(DOMAIN, ws_id)},
manufacturer=MANUFACTURER,
name=f"WebServer {ws_id}",
sw_version=ws_data[AZD_FIRMWARE],
)
def get_airzone_value(self, key: str) -> Any:
"""Return WebServer value by key."""
value = None
if webserver := self.coordinator.data[AZD_WEBSERVERS].get(self.ws_id):
value = webserver.get(key)
return value
class AirzoneZoneEntity(AirzoneEntity):
"""Define an Airzone Cloud Zone entity."""
def __init__(
self,
coordinator: AirzoneUpdateCoordinator,
entry: ConfigEntry,
zone_id: str,
zone_data: dict[str, Any],
) -> None:
"""Initialize."""
super().__init__(coordinator)
self.system_id = zone_data[AZD_SYSTEM_ID]
self.zone_id = zone_id
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, zone_id)},
manufacturer=MANUFACTURER,
name=zone_data[AZD_NAME],
via_device=(DOMAIN, self.system_id),
)
def get_airzone_value(self, key: str) -> Any:
"""Return zone value by key."""
value = None
if zone := self.coordinator.data[AZD_ZONES].get(self.zone_id):
value = zone.get(key)
return value

View File

@@ -0,0 +1,10 @@
{
"domain": "airzone_cloud",
"name": "Airzone Cloud",
"codeowners": ["@Noltari"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/airzone_cloud",
"iot_class": "cloud_polling",
"loggers": ["aioairzone_cloud"],
"requirements": ["aioairzone-cloud==0.1.7"]
}

View File

@@ -0,0 +1,209 @@
"""Support for the Airzone Cloud sensors."""
from __future__ import annotations
from typing import Any, Final
from aioairzone_cloud.const import (
AZD_AIDOOS,
AZD_HUMIDITY,
AZD_NAME,
AZD_TEMP,
AZD_WEBSERVERS,
AZD_WIFI_RSSI,
AZD_ZONES,
)
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
PERCENTAGE,
SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
EntityCategory,
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN
from .coordinator import AirzoneUpdateCoordinator
from .entity import (
AirzoneAidooEntity,
AirzoneEntity,
AirzoneWebServerEntity,
AirzoneZoneEntity,
)
AIDOO_SENSOR_TYPES: Final[tuple[SensorEntityDescription, ...]] = (
SensorEntityDescription(
device_class=SensorDeviceClass.TEMPERATURE,
key=AZD_TEMP,
name="Temperature",
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
),
)
WEBSERVER_SENSOR_TYPES: Final[tuple[SensorEntityDescription, ...]] = (
SensorEntityDescription(
device_class=SensorDeviceClass.SIGNAL_STRENGTH,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
has_entity_name=True,
key=AZD_WIFI_RSSI,
name="RSSI",
native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
state_class=SensorStateClass.MEASUREMENT,
),
)
ZONE_SENSOR_TYPES: Final[tuple[SensorEntityDescription, ...]] = (
SensorEntityDescription(
device_class=SensorDeviceClass.TEMPERATURE,
key=AZD_TEMP,
name="Temperature",
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
device_class=SensorDeviceClass.HUMIDITY,
key=AZD_HUMIDITY,
name="Humidity",
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
),
)
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Add Airzone Cloud sensors from a config_entry."""
coordinator: AirzoneUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
sensors: list[AirzoneSensor] = []
# Aidoos
for aidoo_id, aidoo_data in coordinator.data.get(AZD_AIDOOS, {}).items():
for description in AIDOO_SENSOR_TYPES:
if description.key in aidoo_data:
sensors.append(
AirzoneAidooSensor(
coordinator,
description,
entry,
aidoo_id,
aidoo_data,
)
)
# WebServers
for ws_id, ws_data in coordinator.data.get(AZD_WEBSERVERS, {}).items():
for description in WEBSERVER_SENSOR_TYPES:
if description.key in ws_data:
sensors.append(
AirzoneWebServerSensor(
coordinator,
description,
entry,
ws_id,
ws_data,
)
)
# Zones
for zone_id, zone_data in coordinator.data.get(AZD_ZONES, {}).items():
for description in ZONE_SENSOR_TYPES:
if description.key in zone_data:
sensors.append(
AirzoneZoneSensor(
coordinator,
description,
entry,
zone_id,
zone_data,
)
)
async_add_entities(sensors)
class AirzoneSensor(AirzoneEntity, SensorEntity):
"""Define an Airzone Cloud sensor."""
@callback
def _handle_coordinator_update(self) -> None:
"""Update attributes when the coordinator updates."""
self._async_update_attrs()
super()._handle_coordinator_update()
@callback
def _async_update_attrs(self) -> None:
"""Update sensor attributes."""
self._attr_native_value = self.get_airzone_value(self.entity_description.key)
class AirzoneAidooSensor(AirzoneAidooEntity, AirzoneSensor):
"""Define an Airzone Cloud Aidoo sensor."""
def __init__(
self,
coordinator: AirzoneUpdateCoordinator,
description: SensorEntityDescription,
entry: ConfigEntry,
aidoo_id: str,
aidoo_data: dict[str, Any],
) -> None:
"""Initialize."""
super().__init__(coordinator, entry, aidoo_id, aidoo_data)
self._attr_name = f"{aidoo_data[AZD_NAME]} {description.name}"
self._attr_unique_id = f"{aidoo_id}_{description.key}"
self.entity_description = description
self._async_update_attrs()
class AirzoneWebServerSensor(AirzoneWebServerEntity, AirzoneSensor):
"""Define an Airzone Cloud WebServer sensor."""
def __init__(
self,
coordinator: AirzoneUpdateCoordinator,
description: SensorEntityDescription,
entry: ConfigEntry,
ws_id: str,
ws_data: dict[str, Any],
) -> None:
"""Initialize."""
super().__init__(coordinator, entry, ws_id, ws_data)
self._attr_unique_id = f"{ws_id}_{description.key}"
self.entity_description = description
self._async_update_attrs()
class AirzoneZoneSensor(AirzoneZoneEntity, AirzoneSensor):
"""Define an Airzone Cloud Zone sensor."""
def __init__(
self,
coordinator: AirzoneUpdateCoordinator,
description: SensorEntityDescription,
entry: ConfigEntry,
zone_id: str,
zone_data: dict[str, Any],
) -> None:
"""Initialize."""
super().__init__(coordinator, entry, zone_id, zone_data)
self._attr_name = f"{zone_data[AZD_NAME]} {description.name}"
self._attr_unique_id = f"{zone_id}_{description.key}"
self.entity_description = description
self._async_update_attrs()

View File

@@ -0,0 +1,19 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
},
"step": {
"user": {
"data": {
"id": "Installation",
"username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]"
}
}
}
}
}

View File

@@ -89,15 +89,9 @@ class AladdinDevice(CoverEntity):
await self._acc.get_doors(self._serial)
self._attr_available = True
except session_manager.ConnectionError:
except (session_manager.ConnectionError, session_manager.InvalidPasswordError):
self._attr_available = False
except session_manager.InvalidPasswordError:
self._attr_available = False
await self.hass.async_create_task(
self.hass.config_entries.async_reload(self._entry_id)
)
@property
def is_closed(self) -> bool | None:
"""Update is closed attribute."""

View File

@@ -62,5 +62,11 @@
}
}
}
},
"issues": {
"platform_integration_no_support": {
"title": "[%key:common::issues::platform_integration_no_support_title%]",
"description": "[%key:common::issues::platform_integration_no_support_description%]"
}
}
}

View File

@@ -12,7 +12,7 @@ from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET
from homeassistant.core import callback
from homeassistant.helpers import aiohttp_client
from homeassistant.helpers.storage import Store
from homeassistant.util import dt
from homeassistant.util import dt as dt_util
_LOGGER = logging.getLogger(__name__)
@@ -95,12 +95,12 @@ class Auth:
if not self._prefs[STORAGE_ACCESS_TOKEN]:
return False
expire_time = dt.parse_datetime(self._prefs[STORAGE_EXPIRE_TIME])
expire_time = dt_util.parse_datetime(self._prefs[STORAGE_EXPIRE_TIME])
preemptive_expire_time = expire_time - timedelta(
seconds=PREEMPTIVE_REFRESH_TTL_IN_SECONDS
)
return dt.utcnow() < preemptive_expire_time
return dt_util.utcnow() < preemptive_expire_time
async def _async_request_new_token(self, lwa_params):
try:
@@ -130,7 +130,7 @@ class Auth:
access_token = response_json["access_token"]
refresh_token = response_json["refresh_token"]
expires_in = response_json["expires_in"]
expire_time = dt.utcnow() + timedelta(seconds=expires_in)
expire_time = dt_util.utcnow() + timedelta(seconds=expires_in)
await self._async_update_preferences(
access_token, refresh_token, expire_time.isoformat()

View File

@@ -1,4 +1,6 @@
"""Config helpers for Alexa."""
from __future__ import annotations
from abc import ABC, abstractmethod
import asyncio
import logging
@@ -17,15 +19,15 @@ _LOGGER = logging.getLogger(__name__)
class AbstractConfig(ABC):
"""Hold the configuration for Alexa."""
_store: AlexaConfigStore
_unsub_proactive_report: CALLBACK_TYPE | None = None
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize abstract config."""
self.hass = hass
self._enable_proactive_mode_lock = asyncio.Lock()
self._store = None
async def async_initialize(self):
async def async_initialize(self) -> None:
"""Perform async initialization of config."""
self._store = AlexaConfigStore(self.hass)
await self._store.async_load()
@@ -65,7 +67,7 @@ class AbstractConfig(ABC):
def user_identifier(self):
"""Return an identifier for the user that represents this config."""
async def async_enable_proactive_mode(self):
async def async_enable_proactive_mode(self) -> None:
"""Enable proactive mode."""
_LOGGER.debug("Enable proactive mode")
async with self._enable_proactive_mode_lock:
@@ -75,7 +77,7 @@ class AbstractConfig(ABC):
self.hass, self
)
async def async_disable_proactive_mode(self):
async def async_disable_proactive_mode(self) -> None:
"""Disable proactive mode."""
_LOGGER.debug("Disable proactive mode")
if unsub_func := self._unsub_proactive_report:
@@ -105,7 +107,7 @@ class AbstractConfig(ABC):
"""Return authorization status."""
return self._store.authorized
async def set_authorized(self, authorized):
async def set_authorized(self, authorized) -> None:
"""Set authorization status.
- Set when an incoming message is received from Alexa.

View File

@@ -1,6 +1,7 @@
"""Support for Alexa skill service end point."""
import enum
import logging
from typing import Any
from homeassistant.components import http
from homeassistant.core import callback
@@ -180,12 +181,15 @@ async def async_handle_intent(hass, message):
return alexa_response.as_dict()
def resolve_slot_synonyms(key, request):
def resolve_slot_data(key: str, request: dict[str, Any]) -> dict[str, str]:
"""Check slot request for synonym resolutions."""
# Default to the spoken slot value if more than one or none are found. For
# Default to the spoken slot value if more than one or none are found. Always
# passes the id and name of the nearest possible slot resolution. For
# reference to the request object structure, see the Alexa docs:
# https://tinyurl.com/ybvm7jhs
resolved_value = request["value"]
resolved_data = {}
resolved_data["value"] = request["value"]
resolved_data["id"] = ""
if (
"resolutions" in request
@@ -200,20 +204,26 @@ def resolve_slot_synonyms(key, request):
if entry["status"]["code"] != SYN_RESOLUTION_MATCH:
continue
possible_values.extend([item["value"]["name"] for item in entry["values"]])
possible_values.extend([item["value"] for item in entry["values"]])
# Always set id if available, otherwise an empty string is used as id
if len(possible_values) >= 1:
# Set ID if available
if "id" in possible_values[0]:
resolved_data["id"] = possible_values[0]["id"]
# If there is only one match use the resolved value, otherwise the
# resolution cannot be determined, so use the spoken slot value
# resolution cannot be determined, so use the spoken slot value and empty string as id
if len(possible_values) == 1:
resolved_value = possible_values[0]
resolved_data["value"] = possible_values[0]["name"]
else:
_LOGGER.debug(
"Found multiple synonym resolutions for slot value: {%s: %s}",
key,
resolved_value,
resolved_data["value"],
)
return resolved_value
return resolved_data
class AlexaResponse:
@@ -237,8 +247,10 @@ class AlexaResponse:
continue
_key = key.replace(".", "_")
_slot_data = resolve_slot_data(key, value)
self.variables[_key] = resolve_slot_synonyms(key, value)
self.variables[_key] = _slot_data["value"]
self.variables[_key + "_Id"] = _slot_data["id"]
def add_card(self, card_type, title, content):
"""Add a card to the response."""

View File

@@ -5,7 +5,7 @@ import asyncio
from http import HTTPStatus
import json
import logging
from typing import cast
from typing import TYPE_CHECKING, cast
import aiohttp
import async_timeout
@@ -23,6 +23,9 @@ from .entities import ENTITY_ADAPTERS, AlexaEntity, generate_alexa_id
from .errors import NoTokenAvailable, RequireRelink
from .messages import AlexaResponse
if TYPE_CHECKING:
from .config import AbstractConfig
_LOGGER = logging.getLogger(__name__)
DEFAULT_TIMEOUT = 10
@@ -188,7 +191,9 @@ async def async_send_changereport_message(
)
async def async_send_add_or_update_message(hass, config, entity_ids):
async def async_send_add_or_update_message(
hass: HomeAssistant, config: AbstractConfig, entity_ids: list[str]
) -> aiohttp.ClientResponse:
"""Send an AddOrUpdateReport message for entities.
https://developer.amazon.com/docs/device-apis/alexa-discovery.html#add-or-update-report
@@ -223,7 +228,9 @@ async def async_send_add_or_update_message(hass, config, entity_ids):
)
async def async_send_delete_message(hass, config, entity_ids):
async def async_send_delete_message(
hass: HomeAssistant, config: AbstractConfig, entity_ids: list[str]
) -> aiohttp.ClientResponse:
"""Send an DeleteReport message for entities.
https://developer.amazon.com/docs/device-apis/alexa-discovery.html#deletereport-event

View File

@@ -167,12 +167,9 @@ class AmazonPollyProvider(Provider):
self,
message: str,
language: str,
options: dict[str, Any] | None = None,
options: dict[str, Any],
) -> TtsAudioType:
"""Request TTS file from Polly."""
if options is None or language is None:
_LOGGER.debug("language and/or options were missing")
return None, None
voice_id = options.get(CONF_VOICE, self.default_voice)
voice_in_dict = self.all_voices[voice_id]
if language != voice_in_dict.get("LanguageCode"):

View File

@@ -6,12 +6,15 @@ import voluptuous as vol
from homeassistant.components import websocket_api
from homeassistant.const import EVENT_HOMEASSISTANT_STARTED
from homeassistant.core import Event, HassJob, HomeAssistant, callback
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.event import async_call_later, async_track_time_interval
from homeassistant.helpers.typing import ConfigType
from .analytics import Analytics
from .const import ATTR_ONBOARDED, ATTR_PREFERENCES, DOMAIN, INTERVAL, PREFERENCE_SCHEMA
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
async def async_setup(hass: HomeAssistant, _: ConfigType) -> bool:
"""Set up the analytics integration."""

View File

@@ -1,6 +1,8 @@
"""The Android TV Remote integration."""
from __future__ import annotations
import logging
from androidtvremote2 import (
AndroidTVRemote,
CannotConnect,
@@ -9,20 +11,37 @@ from androidtvremote2 import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, EVENT_HOMEASSISTANT_STOP, Platform
from homeassistant.const import CONF_HOST, CONF_NAME, 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]
_LOGGER = logging.getLogger(__name__)
PLATFORMS: list[Platform] = [Platform.MEDIA_PLAYER, 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])
@callback
def is_available_updated(is_available: bool) -> None:
if is_available:
_LOGGER.info(
"Reconnected to %s at %s", entry.data[CONF_NAME], entry.data[CONF_HOST]
)
else:
_LOGGER.warning(
"Disconnected from %s at %s",
entry.data[CONF_NAME],
entry.data[CONF_HOST],
)
api.add_is_available_updated_callback(is_available_updated)
try:
await api.async_connect()
except InvalidAuth as exc:

View File

@@ -135,7 +135,8 @@ class AndroidTVRemoteConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
self.host = discovery_info.host
self.name = discovery_info.name.removesuffix("._androidtvremote2._tcp.local.")
self.mac = discovery_info.properties.get("bt")
assert self.mac
if not self.mac:
return self.async_abort(reason="cannot_connect")
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}

View File

@@ -0,0 +1,84 @@
"""Base entity for Android TV Remote."""
from __future__ import annotations
from androidtvremote2 import AndroidTVRemote, ConnectionClosed
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME
from homeassistant.core import callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC
from homeassistant.helpers.entity import DeviceInfo, Entity
from .const import DOMAIN
class AndroidTVRemoteBaseEntity(Entity):
"""Android TV Remote Base Entity."""
_attr_has_entity_name = True
_attr_should_poll = False
def __init__(self, api: AndroidTVRemote, config_entry: ConfigEntry) -> None:
"""Initialize the entity."""
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_is_on = api.is_on
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_available_updated(self, is_available: bool) -> None:
"""Update the state when the device is ready to receive commands or is unavailable."""
self._attr_available = is_available
self.async_write_ha_state()
@callback
def _is_on_updated(self, is_on: bool) -> None:
"""Update the state when device turns on or off."""
self._attr_is_on = is_on
self.async_write_ha_state()
async def async_added_to_hass(self) -> None:
"""Register callbacks."""
self._api.add_is_available_updated_callback(self._is_available_updated)
self._api.add_is_on_updated_callback(self._is_on_updated)
async def async_will_remove_from_hass(self) -> None:
"""Remove callbacks."""
self._api.remove_is_available_updated_callback(self._is_available_updated)
self._api.remove_is_on_updated_callback(self._is_on_updated)
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

@@ -1,13 +1,13 @@
{
"domain": "androidtv_remote",
"name": "Android TV Remote",
"codeowners": ["@tronikos"],
"codeowners": ["@tronikos", "@Drafteed"],
"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"],
"requirements": ["androidtvremote2==0.0.9"],
"zeroconf": ["_androidtvremote2._tcp.local."]
}

View File

@@ -0,0 +1,198 @@
"""Media player support for Android TV Remote."""
from __future__ import annotations
import asyncio
from typing import Any
from androidtvremote2 import AndroidTVRemote, ConnectionClosed
from homeassistant.components.media_player import (
MediaPlayerDeviceClass,
MediaPlayerEntity,
MediaPlayerEntityFeature,
MediaPlayerState,
MediaType,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN
from .entity import AndroidTVRemoteBaseEntity
PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Android TV media player entity based on a config entry."""
api: AndroidTVRemote = hass.data[DOMAIN][config_entry.entry_id]
async_add_entities([AndroidTVRemoteMediaPlayerEntity(api, config_entry)])
class AndroidTVRemoteMediaPlayerEntity(AndroidTVRemoteBaseEntity, MediaPlayerEntity):
"""Android TV Remote Media Player Entity."""
_attr_assumed_state = True
_attr_device_class = MediaPlayerDeviceClass.TV
_attr_supported_features = (
MediaPlayerEntityFeature.PAUSE
| MediaPlayerEntityFeature.VOLUME_STEP
| MediaPlayerEntityFeature.VOLUME_MUTE
| MediaPlayerEntityFeature.PREVIOUS_TRACK
| MediaPlayerEntityFeature.NEXT_TRACK
| MediaPlayerEntityFeature.TURN_ON
| MediaPlayerEntityFeature.TURN_OFF
| MediaPlayerEntityFeature.PLAY
| MediaPlayerEntityFeature.STOP
| MediaPlayerEntityFeature.PLAY_MEDIA
)
def __init__(self, api: AndroidTVRemote, config_entry: ConfigEntry) -> None:
"""Initialize the entity."""
super().__init__(api, config_entry)
# This task is needed to create a job that sends a key press
# sequence that can be canceled if concurrency occurs
self._channel_set_task: asyncio.Task | None = None
def _update_current_app(self, current_app: str) -> None:
"""Update current app info."""
self._attr_app_id = current_app
self._attr_app_name = current_app
def _update_volume_info(self, volume_info: dict[str, str | bool]) -> None:
"""Update volume info."""
if volume_info.get("max"):
self._attr_volume_level = int(volume_info["level"]) / int(
volume_info["max"]
)
self._attr_is_volume_muted = bool(volume_info["muted"])
else:
self._attr_volume_level = None
self._attr_is_volume_muted = None
@callback
def _current_app_updated(self, current_app: str) -> None:
"""Update the state when the current app changes."""
self._update_current_app(current_app)
self.async_write_ha_state()
@callback
def _volume_info_updated(self, volume_info: dict[str, str | bool]) -> None:
"""Update the state when the volume info changes."""
self._update_volume_info(volume_info)
self.async_write_ha_state()
async def async_added_to_hass(self) -> None:
"""Register callbacks."""
await super().async_added_to_hass()
self._update_current_app(self._api.current_app)
self._update_volume_info(self._api.volume_info)
self._api.add_current_app_updated_callback(self._current_app_updated)
self._api.add_volume_info_updated_callback(self._volume_info_updated)
async def async_will_remove_from_hass(self) -> None:
"""Remove callbacks."""
await super().async_will_remove_from_hass()
self._api.remove_current_app_updated_callback(self._current_app_updated)
self._api.remove_volume_info_updated_callback(self._volume_info_updated)
@property
def state(self) -> MediaPlayerState:
"""Return the state of the device."""
if self._attr_is_on:
return MediaPlayerState.ON
return MediaPlayerState.OFF
async def async_turn_on(self) -> None:
"""Turn the Android TV on."""
if not self._attr_is_on:
self._send_key_command("POWER")
async def async_turn_off(self) -> None:
"""Turn the Android TV off."""
if self._attr_is_on:
self._send_key_command("POWER")
async def async_volume_up(self) -> None:
"""Turn volume up for media player."""
self._send_key_command("VOLUME_UP")
async def async_volume_down(self) -> None:
"""Turn volume down for media player."""
self._send_key_command("VOLUME_DOWN")
async def async_mute_volume(self, mute: bool) -> None:
"""Mute the volume."""
if mute != self.is_volume_muted:
self._send_key_command("VOLUME_MUTE")
async def async_media_play(self) -> None:
"""Send play command."""
self._send_key_command("MEDIA_PLAY")
async def async_media_pause(self) -> None:
"""Send pause command."""
self._send_key_command("MEDIA_PAUSE")
async def async_media_play_pause(self) -> None:
"""Send play/pause command."""
self._send_key_command("MEDIA_PLAY_PAUSE")
async def async_media_stop(self) -> None:
"""Send stop command."""
self._send_key_command("MEDIA_STOP")
async def async_media_previous_track(self) -> None:
"""Send previous track command."""
self._send_key_command("MEDIA_PREVIOUS")
async def async_media_next_track(self) -> None:
"""Send next track command."""
self._send_key_command("MEDIA_NEXT")
async def async_play_media(
self, media_type: MediaType | str, media_id: str, **kwargs: Any
) -> None:
"""Play a piece of media."""
if media_type == MediaType.CHANNEL:
if not media_id.isnumeric():
raise ValueError(f"Channel must be numeric: {media_id}")
if self._channel_set_task:
self._channel_set_task.cancel()
self._channel_set_task = asyncio.create_task(
self._send_key_commands(list(media_id))
)
await self._channel_set_task
return
if media_type == MediaType.URL:
self._send_launch_app_command(media_id)
return
raise ValueError(f"Invalid media type: {media_type}")
async def _send_key_commands(
self, key_codes: list[str], delay_secs: float = 0.1
) -> None:
"""Send a key press sequence to Android TV.
The delay is necessary because device may ignore
some commands if we send the sequence without delay.
"""
try:
for key_code in key_codes:
self._api.send_key_command(key_code)
await asyncio.sleep(delay_secs)
except ConnectionClosed as exc:
raise HomeAssistantError(
"Connection to Android TV device is closed"
) from exc

View File

@@ -3,10 +3,9 @@ from __future__ import annotations
import asyncio
from collections.abc import Iterable
import logging
from typing import Any
from androidtvremote2 import AndroidTVRemote, ConnectionClosed
from androidtvremote2 import AndroidTVRemote
from homeassistant.components.remote import (
ATTR_ACTIVITY,
@@ -20,17 +19,13 @@ from homeassistant.components.remote import (
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
from .entity import AndroidTVRemoteBaseEntity
PARALLEL_UPDATES = 0
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
@@ -43,62 +38,29 @@ async def async_setup_entry(
async_add_entities([AndroidTVRemoteEntity(api, config_entry)])
class AndroidTVRemoteEntity(RemoteEntity):
"""Representation of an Android TV Remote."""
class AndroidTVRemoteEntity(AndroidTVRemoteBaseEntity, RemoteEntity):
"""Android TV Remote Entity."""
_attr_has_entity_name = True
_attr_should_poll = False
_attr_supported_features = RemoteEntityFeature.ACTIVITY
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 _current_app_updated(self, current_app: str) -> None:
"""Update the state when the current app changes."""
self._attr_current_activity = current_app
self.async_write_ha_state()
@callback
def is_on_updated(is_on: bool) -> None:
self._attr_is_on = is_on
self.async_write_ha_state()
async def async_added_to_hass(self) -> None:
"""Register callbacks."""
await super().async_added_to_hass()
@callback
def current_app_updated(current_app: str) -> None:
self._attr_current_activity = current_app
self.async_write_ha_state()
self._attr_current_activity = self._api.current_app
self._api.add_current_app_updated_callback(self._current_app_updated)
@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()
async def async_will_remove_from_hass(self) -> None:
"""Remove callbacks."""
await super().async_will_remove_from_hass()
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)
self._api.remove_current_app_updated_callback(self._current_app_updated)
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the Android TV on."""
@@ -128,27 +90,3 @@ class AndroidTVRemoteEntity(RemoteEntity):
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

@@ -3,13 +3,7 @@ from __future__ import annotations
import logging
from anova_wifi import (
AnovaApi,
AnovaPrecisionCooker,
AnovaPrecisionCookerSensor,
InvalidLogin,
NoDevicesFound,
)
from anova_wifi import AnovaApi, AnovaPrecisionCooker, InvalidLogin, NoDevicesFound
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform
@@ -67,9 +61,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
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
]
firmware_version = coordinator.data.sensor.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

View File

@@ -2,7 +2,7 @@
from datetime import timedelta
import logging
from anova_wifi import AnovaOffline, AnovaPrecisionCooker
from anova_wifi import AnovaOffline, AnovaPrecisionCooker, APCUpdate
import async_timeout
from homeassistant.core import HomeAssistant, callback
@@ -14,11 +14,9 @@ from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
class AnovaCoordinator(DataUpdateCoordinator):
class AnovaCoordinator(DataUpdateCoordinator[APCUpdate]):
"""Anova custom coordinator."""
data: dict[str, dict[str, str | int | float]]
def __init__(
self,
hass: HomeAssistant,
@@ -47,7 +45,7 @@ class AnovaCoordinator(DataUpdateCoordinator):
sw_version=firmware_version,
)
async def _async_update_data(self) -> dict[str, dict[str, str | int | float]]:
async def _async_update_data(self) -> APCUpdate:
try:
async with async_timeout.timeout(5):
return await self.anova_device.update()

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/anova",
"iot_class": "cloud_polling",
"loggers": ["anova_wifi"],
"requirements": ["anova-wifi==0.8.0"]
"requirements": ["anova-wifi==0.10.0"]
}

View File

@@ -1,7 +1,10 @@
"""Support for Anova Sensors."""
from __future__ import annotations
from anova_wifi import AnovaPrecisionCookerSensor
from collections.abc import Callable
from dataclasses import dataclass
from anova_wifi import APCUpdateSensor
from homeassistant import config_entries
from homeassistant.components.sensor import (
@@ -19,57 +22,80 @@ from .const import DOMAIN
from .entity import AnovaDescriptionEntity
from .models import AnovaData
@dataclass
class AnovaSensorEntityDescriptionMixin:
"""Describes the mixin variables for anova sensors."""
value_fn: Callable[[APCUpdateSensor], float | int | str]
@dataclass
class AnovaSensorEntityDescription(
SensorEntityDescription, AnovaSensorEntityDescriptionMixin
):
"""Describes a Anova sensor."""
SENSOR_DESCRIPTIONS: list[SensorEntityDescription] = [
SensorEntityDescription(
key=AnovaPrecisionCookerSensor.COOK_TIME,
AnovaSensorEntityDescription(
key="cook_time",
state_class=SensorStateClass.TOTAL_INCREASING,
native_unit_of_measurement=UnitOfTime.SECONDS,
icon="mdi:clock-outline",
translation_key="cook_time",
device_class=SensorDeviceClass.DURATION,
value_fn=lambda data: data.cook_time,
),
SensorEntityDescription(
key=AnovaPrecisionCookerSensor.STATE, translation_key="state"
AnovaSensorEntityDescription(
key="state", translation_key="state", value_fn=lambda data: data.state
),
SensorEntityDescription(
key=AnovaPrecisionCookerSensor.MODE, translation_key="mode"
AnovaSensorEntityDescription(
key="mode", translation_key="mode", value_fn=lambda data: data.mode
),
SensorEntityDescription(
key=AnovaPrecisionCookerSensor.TARGET_TEMPERATURE,
AnovaSensorEntityDescription(
key="target_temperature",
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
icon="mdi:thermometer",
translation_key="target_temperature",
value_fn=lambda data: data.target_temperature,
),
SensorEntityDescription(
key=AnovaPrecisionCookerSensor.COOK_TIME_REMAINING,
AnovaSensorEntityDescription(
key="cook_time_remaining",
native_unit_of_measurement=UnitOfTime.SECONDS,
icon="mdi:clock-outline",
translation_key="cook_time_remaining",
device_class=SensorDeviceClass.DURATION,
value_fn=lambda data: data.cook_time_remaining,
),
SensorEntityDescription(
key=AnovaPrecisionCookerSensor.HEATER_TEMPERATURE,
AnovaSensorEntityDescription(
key="heater_temperature",
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
icon="mdi:thermometer",
translation_key="heater_temperature",
value_fn=lambda data: data.heater_temperature,
),
SensorEntityDescription(
key=AnovaPrecisionCookerSensor.TRIAC_TEMPERATURE,
AnovaSensorEntityDescription(
key="triac_temperature",
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
icon="mdi:thermometer",
translation_key="triac_temperature",
value_fn=lambda data: data.triac_temperature,
),
SensorEntityDescription(
key=AnovaPrecisionCookerSensor.WATER_TEMPERATURE,
AnovaSensorEntityDescription(
key="water_temperature",
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
icon="mdi:thermometer",
translation_key="water_temperature",
value_fn=lambda data: data.water_temperature,
),
]
@@ -91,7 +117,9 @@ async def async_setup_entry(
class AnovaSensor(AnovaDescriptionEntity, SensorEntity):
"""A sensor using Anova coordinator."""
entity_description: AnovaSensorEntityDescription
@property
def native_value(self) -> StateType:
"""Return the state."""
return self.coordinator.data["sensors"][self.entity_description.key]
return self.entity_description.value_fn(self.coordinator.data.sensor)

View File

@@ -17,7 +17,7 @@
"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"
"no_devices_found": "No devices were found. Make sure you have at least one Anova device online."
}
},
"entity": {

View File

@@ -3,8 +3,6 @@ from __future__ import annotations
import logging
from apcaccess.status import ALL_UNITS
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
@@ -379,7 +377,6 @@ SENSORS: dict[str, SensorEntityDescription] = {
key="stesti",
name="UPS Self Test Interval",
icon="mdi:information-outline",
state_class=SensorStateClass.TOTAL_INCREASING,
),
"timeleft": SensorEntityDescription(
key="timeleft",
@@ -427,7 +424,6 @@ SENSORS: dict[str, SensorEntityDescription] = {
),
}
SPECIFIC_UNITS = {"ITEMP": UnitOfTemperature.CELSIUS}
INFERRED_UNITS = {
" Minutes": UnitOfTime.MINUTES,
" Seconds": UnitOfTime.SECONDS,
@@ -438,7 +434,14 @@ INFERRED_UNITS = {
" Watts": UnitOfPower.WATT,
" Hz": UnitOfFrequency.HERTZ,
" C": UnitOfTemperature.CELSIUS,
# APCUPSd reports data for "itemp" field (eventually represented by UPS Internal
# Temperature sensor in this integration) with a trailing "Internal", e.g.,
# "34.6 C Internal". Here we create a fake unit " C Internal" to handle this case.
" C Internal": UnitOfTemperature.CELSIUS,
" Percent Load Capacity": PERCENTAGE,
# "stesti" field (Self Test Interval) field could report a "days" unit, e.g.,
# "7 days", so here we add support for it.
" days": UnitOfTime.DAYS,
}
@@ -466,15 +469,16 @@ async def async_setup_entry(
def infer_unit(value: str) -> tuple[str, str | None]:
"""If the value ends with any of the units from ALL_UNITS.
"""If the value ends with any of the units from supported units.
Split the unit off the end of the value and return the value, unit tuple
pair. Else return the original value and None as the unit.
"""
for unit in ALL_UNITS:
for unit, ha_unit in INFERRED_UNITS.items():
if value.endswith(unit):
return value.removesuffix(unit), INFERRED_UNITS.get(unit, unit.strip())
return value.removesuffix(unit), ha_unit
return value, None

View File

@@ -28,7 +28,7 @@ from homeassistant.const import (
import homeassistant.core as ha
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceNotFound, TemplateError, Unauthorized
from homeassistant.helpers import template
from homeassistant.helpers import config_validation as cv, template
from homeassistant.helpers.json import json_dumps
from homeassistant.helpers.service import async_get_all_descriptions
from homeassistant.helpers.typing import ConfigType
@@ -49,6 +49,8 @@ DOMAIN = "api"
STREAM_PING_PAYLOAD = "ping"
STREAM_PING_INTERVAL = 50 # seconds
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Register the API with the HTTP interface."""

View File

@@ -407,8 +407,9 @@ class AppleTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
# Protocol specific arguments
pair_args = {}
if self.protocol == Protocol.DMAP:
if self.protocol in {Protocol.AirPlay, Protocol.Companion, Protocol.DMAP}:
pair_args["name"] = "Home Assistant"
if self.protocol == Protocol.DMAP:
pair_args["zeroconf"] = await zeroconf.async_get_instance(self.hass)
# Initiate the pairing process

View File

@@ -7,7 +7,7 @@
"documentation": "https://www.home-assistant.io/integrations/apple_tv",
"iot_class": "local_push",
"loggers": ["pyatv", "srptools"],
"requirements": ["pyatv==0.11.0"],
"requirements": ["pyatv==0.12.0"],
"zeroconf": [
"_mediaremotetv._tcp.local.",
"_companion-link._tcp.local.",

View File

@@ -138,6 +138,9 @@ class AppleTvMediaPlayer(AppleTVEntity, MediaPlayerEntity):
# Listen to power updates
self.atv.power.listener = self
# Listen to volume updates
self.atv.audio.listener = self
if self.atv.features.in_state(FeatureState.Available, FeatureName.AppList):
self.hass.create_task(self._update_app_list())
@@ -203,6 +206,11 @@ class AppleTvMediaPlayer(AppleTVEntity, MediaPlayerEntity):
"""Update power state when it changes."""
self.async_write_ha_state()
@callback
def volume_update(self, old_level: float, new_level: float) -> None:
"""Update volume when it changes."""
self.async_write_ha_state()
@property
def app_id(self) -> str | None:
"""ID of the current running app."""

View File

@@ -57,6 +57,8 @@ CREATE_FIELDS = {
}
UPDATE_FIELDS: dict = {} # Not supported
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
@dataclass
class ClientCredential:

View File

@@ -5,5 +5,5 @@
"documentation": "https://www.home-assistant.io/integrations/apprise",
"iot_class": "cloud_push",
"loggers": ["apprise"],
"requirements": ["apprise==1.3.0"]
"requirements": ["apprise==1.4.0"]
}

View File

@@ -161,16 +161,13 @@ class ArestSwitchFunction(ArestSwitchBase):
class ArestSwitchPin(ArestSwitchBase):
"""Representation of an aREST switch. Based on digital I/O."""
def __init__(self, resource, location, name, pin, invert):
def __init__(self, resource, location, name, pin, invert) -> None:
"""Initialize the switch."""
super().__init__(resource, location, name)
self._pin = pin
self.invert = invert
request = requests.get(f"{resource}/mode/{pin}/o", timeout=10)
if request.status_code != HTTPStatus.OK:
_LOGGER.error("Can't set mode")
self._attr_available = False
self.__set_pin_output()
def turn_on(self, **kwargs: Any) -> None:
"""Turn the device on."""
@@ -200,7 +197,15 @@ class ArestSwitchPin(ArestSwitchBase):
request = requests.get(f"{self._resource}/digital/{self._pin}", timeout=10)
status_value = int(self.invert)
self._attr_is_on = request.json()["return_value"] != status_value
self._attr_available = True
if self._attr_available is False:
self._attr_available = True
self.__set_pin_output()
except requests.exceptions.ConnectionError:
_LOGGER.warning("No route to device %s", self._resource)
self._attr_available = False
def __set_pin_output(self) -> None:
request = requests.get(f"{self._resource}/mode/{self._pin}/o", timeout=10)
if request.status_code != HTTPStatus.OK:
_LOGGER.error("Can't set mode")
self._attr_available = False

View File

@@ -101,6 +101,11 @@ async def async_setup_platform(
) -> None:
"""Set up the ARWN platform."""
# Make sure MQTT integration is enabled and the client is available
if not await mqtt.async_wait_for_mqtt_client(hass):
_LOGGER.error("MQTT integration is not available")
return
@callback
def async_sensor_event_received(msg: mqtt.ReceiveMessage) -> None:
"""Process events as sensors.

View File

@@ -5,6 +5,7 @@ from collections.abc import AsyncIterable
from homeassistant.components import stt
from homeassistant.core import Context, HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
from .const import DOMAIN
@@ -33,8 +34,11 @@ __all__ = (
"Pipeline",
"PipelineEvent",
"PipelineEventType",
"PipelineNotFound",
)
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Assist pipeline integration."""
@@ -54,13 +58,10 @@ async def async_pipeline_from_audio_stream(
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"
)
"""Create an audio pipeline from an audio stream.
Raises PipelineNotFound if no pipeline is found.
"""
pipeline_input = PipelineInput(
conversation_id=conversation_id,
stt_metadata=stt_metadata,
@@ -68,13 +69,12 @@ async def async_pipeline_from_audio_stream(
run=PipelineRun(
hass,
context=context,
pipeline=pipeline,
pipeline=async_get_pipeline(hass, pipeline_id=pipeline_id),
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

@@ -19,7 +19,7 @@ class PipelineNotFound(PipelineError):
class SpeechToTextError(PipelineError):
"""Error in speech to text portion of pipeline."""
"""Error in speech-to-text portion of pipeline."""
class IntentRecognitionError(PipelineError):
@@ -27,4 +27,4 @@ class IntentRecognitionError(PipelineError):
class TextToSpeechError(PipelineError):
"""Error in text to speech portion of pipeline."""
"""Error in text-to-speech portion of pipeline."""

View File

@@ -5,7 +5,7 @@ import asyncio
from collections.abc import AsyncIterable, Callable, Iterable
from dataclasses import asdict, dataclass, field
import logging
from typing import Any
from typing import Any, cast
import voluptuous as vol
@@ -36,6 +36,7 @@ from .const import DOMAIN
from .error import (
IntentRecognitionError,
PipelineError,
PipelineNotFound,
SpeechToTextError,
TextToSpeechError,
)
@@ -125,7 +126,7 @@ async def _async_resolve_default_pipeline_settings(
stt_language = stt_languages[0]
else:
_LOGGER.debug(
"Speech to text engine '%s' does not support language '%s'",
"Speech-to-text engine '%s' does not support language '%s'",
stt_engine_id,
pipeline_language,
)
@@ -152,7 +153,7 @@ async def _async_resolve_default_pipeline_settings(
tts_voice = tts_voices[0].voice_id
else:
_LOGGER.debug(
"Text to speech engine '%s' does not support language '%s'",
"Text-to-speech engine '%s' does not support language '%s'",
tts_engine_id,
pipeline_language,
)
@@ -208,9 +209,7 @@ async def async_create_default_pipeline(
@callback
def async_get_pipeline(
hass: HomeAssistant, pipeline_id: str | None = None
) -> Pipeline | None:
def async_get_pipeline(hass: HomeAssistant, pipeline_id: str | None = None) -> Pipeline:
"""Get a pipeline by id or the preferred pipeline."""
pipeline_data: PipelineData = hass.data[DOMAIN]
@@ -218,7 +217,15 @@ def async_get_pipeline(
# 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)
pipeline = pipeline_data.pipeline_store.data.get(pipeline_id)
# If invalid pipeline ID was specified
if pipeline is None:
raise PipelineNotFound(
"pipeline_not_found", f"Pipeline {pipeline_id} not found"
)
return pipeline
@callback
@@ -332,12 +339,12 @@ class PipelineRun:
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)
stt_provider: stt.SpeechToTextEntity | stt.Provider = field(init=False)
tts_engine: str = field(init=False)
tts_options: dict | None = field(init=False, default=None)
def __post_init__(self) -> None:
@@ -387,9 +394,7 @@ class PipelineRun:
)
async def prepare_speech_to_text(self, metadata: stt.SpeechMetadata) -> None:
"""Prepare speech to text."""
stt_provider: stt.SpeechToTextEntity | stt.Provider | None = None
"""Prepare speech-to-text."""
# pipeline.stt_engine can't be None or this function is not called
stt_provider = stt.async_get_speech_to_text_engine(
self.hass,
@@ -400,7 +405,7 @@ class PipelineRun:
engine = self.pipeline.stt_engine
raise SpeechToTextError(
code="stt-provider-missing",
message=f"No speech to text provider for: {engine}",
message=f"No speech-to-text provider for: {engine}",
)
metadata.language = self.pipeline.stt_language or self.language
@@ -421,10 +426,7 @@ class PipelineRun:
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")
"""Run speech-to-text portion of pipeline. Returns the spoken text."""
if isinstance(self.stt_provider, stt.Provider):
engine = self.stt_provider.name
else:
@@ -446,10 +448,10 @@ class PipelineRun:
metadata, stream
)
except Exception as src_error:
_LOGGER.exception("Unexpected error during speech to text")
_LOGGER.exception("Unexpected error during speech-to-text")
raise SpeechToTextError(
code="stt-stream-failed",
message="Unexpected error during speech to text",
message="Unexpected error during speech-to-text",
) from src_error
_LOGGER.debug("speech-to-text result %s", result)
@@ -457,7 +459,7 @@ class PipelineRun:
if result.result != stt.SpeechResultState.SUCCESS:
raise SpeechToTextError(
code="stt-stream-failed",
message="Speech to text failed",
message="speech-to-text failed",
)
if not result.text:
@@ -546,8 +548,9 @@ class PipelineRun:
return speech
async def prepare_text_to_speech(self) -> None:
"""Prepare text to speech."""
engine = self.pipeline.tts_engine
"""Prepare text-to-speech."""
# pipeline.tts_engine can't be None or this function is not called
engine = cast(str, self.pipeline.tts_engine)
tts_options = {}
if self.pipeline.tts_voice is not None:
@@ -557,34 +560,31 @@ class PipelineRun:
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(
options_supported = await tts.async_support_options(
self.hass,
engine, # type: ignore[arg-type]
engine,
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",
message=f"Text-to-speech engine '{engine}' not found",
) from err
if not options_supported:
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}"
),
)
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")
"""Run text-to-speech portion of pipeline. Returns URL of TTS audio."""
self.process_event(
PipelineEvent(
PipelineEventType.TTS_START,
@@ -612,10 +612,10 @@ class PipelineRun:
None,
)
except Exception as src_error:
_LOGGER.exception("Unexpected error during text to speech")
_LOGGER.exception("Unexpected error during text-to-speech")
raise TextToSpeechError(
code="tts-failed",
message="Unexpected error during text to speech",
message="Unexpected error during text-to-speech",
) from src_error
_LOGGER.debug("TTS result %s", tts_media)
@@ -651,7 +651,7 @@ class PipelineInput:
"""Input for conversation agent. Required when start_stage = intent."""
tts_input: str | None = None
"""Input for text to speech. Required when start_stage = tts."""
"""Input for text-to-speech. Required when start_stage = tts."""
conversation_id: str | None = None
@@ -661,7 +661,7 @@ class PipelineInput:
current_stage = self.run.start_stage
try:
# Speech to text
# speech-to-text
intent_input = self.intent_input
if current_stage == PipelineStage.STT:
assert self.stt_metadata is not None
@@ -703,15 +703,15 @@ class PipelineInput:
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"
"the pipeline does not support speech-to-text"
)
if self.stt_metadata is None:
raise PipelineRunValidationError(
"stt_metadata is required for speech to text"
"stt_metadata is required for speech-to-text"
)
if self.stt_stream is None:
raise PipelineRunValidationError(
"stt_stream is required for speech to text"
"stt_stream is required for speech-to-text"
)
elif self.run.start_stage == PipelineStage.INTENT:
if self.intent_input is None:
@@ -721,12 +721,12 @@ class PipelineInput:
elif self.run.start_stage == PipelineStage.TTS:
if self.tts_input is None:
raise PipelineRunValidationError(
"tts_input is required for text to speech"
"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"
"the pipeline does not support text-to-speech"
)
start_stage_index = PIPELINE_STAGE_ORDER.index(self.run.start_stage)

View File

@@ -17,6 +17,7 @@ from homeassistant.helpers import config_validation as cv
from homeassistant.util import language as language_util
from .const import DOMAIN
from .error import PipelineNotFound
from .pipeline import (
PipelineData,
PipelineError,
@@ -85,8 +86,9 @@ async def websocket_run(
) -> None:
"""Run a pipeline."""
pipeline_id = msg.get("pipeline")
pipeline = async_get_pipeline(hass, pipeline_id=pipeline_id)
if pipeline is None:
try:
pipeline = async_get_pipeline(hass, pipeline_id=pipeline_id)
except PipelineNotFound:
connection.send_error(
msg["id"],
"pipeline-not-found",
@@ -151,7 +153,7 @@ async def websocket_run(
# Input to conversation agent
input_args["intent_input"] = msg["input"]["text"]
elif start_stage == PipelineStage.TTS:
# Input to text to speech system
# Input to text-to-speech system
input_args["tts_input"] = msg["input"]["text"]
input_args["run"] = PipelineRun(

View File

@@ -23,7 +23,7 @@ from homeassistant.exceptions import (
ConfigEntryNotReady,
HomeAssistantError,
)
from homeassistant.helpers import device_registry as dr, discovery_flow
from homeassistant.helpers import aiohttp_client, device_registry as dr, discovery_flow
from .activity import ActivityStream
from .const import CONF_BRAND, DOMAIN, MIN_TIME_BETWEEN_DETAIL_UPDATES, PLATFORMS
@@ -44,8 +44,11 @@ YALEXS_BLE_DOMAIN = "yalexs_ble"
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up August from a config entry."""
august_gateway = AugustGateway(hass)
# Create an aiohttp session instead of using the default one since the
# default one is likely to trigger august's WAF if another integration
# is also using Cloudflare
session = aiohttp_client.async_create_clientsession(hass)
august_gateway = AugustGateway(hass, session)
try:
await august_gateway.async_setup(entry.data)

View File

@@ -4,13 +4,16 @@ from dataclasses import dataclass
import logging
from typing import Any
import aiohttp
import voluptuous as vol
from yalexs.authenticator import ValidationResult
from yalexs.const import BRANDS, DEFAULT_BRAND
from homeassistant import config_entries
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import callback
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers import aiohttp_client
from .const import (
CONF_ACCESS_TOKEN_CACHE_FILE,
@@ -80,6 +83,7 @@ class AugustConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
def __init__(self):
"""Store an AugustGateway()."""
self._august_gateway: AugustGateway | None = None
self._aiohttp_session: aiohttp.ClientSession | None = None
self._user_auth_details: dict[str, Any] = {}
self._needs_reset = True
self._mode = None
@@ -87,7 +91,6 @@ class AugustConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
async def async_step_user(self, user_input=None):
"""Handle the initial step."""
self._august_gateway = AugustGateway(self.hass)
return await self.async_step_user_validate()
async def async_step_user_validate(self, user_input=None):
@@ -151,12 +154,30 @@ class AugustConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
},
)
@callback
def _async_get_gateway(self) -> AugustGateway:
"""Set up the gateway."""
if self._august_gateway is not None:
return self._august_gateway
# Create an aiohttp session instead of using the default one since the
# default one is likely to trigger august's WAF if another integration
# is also using Cloudflare
self._aiohttp_session = aiohttp_client.async_create_clientsession(self.hass)
self._august_gateway = AugustGateway(self.hass, self._aiohttp_session)
return self._august_gateway
@callback
def _async_shutdown_gateway(self) -> None:
"""Shutdown the gateway."""
if self._aiohttp_session is not None:
self._aiohttp_session.detach()
self._august_gateway = None
async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult:
"""Handle configuration by re-auth."""
self._user_auth_details = dict(entry_data)
self._mode = "reauth"
self._needs_reset = True
self._august_gateway = AugustGateway(self.hass)
return await self.async_step_reauth_validate()
async def async_step_reauth_validate(self, user_input=None):
@@ -206,7 +227,7 @@ class AugustConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
async def _async_auth_or_validate(self) -> ValidateResult:
"""Authenticate or validate."""
user_auth_details = self._user_auth_details
gateway = self._august_gateway
gateway = self._async_get_gateway()
assert gateway is not None
await self._async_reset_access_token_cache_if_needed(
gateway,
@@ -239,6 +260,8 @@ class AugustConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
async def _async_update_or_create_entry(self, info: dict[str, Any]) -> FlowResult:
"""Update existing entry or create a new one."""
self._async_shutdown_gateway()
existing_entry = await self.async_set_unique_id(
self._user_auth_details[CONF_USERNAME]
)

View File

@@ -7,7 +7,7 @@ import logging
import os
from typing import Any
from aiohttp import ClientError, ClientResponseError
from aiohttp import ClientError, ClientResponseError, ClientSession
from yalexs.api_async import ApiAsync
from yalexs.authenticator_async import AuthenticationState, AuthenticatorAsync
from yalexs.authenticator_common import Authentication
@@ -16,7 +16,6 @@ from yalexs.exceptions import AugustApiAIOHTTPError
from homeassistant.const import CONF_PASSWORD, CONF_TIMEOUT, CONF_USERNAME
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import aiohttp_client
from .const import (
CONF_ACCESS_TOKEN_CACHE_FILE,
@@ -35,12 +34,9 @@ _LOGGER = logging.getLogger(__name__)
class AugustGateway:
"""Handle the connection to August."""
def __init__(self, hass: HomeAssistant) -> None:
def __init__(self, hass: HomeAssistant, aiohttp_session: ClientSession) -> None:
"""Init the connection."""
# Create an aiohttp session instead of using the default one since the
# default one is likely to trigger august's WAF if another integration
# is also using Cloudflare
self._aiohttp_session = aiohttp_client.async_create_clientsession(hass)
self._aiohttp_session = aiohttp_session
self._token_refresh_lock = asyncio.Lock()
self._access_token_cache_file: str | None = None
self._hass: HomeAssistant = hass

View File

@@ -172,6 +172,7 @@ async def _async_migrate_old_unique_ids(hass, devices):
registry.async_update_entity(old_entity_id, new_unique_id=device.unique_id)
# pylint: disable-next=hass-invalid-inheritance # needs fixing
class AugustOperatorSensor(AugustEntityMixin, RestoreEntity, SensorEntity):
"""Representation of an August lock operation sensor."""

View File

@@ -7,6 +7,8 @@
# Developer note:
# vscode devcontainer: use the following to access USB device:
# "runArgs": ["-e", "GIT_EDITOR=code --wait", "--device=/dev/ttyUSB0"],
# and add the following to the end of script/bootstrap:
# sudo chmod 777 /dev/ttyUSB0
import logging

View File

@@ -149,6 +149,7 @@ from homeassistant.components.http.ban import log_invalid_auth
from homeassistant.components.http.data_validator import RequestDataValidator
from homeassistant.components.http.view import HomeAssistantView
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.config_entry_oauth2_flow import OAuth2AuthorizeCallbackView
from homeassistant.helpers.typing import ConfigType
from homeassistant.loader import bind_hass
@@ -161,6 +162,8 @@ DOMAIN = "auth"
StoreResultType = Callable[[str, Credentials], str]
RetrieveResultType = Callable[[str, str], Credentials | None]
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
@bind_hass
def create_auth_code(

View File

@@ -228,6 +228,20 @@ def automations_with_blueprint(hass: HomeAssistant, blueprint_path: str) -> list
]
@callback
def blueprint_in_automation(hass: HomeAssistant, entity_id: str) -> str | None:
"""Return the blueprint the automation is based on or None."""
if DOMAIN not in hass.data:
return None
component: EntityComponent[AutomationEntity] = hass.data[DOMAIN]
if (automation_entity := component.get_entity(entity_id)) is None:
return None
return automation_entity.referenced_blueprint
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up all automations."""
hass.data[DOMAIN] = component = EntityComponent[AutomationEntity](
@@ -578,6 +592,14 @@ class AutomationEntity(ToggleEntity, RestoreEntity):
await super().async_will_remove_from_hass()
await self.async_disable()
async def _async_enable_automation(self, event: Event) -> None:
"""Start automation on startup."""
# Don't do anything if no longer enabled or already attached
if not self._is_enabled or self._async_detach_triggers is not None:
return
self._async_detach_triggers = await self._async_attach_triggers(True)
async def async_enable(self) -> None:
"""Enable this automation entity.
@@ -594,16 +616,8 @@ class AutomationEntity(ToggleEntity, RestoreEntity):
self.async_write_ha_state()
return
async def async_enable_automation(event: Event) -> None:
"""Start automation on startup."""
# Don't do anything if no longer enabled or already attached
if not self._is_enabled or self._async_detach_triggers is not None:
return
self._async_detach_triggers = await self._async_attach_triggers(True)
self.hass.bus.async_listen_once(
EVENT_HOMEASSISTANT_STARTED, async_enable_automation
EVENT_HOMEASSISTANT_STARTED, self._async_enable_automation
)
self.async_write_ha_state()

View File

@@ -218,7 +218,7 @@ class AxisNetworkDevice:
"""Stop stream."""
if self.api.stream.state != State.STOPPED:
self.api.stream.connection_status_callback.clear()
self.api.stream.stop()
self.api.stream.stop()
async def shutdown(self, event) -> None:
"""Stop the event stream."""

View File

@@ -26,7 +26,7 @@
"iot_class": "local_push",
"loggers": ["axis"],
"quality_scale": "platinum",
"requirements": ["axis==47"],
"requirements": ["axis==48"],
"ssdp": [
{
"manufacturer": "AXIS"

View File

@@ -155,7 +155,6 @@ class AzureEventHub:
Suppress the INFO and below logging on the underlying packages,
they are very verbose, even at INFO.
"""
logging.getLogger("uamqp").setLevel(logging.WARNING)
logging.getLogger("azure.eventhub").setLevel(logging.WARNING)
self._listener_remover = self.hass.bus.async_listen(
MATCH_ALL, self.async_listen

View File

@@ -5,5 +5,5 @@
"documentation": "https://www.home-assistant.io/integrations/azure_service_bus",
"iot_class": "cloud_push",
"loggers": ["azure"],
"requirements": ["azure-servicebus==7.8.0"]
"requirements": ["azure-servicebus==7.10.0"]
}

View File

@@ -1,6 +1,7 @@
"""The Backup integration."""
from homeassistant.components.hassio import is_hassio
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
from .const import DOMAIN, LOGGER
@@ -8,6 +9,8 @@ from .http import async_register_http_views
from .manager import BackupManager
from .websocket import async_register_websocket_handlers
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Backup integration."""

View File

@@ -18,7 +18,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import integration_platform
from homeassistant.helpers.json import save_json
from homeassistant.util import dt
from homeassistant.util import dt as dt_util
from homeassistant.util.json import json_loads_object
from .const import DOMAIN, EXCLUDE_FROM_BACKUP, LOGGER
@@ -176,7 +176,7 @@ class BackupManager:
raise result
backup_name = f"Core {HAVERSION}"
date_str = dt.now().isoformat()
date_str = dt_util.now().isoformat()
slug = _generate_slug(date_str, backup_name)
backup_data = {

View File

@@ -5,7 +5,7 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/baf",
"iot_class": "local_push",
"requirements": ["aiobafi6==0.8.0"],
"requirements": ["aiobafi6==0.8.2"],
"zeroconf": [
{
"type": "_api._tcp.local.",

View File

@@ -27,7 +27,6 @@ class BAFNumberDescriptionMixin:
"""Required values for BAF sensors."""
value_fn: Callable[[Device], int | None]
mode: NumberMode
@dataclass
@@ -147,7 +146,6 @@ class BAFNumber(BAFEntity, NumberEntity):
self.entity_description = description
super().__init__(device, f"{device.name} {description.name}")
self._attr_unique_id = f"{self._device.mac_address}-{description.key}"
self._attr_mode = description.mode
@callback
def _async_update_attrs(self) -> None:

View File

@@ -104,7 +104,7 @@ class BaiduTTSProvider(Provider):
"""Return a list of supported options."""
return SUPPORTED_OPTIONS
def get_tts_audio(self, message, language, options=None):
def get_tts_audio(self, message, language, options):
"""Load TTS from BaiduTTS."""
aip_speech = AipSpeech(
@@ -113,14 +113,11 @@ class BaiduTTSProvider(Provider):
self._app_data["secretkey"],
)
if options is None:
result = aip_speech.synthesis(message, language, 1, self._speech_conf_data)
else:
speech_data = self._speech_conf_data.copy()
for key, value in options.items():
speech_data[_OPTIONS[key]] = value
speech_data = self._speech_conf_data.copy()
for key, value in options.items():
speech_data[_OPTIONS[key]] = value
result = aip_speech.synthesis(message, language, 1, speech_data)
result = aip_speech.synthesis(message, language, 1, speech_data)
if isinstance(result, dict):
_LOGGER.error(

View File

@@ -70,62 +70,6 @@ CONF_OPENED = "opened"
CONF_NOT_OPENED = "not_opened"
TURNED_ON = [
CONF_BAT_LOW,
CONF_CO,
CONF_COLD,
CONF_CONNECTED,
CONF_GAS,
CONF_HOT,
CONF_LIGHT,
CONF_NOT_LOCKED,
CONF_MOIST,
CONF_MOTION,
CONF_MOVING,
CONF_OCCUPIED,
CONF_OPENED,
CONF_PLUGGED_IN,
CONF_POWERED,
CONF_PRESENT,
CONF_PROBLEM,
CONF_RUNNING,
CONF_SMOKE,
CONF_SOUND,
CONF_UNSAFE,
CONF_UPDATE,
CONF_VIBRATION,
CONF_TAMPERED,
CONF_TURNED_ON,
]
TURNED_OFF = [
CONF_NOT_BAT_LOW,
CONF_NOT_COLD,
CONF_NOT_CONNECTED,
CONF_NOT_HOT,
CONF_LOCKED,
CONF_NOT_MOIST,
CONF_NOT_MOVING,
CONF_NOT_OCCUPIED,
CONF_NOT_OPENED,
CONF_NOT_PLUGGED_IN,
CONF_NOT_POWERED,
CONF_NOT_PRESENT,
CONF_NOT_TAMPERED,
CONF_NOT_UNSAFE,
CONF_NO_CO,
CONF_NO_GAS,
CONF_NO_LIGHT,
CONF_NO_MOTION,
CONF_NO_PROBLEM,
CONF_NOT_RUNNING,
CONF_NO_SMOKE,
CONF_NO_SOUND,
CONF_NO_VIBRATION,
CONF_TURNED_OFF,
]
ENTITY_TRIGGERS = {
BinarySensorDeviceClass.BATTERY: [
{CONF_TYPE: CONF_BAT_LOW},
@@ -245,6 +189,9 @@ ENTITY_TRIGGERS = {
],
}
TURNED_ON = [trigger[0][CONF_TYPE] for trigger in ENTITY_TRIGGERS.values()]
TURNED_OFF = [trigger[1][CONF_TYPE] for trigger in ENTITY_TRIGGERS.values()]
TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend(
{

View File

@@ -4,6 +4,8 @@
"condition_type": {
"is_bat_low": "{entity_name} battery is low",
"is_not_bat_low": "{entity_name} battery is normal",
"is_charging": "{entity_name} is charging",
"is_not_charging": "{entity_name} is not charging",
"is_co": "{entity_name} is detecting carbon monoxide",
"is_no_co": "{entity_name} is not detecting carbon monoxide",
"is_cold": "{entity_name} is cold",
@@ -56,6 +58,8 @@
"trigger_type": {
"bat_low": "{entity_name} battery low",
"not_bat_low": "{entity_name} battery normal",
"charging": "{entity_name} charging",
"not_charging": "{entity_name} not charging",
"co": "{entity_name} started detecting carbon monoxide",
"no_co": "{entity_name} stopped detecting carbon monoxide",
"cold": "{entity_name} became cold",
@@ -310,5 +314,11 @@
"smoke": "smoke",
"sound": "sound",
"vibration": "vibration"
},
"issues": {
"platform_integration_no_support": {
"title": "[%key:common::issues::platform_integration_no_support_title%]",
"description": "[%key:common::issues::platform_integration_no_support_description%]"
}
}
}

View File

@@ -20,5 +20,5 @@
"documentation": "https://www.home-assistant.io/integrations/blink",
"iot_class": "cloud_polling",
"loggers": ["blinkpy"],
"requirements": ["blinkpy==0.19.2"]
"requirements": ["blinkpy==0.21.0"]
}

View File

@@ -1,5 +1,6 @@
"""The blueprint integration."""
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
from . import websocket_api
@@ -15,6 +16,8 @@ from .errors import ( # noqa: F401
from .models import Blueprint, BlueprintInputs, DomainBlueprints # noqa: F401
from .schemas import is_blueprint_instance_config # noqa: F401
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the blueprint integration."""

View File

@@ -6,7 +6,6 @@ import logging
import platform
from typing import TYPE_CHECKING
from awesomeversion import AwesomeVersion
from bleak_retry_connector import BleakSlotManager
from bluetooth_adapters import (
ADAPTER_ADDRESS,
@@ -25,22 +24,18 @@ from bluetooth_adapters import (
from home_assistant_bluetooth import BluetoothServiceInfo, BluetoothServiceInfoBleak
from homeassistant.components import usb
from homeassistant.config_entries import (
SOURCE_IGNORE,
SOURCE_INTEGRATION_DISCOVERY,
ConfigEntry,
)
from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP
from homeassistant.config_entries import SOURCE_INTEGRATION_DISCOVERY, ConfigEntry
from homeassistant.const import EVENT_HOMEASSISTANT_STOP
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 import (
config_validation as cv,
device_registry as dr,
discovery_flow,
)
from homeassistant.helpers.debounce import Debouncer
from homeassistant.helpers.event import async_call_later
from homeassistant.helpers.issue_registry import (
IssueSeverity,
async_create_issue,
async_delete_issue,
)
from homeassistant.helpers.issue_registry import async_delete_issue
from homeassistant.loader import async_get_bluetooth
from . import models
@@ -117,7 +112,7 @@ __all__ = [
_LOGGER = logging.getLogger(__name__)
RECOMMENDED_MIN_HAOS_VERSION = AwesomeVersion("9.0.dev0")
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
async def _async_get_adapter_from_address(
@@ -127,43 +122,6 @@ async def _async_get_adapter_from_address(
return await _get_manager(hass).async_get_adapter_from_address(address)
@hass_callback
def _async_haos_is_new_enough(hass: HomeAssistant) -> bool:
"""Check if the version of Home Assistant Operating System is new enough."""
# Only warn if a USB adapter is plugged in
if not any(
entry
for entry in hass.config_entries.async_entries(DOMAIN)
if entry.source != SOURCE_IGNORE
):
return True
if (
not hass.components.hassio.is_hassio()
or not (os_info := hass.components.hassio.get_os_info())
or not (haos_version := os_info.get("version"))
or AwesomeVersion(haos_version) >= RECOMMENDED_MIN_HAOS_VERSION
):
return True
return False
@hass_callback
def _async_check_haos(hass: HomeAssistant) -> None:
"""Create or delete an the haos_outdated issue."""
if _async_haos_is_new_enough(hass):
async_delete_issue(hass, DOMAIN, "haos_outdated")
return
async_create_issue(
hass,
DOMAIN,
"haos_outdated",
is_fixable=False,
severity=IssueSeverity.WARNING,
learn_more_url="/config/updates",
translation_key="haos_outdated",
)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the bluetooth integration."""
integration_matcher = IntegrationMatcher(await async_get_bluetooth(hass))
@@ -236,12 +194,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
EVENT_HOMEASSISTANT_STOP, hass_callback(lambda event: cancel())
)
# Wait to check until after start to make sure
# that the system info is available.
hass.bus.async_listen_once(
EVENT_HOMEASSISTANT_STARTED,
hass_callback(lambda event: _async_check_haos(hass)),
)
async_delete_issue(hass, DOMAIN, "haos_outdated")
return True

View File

@@ -18,6 +18,7 @@ from bluetooth_adapters import (
)
from homeassistant import config_entries
from homeassistant.components.logger import EVENT_LOGGING_CHANGED
from homeassistant.core import (
CALLBACK_TYPE,
Event,
@@ -113,6 +114,7 @@ class BluetoothManager:
self.hass = hass
self._integration_matcher = integration_matcher
self._cancel_unavailable_tracking: CALLBACK_TYPE | None = None
self._cancel_logging_listener: CALLBACK_TYPE | None = None
self._advertisement_tracker = AdvertisementTracker()
@@ -136,6 +138,7 @@ class BluetoothManager:
self._bluetooth_adapters = bluetooth_adapters
self.storage = storage
self.slot_manager = slot_manager
self._debug = _LOGGER.isEnabledFor(logging.DEBUG)
@property
def supports_passive_scan(self) -> bool:
@@ -201,6 +204,11 @@ class BluetoothManager:
self._adapters = self._bluetooth_adapters.adapters
return self._find_adapter_by_address(address)
@hass_callback
def _async_logging_changed(self, event: Event) -> None:
"""Handle logging change."""
self._debug = _LOGGER.isEnabledFor(logging.DEBUG)
async def async_setup(self) -> None:
"""Set up the bluetooth manager."""
await self._bluetooth_adapters.refresh()
@@ -208,6 +216,9 @@ class BluetoothManager:
self._all_history, self._connectable_history = async_load_history_from_system(
self._bluetooth_adapters, self.storage
)
self._cancel_logging_listener = self.hass.bus.async_listen(
EVENT_LOGGING_CHANGED, self._async_logging_changed
)
self.async_setup_unavailable_tracking()
seen: set[str] = set()
for address, service_info in itertools.chain(
@@ -225,6 +236,9 @@ class BluetoothManager:
if self._cancel_unavailable_tracking:
self._cancel_unavailable_tracking()
self._cancel_unavailable_tracking = None
if self._cancel_logging_listener:
self._cancel_logging_listener()
self._cancel_logging_listener = None
uninstall_multiple_bleak_catcher()
@hass_callback
@@ -342,7 +356,6 @@ class BluetoothManager:
self,
old: BluetoothServiceInfoBleak,
new: BluetoothServiceInfoBleak,
debug: bool,
) -> bool:
"""Prefer previous advertisement from a different source if it is better."""
if new.time - old.time > (
@@ -351,7 +364,7 @@ class BluetoothManager:
)
):
# If the old advertisement is stale, any new advertisement is preferred
if debug:
if self._debug:
_LOGGER.debug(
(
"%s (%s): Switching from %s to %s (time elapsed:%s > stale"
@@ -370,7 +383,7 @@ class BluetoothManager:
):
# If new advertisement is RSSI_SWITCH_THRESHOLD more,
# the new one is preferred.
if debug:
if self._debug:
_LOGGER.debug(
(
"%s (%s): Switching from %s to %s (new rssi:%s - threshold:%s >"
@@ -414,7 +427,6 @@ class BluetoothManager:
old_connectable_service_info = connectable and connectable_history.get(address)
source = service_info.source
debug = _LOGGER.isEnabledFor(logging.DEBUG)
# This logic is complex due to the many combinations of scanners
# that are supported.
#
@@ -437,7 +449,7 @@ class BluetoothManager:
and (scanner := self._sources.get(old_service_info.source))
and scanner.scanning
and self._prefer_previous_adv_from_different_source(
old_service_info, service_info, debug
old_service_info, service_info
)
):
# If we are rejecting the new advertisement and the device is connectable
@@ -461,7 +473,7 @@ class BluetoothManager:
)
and connectable_scanner.scanning
and self._prefer_previous_adv_from_different_source(
old_connectable_service_info, service_info, debug
old_connectable_service_info, service_info
)
)
):
@@ -523,7 +535,7 @@ class BluetoothManager:
)
matched_domains = self._integration_matcher.match_domains(service_info)
if debug:
if self._debug:
_LOGGER.debug(
"%s: %s %s match: %s",
self._async_describe_source(service_info),

View File

@@ -1,10 +1,9 @@
{
"domain": "bluetooth",
"name": "Bluetooth",
"after_dependencies": ["hassio"],
"codeowners": ["@bdraco"],
"config_flow": true,
"dependencies": ["usb"],
"dependencies": ["logger", "usb"],
"documentation": "https://www.home-assistant.io/integrations/bluetooth",
"iot_class": "local_push",
"loggers": [
@@ -20,6 +19,6 @@
"bluetooth-adapters==0.15.3",
"bluetooth-auto-recovery==1.2.0",
"bluetooth-data-tools==0.4.0",
"dbus-fast==1.85.0"
"dbus-fast==1.86.0"
]
}

View File

@@ -1,10 +1,4 @@
{
"issues": {
"haos_outdated": {
"title": "Update to Home Assistant Operating System 9.0 or later",
"description": "To improve Bluetooth reliability and performance, we highly recommend you update to version 9.0 or later of the Home Assistant Operating System."
}
},
"config": {
"flow_title": "{name}",
"step": {

View File

@@ -2,10 +2,13 @@
from __future__ import annotations
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
DOMAIN = "bluetooth_adapters"
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up Bluetooth Adapters from a config entry.

View File

@@ -44,6 +44,7 @@ PLATFORMS = [
Platform.NUMBER,
Platform.SELECT,
Platform.SENSOR,
Platform.SWITCH,
]
SERVICE_UPDATE_STATE = "update_state"

View File

@@ -189,6 +189,14 @@ SENSOR_TYPES: tuple[BMWBinarySensorEntityDescription, ...] = (
icon="mdi:car-electric",
value_fn=lambda v: v.fuel_and_battery.is_charger_connected,
),
BMWBinarySensorEntityDescription(
key="is_pre_entry_climatization_enabled",
name="Pre entry climatization",
icon="mdi:car-seat-heater",
value_fn=lambda v: v.charging_profile.is_pre_entry_climatization_enabled
if v.charging_profile
else False,
),
)

View File

@@ -53,12 +53,6 @@ BUTTON_TYPES: tuple[BMWButtonEntityDescription, ...] = (
name="Activate air conditioning",
remote_function=lambda vehicle: vehicle.remote_services.trigger_remote_air_conditioning(),
),
BMWButtonEntityDescription(
key="deactivate_air_conditioning",
icon="mdi:hvac-off",
name="Deactivate air conditioning",
remote_function=lambda vehicle: vehicle.remote_services.trigger_remote_air_conditioning_stop(),
),
BMWButtonEntityDescription(
key="find_vehicle",
icon="mdi:crosshairs-question",
@@ -128,7 +122,4 @@ class BMWButton(BMWBaseEntity, ButtonEntity):
)
await self.entity_description.account_function(self.coordinator)
# Always update HA states after a button was executed.
# BMW remote services that change the vehicle's state update the local object
# when executing the service, so only the HA state machine needs further updates.
self.coordinator.async_update_listeners()

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